fix(ble): survive dual-mode bearer selection and late GATT resolution - #3
Open
X-Ryl669 wants to merge 1 commit into
Open
fix(ble): survive dual-mode bearer selection and late GATT resolution#3X-Ryl669 wants to merge 1 commit into
X-Ryl669 wants to merge 1 commit into
Conversation
Pairing failed on a laptop whose phone was ALSO paired as a classic
Bluetooth audio device. Three separate faults, found while debugging a
Redmi-style dual-mode phone on BlueZ 5.87 / KDE:
1. `VortexClient::connect` gated on `Device::is_connected()`. BlueZ's
`Connected` is one property per device, true when EITHER bearer is up,
so a phone merely streaming A2DP satisfied it. Vortex then skipped
`Connect()` entirely and polled an empty GATT service list for 15 s.
Link state now goes through `gatt_link_state()`, which requires a live
connection AND at least one GATT service, and distinguishes a
classic-only link (services resolved, zero GATT services — that is
SDP, not ATT) from one still resolving.
2. When BlueZ hands us a classic-only link, ask for the LE bearer
explicitly with a second `Connect()`. Per BlueZ's documented "connect
any disconnected bearer if one is already connected", `dev_connect()`
switches to `device_connect_le()` once BR/EDR is up with a profile
connected. Gated strictly on `ClassicOnly` so a healthy but
still-resolving LE link never gets a needless A2DP pull-up.
(`PreferredBearer = "le"` is NOT a fix here: it is experimental-gated,
and `select_conn_bearer()` evaluates the bonded-bearer clause first.)
3. The service-discovery loop propagated `device.services()` errors with
`?`. On a freshly established LE link BlueZ has not set
ServicesResolved yet and bluer reports that as an *error*, not an
empty list — so the loop aborted on its first poll, in exactly the
case it exists to wait out. Every error is now "not ready yet", with
the enclosing timeout bounding the wait. This one blocked pairing
outright; the Noise handshake never sent msg1.
A discovery timeout on a classic link now reports `ClassicBearerOnly`
with the remedy, instead of a bare "timeout: service discovery", and
`cmd_pairing` logs pairing failures — previously the error was emitted
to the frontend and dropped, and `PairingOverlay.vue` renders every
failure as the MITM "codes didn't match" screen, so the real cause was
lost on both ends.
Not fixed here, both pre-existing:
- `PairingOverlay.vue:33` reports all failures as an SAS mismatch.
- `worker.rs` registers its Just Works agent with
`request_default: false`, so on KDE bluedevil handles bonds and
prompts for numeric comparison — the flow that comment avoids.
Root cause of the original report was environmental, not in this diff:
cross-transport key derivation from the classic bond gives BlueZ an IRK,
which collapses the phone's rotating RPA onto its identity address; LE
connects then go through an accept-list-filtered passive scan that can
never match a rotating address. Removing the classic bond restores the
RPA and LE connects in ~2.5 s. Separately, BlueZ 5.87's `rap` plugin
reads authentication-gated `RAS Features` on every LE connection and
escalates to a bonded MITM pairing, which kills unauthenticated GATT;
`bluetoothd --noplugin=rap` works around it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was authored by Claude, to solve #2. The main issue is that the phone is paired with my laptop for audio initially and this collided with Vortex's pairing mechanism. Even after forgetting the connection, it would still fail, because of some BlueZ plugin attempting a BLE's encrypted connection before Vortex could deal with its own noise protocol. So the solution is in this commit, and in disabling BlueZ's 'rap' plugin too.
I've done this way:
/etc/systemd/system/bluetooth.service.d/noplugin-rap.conf:sudo systemctl daemon-reload && sudo systemctl restart bluetooth)Claude utterance below for detailed description...
Pairing failed on a laptop whose phone was ALSO paired as a classic Bluetooth audio device. Three separate faults, found while debugging a Redmi-style dual-mode phone on BlueZ 5.87 / KDE:
VortexClient::connectgated onDevice::is_connected(). BlueZ'sConnectedis one property per device, true when EITHER bearer is up, so a phone merely streaming A2DP satisfied it. Vortex then skippedConnect()entirely and polled an empty GATT service list for 15 s. Link state now goes throughgatt_link_state(), which requires a live connection AND at least one GATT service, and distinguishes a classic-only link (services resolved, zero GATT services — that is SDP, not ATT) from one still resolving.When BlueZ hands us a classic-only link, ask for the LE bearer explicitly with a second
Connect(). Per BlueZ's documented "connect any disconnected bearer if one is already connected",dev_connect()switches todevice_connect_le()once BR/EDR is up with a profile connected. Gated strictly onClassicOnlyso a healthy but still-resolving LE link never gets a needless A2DP pull-up. (PreferredBearer = "le"is NOT a fix here: it is experimental-gated, andselect_conn_bearer()evaluates the bonded-bearer clause first.)The service-discovery loop propagated
device.services()errors with?. On a freshly established LE link BlueZ has not set ServicesResolved yet and bluer reports that as an error, not an empty list — so the loop aborted on its first poll, in exactly the case it exists to wait out. Every error is now "not ready yet", with the enclosing timeout bounding the wait. This one blocked pairing outright; the Noise handshake never sent msg1.A discovery timeout on a classic link now reports
ClassicBearerOnlywith the remedy, instead of a bare "timeout: service discovery", andcmd_pairinglogs pairing failures — previously the error was emitted to the frontend and dropped, andPairingOverlay.vuerenders every failure as the MITM "codes didn't match" screen, so the real cause was lost on both ends.Not fixed here, both pre-existing:
PairingOverlay.vue:33reports all failures as an SAS mismatch.worker.rsregisters its Just Works agent withrequest_default: false, so on KDE bluedevil handles bonds and prompts for numeric comparison — the flow that comment avoids.Root cause of the original report was environmental, not in this diff: cross-transport key derivation from the classic bond gives BlueZ an IRK, which collapses the phone's rotating RPA onto its identity address; LE connects then go through an accept-list-filtered passive scan that can never match a rotating address. Removing the classic bond restores the RPA and LE connects in ~2.5 s. Separately, BlueZ 5.87's
rapplugin reads authentication-gatedRAS Featureson every LE connection and escalates to a bonded MITM pairing, which kills unauthenticated GATT;bluetoothd --noplugin=rapworks around it.