Skip to content

fix: automatic ear detection on AirPods Pro 2 below the RSSI floor - #237

Open
akhmadqasim wants to merge 2 commits into
SpriteOvO:mainfrom
akhmadqasim:fix/ear-detection-state-loss
Open

akhmadqasim wants to merge 2 commits into
SpriteOvO:mainfrom
akhmadqasim:fix/ear-detection-state-loss

Conversation

@akhmadqasim

Copy link
Copy Markdown

Fixes #86 (automatic ear detection never pausing/resuming on AirPods Pro 2).

What was happening

A --trace log from AirPods Pro 2 (USB-C, model 0x2024) sitting a few metres from the PC showed the in-ear bits in the advertisement changing correctly (0x2b → 0x23 → 0x29 → 0x2b), but none of those changes ever reached Manager::OnBothInEar:

  1. StateManager::IsPossibleDesiredAdv rejects every advertisement below rssi_min (default -80) before checking whether it comes from the address already being tracked. 26 of 38 advertisements in the log were dropped this way, including every one carrying an in-ear change (-82 / -88 dBm).
  2. With the weak advertisements dropped, the lost timer starves; 10 s later DoStateReset/DoLost wipe the cached state.
  3. The next accepted advertisement then arrives with oldState == nullopt, so OnStateChanged has nothing to compare against and never pauses or resumes.

Two follow-up gaps showed up on hardware once that was fixed: the pods alternate which one broadcasts, and a side's cached advertisement (where the address was read from) is dropped after 10 s of silence; and Windows occasionally reports RSSI -127 ("unavailable") on exactly the advertisement that carries the change, which both failed the floor and poisoned the rssiDiff heuristics.

Changes

  • StateManager keeps a per-side _knownAddress, written in UpdateAdv, that survives DoStateReset/DoLost/ResetAll and is cleared only in Disconnect() (classic Bluetooth drop or rebind). The RSSI floor is skipped for a known address; unknown addresses still have to clear the floor and the existing model/battery/rssiDiff heuristics.
  • RSSI -127 is treated as unknown: it never takes part in the rssiDiff checks, passes the floor for a known address, and is rejected with its own log reason for an unknown one.
  • New Details::EarDetectionTracker remembers the last "both in ear" value independently of StateManager's cached state. Manager feeds it from OnStateChanged and resets it only when the classic link to the bound device drops or the device is rebound — not on the BLE "device is lost" path.
  • The enabled path of OnBothInEar now logs the transition (only the disabled path did).
  • StateManager takes an Intervals struct so tests can shorten the lost/state-reset timers and exercise the real timer paths.

Tests

Tests/AirPodsDomainTests.cpp: ParsesInEarBitsPerBroadcastSide, AcceptsWeakAdvertisementFromTrackedAddress, EarDetectionTrackerSurvivesStateLoss, RemembersSideAddressAcrossStateReset, AcceptsUnknownRssiFromTrackedAddress, and two trace replays of the captured Pro 2 sequences (TraceReplayPausesOnceAndResumesOnce, TraceReplayFollowsAlternatingBroadcaster) that expect exactly one pause and one resume. FiltersDuplicateAndWeakAdvertisements now sends its weak advertisement from an unknown address.

Verified on hardware (AirPods Pro 2 USB-C, Windows 11): taking one pod out pauses within the next advertisement, putting it back resumes. Possibly related to #132 as well, since the state no longer flaps on weak signal.

Automatic ear detection never paused or resumed media on AirPods Pro 2
(USB-C) sitting a few metres from the PC. A --trace log showed the in-ear
bits changing correctly in the advertisement, but `StateManager` rejected
every advertisement below `rssi_min` before checking whether it came from
the address already being tracked. The weak advertisements carrying the
in-ear change were dropped, the lost timer starved, and 10 s later the
cached state was wiped. The next accepted advertisement then arrived with
`oldState == nullopt`, so `Manager::OnStateChanged` had nothing to compare
against and never called `OnBothInEar`.

- `StateManager::IsPossibleDesiredAdv` skips the RSSI floor when the
  advertisement address matches the cached advertisement of either side.
  Unknown addresses still have to clear the floor and the existing
  model/battery/rssiDiff heuristics.
- New `Details::EarDetectionTracker` remembers the last "both in ear"
  value independently of `StateManager`'s cached state. `Manager` feeds it
  from `OnStateChanged` and resets it only when the classic Bluetooth link
  to the bound device drops or the device is rebound, not on the BLE
  "device is lost" path.
- Log the in-ear transition on the enabled path of `OnBothInEar`.

Tests: `ParsesInEarBitsPerBroadcastSide`, `AcceptsWeakAdvertisementFromTrackedAddress`,
`EarDetectionTrackerSurvivesStateLoss` and `TraceReplayPausesOnceAndResumesOnce`
(replays the captured Pro 2 sequence). `FiltersDuplicateAndWeakAdvertisements`
now sends its weak advertisement from an unknown address, since a weak
advertisement from the tracked address is accepted by design.

Refs SpriteOvO#86, SpriteOvO#132
Hardware testing of the previous fix showed two remaining gaps when the
pods alternate which one broadcasts:

- `DoStateReset` drops a side's cached advertisement after 10 s of
  silence, and the same-address bypass read its address from that cache.
  When the quiet pod started broadcasting again it was treated as an
  unknown address and rejected until a strong advertisement arrived, so
  the in-ear change it carried was delayed or missed.
- Windows sometimes reports RSSI -127 ("unavailable") on the very
  advertisement that carries an in-ear change. It was rejected by the
  floor, and it also poisoned the rssiDiff heuristics (|-127 - x| > 50).

`StateManager` now keeps a per-side `_knownAddress` that is written in
`UpdateAdv`, survives `DoStateReset`/`DoLost`/`ResetAll`, and is cleared
only in `Disconnect()` (classic Bluetooth drop or rebind). The RSSI floor
consults it instead of the advertisement cache. RSSI -127 is treated as
unknown: it never participates in the rssiDiff checks, passes the floor
for a known address, and is rejected with a distinct log reason for an
unknown one.

`StateManager` takes an `Intervals` struct so tests can shorten the lost
and state-reset timers and exercise the real timer paths.

Tests: `RemembersSideAddressAcrossStateReset`,
`AcceptsUnknownRssiFromTrackedAddress`, and
`TraceReplayFollowsAlternatingBroadcaster` (replays the 08:53:25-08:54:20
hardware log). `TraceReplayPausesOnceAndResumesOnce` now reproduces the
"Device is lost" gaps with the real lost timer instead of `Disconnect()`.

Refs SpriteOvO#86, SpriteOvO#132
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report] Ear Detection not working for AirPods Pro 2nd gen

1 participant