Skip to content

fix(linux): duty-cycle BLE scan so other bonded LE devices can reconnect - #750

Open
pedrosekine wants to merge 1 commit into
librepods-org:mainfrom
pedrosekine:fix/ble-scan-duty-cycle
Open

fix(linux): duty-cycle BLE scan so other bonded LE devices can reconnect#750
pedrosekine wants to merge 1 commit into
librepods-org:mainfrom
pedrosekine:fix/ble-scan-duty-cycle

Conversation

@pedrosekine

Copy link
Copy Markdown

Problem

BleManager sets setLowEnergyDiscoveryTimeout(0) ("continuous scanning"), so the BlueZ discovery session stays open for as long as scanning is wanted — which is the entire time the AirPods are not connected (initializeBluetooth(), and again on disconnect).

The Linux kernel will not re-arm the passive accept-list scan while any discovery session is active — hci_update_passive_scan_sync() returns early unless discovery.state == DISCOVERY_STOPPED. That passive scan is precisely the mechanism that reconnects bonded LE peripherals.

The result: while librepods is scanning, no other bonded LE device on the machine can auto-reconnect. On my laptop, an MX Master 3 mouse and MX Keys keyboard silently stopped reconnecting after suspend and had to be connected by hand every time. With the AirPods left in their case overnight, the adapter sat at Discovering: yes for 13 hours straight.

It's an easy one to misattribute — it looks like a mouse, BlueZ, or kernel problem, and nothing points at the AirPods daemon.

Reproducing

$ bluetoothctl show | grep Discovering
        Discovering: yes      # stuck on, with no scan running anywhere

Stop librepods and a bonded LE mouse reconnects on its own within seconds.

Fix

Scan in bounded 4s windows with an 8s idle gap. The idle gap lets BlueZ drop the discovery session so the kernel can re-arm passive scanning; the next window re-arms ours.

# after
08:45:31 Discovering=yes    08:45:43 Discovering=yes
08:45:34 Discovering=no     08:45:46 Discovering=no

This also makes onScanFinished() reachable. With a 0 timeout the agent never emitted finished(), and the restart inside it was guarded by if (discoveryAgent->isActive()) — a condition that cannot hold in a finished() handler, so the branch was dead regardless.

isScanning() now also reports true across the idle gap, since its one caller (onSystemGoingToSleep) means "are we in a scanning state", not "is the radio scanning this instant".

Detection coverage

Unchanged. On my adapter a single 4s window sees the same 11 Apple manufacturer-data advertisers that a continuous scan does — BLE advertisers broadcast every 20ms–2s, so a 4s window is far longer than needed. The cost is up to 8s of extra latency before an advertising device is first noticed. Once connected, everything runs over the AAP L2CAP link and is untouched by this change.

Happy to make the two constants configurable, or to tune them, if you'd prefer different values.

Testing

On Arch Linux, BlueZ 5.87, Qt 6, MediaTek MT7922 adapter, AirPods Pro:

  • ✅ Mouse and keyboard auto-reconnect across suspend/resume — the original bug, confirmed fixed
  • ✅ Duty cycle behaves as intended under the real systemd user service
  • ✅ 4s window sees the same advertiser set as a continuous scan
  • ✅ AAP path unaffected: noise-control commands round-trip (Transparency → ANC → Adaptive), ear detection parses correctly, media detection works
  • ⚠️ Not verified: the 0x07 proximity-pairing parse path. My AirPods never emitted a 0x07 advert during testing — not in 20s of continuous scanning, nor a 2-minute capture across case open/close cycles — so I had nothing to feed the parser. This patch doesn't touch that code, and a continuous scan saw no 0x07 either, so I don't believe it's affected. Flagging it rather than implying coverage I don't have.

Unrelated issues noticed

Not touched here, happy to file separately:

  1. The single-instance guard can never firemain() calls QLocalServer::removeServer("app_server") and deletes /tmp/app_server immediately before probing it with socket_check.connectToServer("app_server"), so the connect always fails.
  2. --headless isn't parsed — the arg loop only handles --debug and --hide, though the flag appears in the systemd unit in the README.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bsha6PUDqP7yiNEMM8UG5a

BleManager set setLowEnergyDiscoveryTimeout(0), so the discovery session
stayed open for as long as the scan was wanted - which is the whole time
the AirPods are not connected.

The Linux kernel will not re-arm the passive accept-list scan while any
discovery session is active (hci_update_passive_scan_sync returns early
unless discovery.state == DISCOVERY_STOPPED). That passive scan is what
reconnects bonded LE peripherals, so while librepods was scanning, no
other bonded LE device on the machine could auto-reconnect at all. On my
laptop an MX Master 3 and MX Keys stopped reconnecting after suspend and
had to be connected by hand every time.

Scan in bounded 4s windows with an 8s idle gap instead. The idle gap lets
BlueZ drop the discovery session so the kernel can re-arm passive
scanning; the next window re-arms ours.

This also makes onScanFinished() reachable. With a 0 timeout the agent
never emitted finished(), and its restart was guarded by
`if (discoveryAgent->isActive())` - a condition that cannot hold in a
finished() handler, so the branch was dead either way.

Detection coverage is unchanged: on my adapter a single 4s window sees
the same 11 Apple manufacturer-data advertisers that a continuous scan
does, since advertisers broadcast every 20ms-2s. The cost is up to 8s of
extra latency before an advertising device is first noticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bsha6PUDqP7yiNEMM8UG5a
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.

1 participant