Skip to content

fix(ble): survive dual-mode bearer selection and late GATT resolution - #3

Open
X-Ryl669 wants to merge 1 commit into
zoir-dev:mainfrom
X-Ryl669:fix/ble-le-bearer-and-discovery
Open

fix(ble): survive dual-mode bearer selection and late GATT resolution#3
X-Ryl669 wants to merge 1 commit into
zoir-dev:mainfrom
X-Ryl669:fix/ble-le-bearer-and-discovery

Conversation

@X-Ryl669

Copy link
Copy Markdown

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:

  1. Create in /etc/systemd/system/bluetooth.service.d/noplugin-rap.conf:
[Service]
ExecStart=
ExecStart=/usr/lib/bluetooth/bluetoothd --noplugin=rap
  1. Restart bluetooth stack so rap pluggin doesn't load and doesn't take over the LE connection (with sudo systemctl daemon-reload && sudo systemctl restart bluetooth)
  2. Kill vortex app on both system and restart them.
  3. Pair.

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:

  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.

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.
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.

2 participants