Skip to content

fix(ble): require fresh advertisement for auto-reconnect#5912

Merged
jamesarich merged 2 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/ble-reconnect-fresh-advertisement
Jun 23, 2026
Merged

fix(ble): require fresh advertisement for auto-reconnect#5912
jamesarich merged 2 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/ble-reconnect-fresh-advertisement

Conversation

@jeremiah-k

Copy link
Copy Markdown
Contributor

Overview

This PR fixes BLE auto-reconnect for bonded radios when the initial targeted scan misses a low-duty advertising window.

Previously, BleRadioTransport.findDevice() fell back to the stale bonded device handle after a missed 2-second targeted scan. That handle could lack a fresh advertisement, causing KableBleConnection to enter autoConnect = true and wait for CONNECTION_TIMEOUT before the reconnect loop could try again.

Bonded reconnect now requires a fresh advertisement before connecting. The reconnect path keeps the fast targeted scan, escalates once to a bounded scan when needed, and reports the bonded device as unavailable if it still is not advertising.

Key changes

  • Changed bonded-device discovery to use a bounded fresh-advertisement path:
    • 2-second targeted scan.
    • 5-second escalated scan if the targeted scan misses.
    • RadioNotConnectedException if the bonded device is still not advertising.
  • Removed the stale bonded-handle fallback that could lead to autoConnect = true hangs.
  • Preserved non-bonded discovery retry behavior.
  • Added a shared scanForFreshDevice(timeout) helper so bonded and non-bonded discovery use the same single-scan primitive.
  • Skips screen-entry BLE auto-scan when a selected device already exists, letting selected-device reconnect own the fresh-advertisement scan.
  • Stops active discovery scans once the service reaches Connected.
  • Keeps manual scan controls available for explicit discovery or device switching.

Testing

  • Added regression coverage for bonded reconnect when the targeted scan misses but the escalated scan finds a fresh advertisement.
  • Added regression coverage for bonded reconnect when no fresh advertisement is found, verifying the stale bonded handle is not used and connectAndAwait is not called.
  • Updated the previous bonded fallback test to assert the new no-stale-handle behavior.
  • Added scan-lifecycle coverage for:
    • stopping active BLE scan on Connected.
    • stopping active network scan on Connected.
    • skipping screen-entry BLE auto-scan when a selected device exists.
    • allowing screen-entry BLE auto-scan when no selected device exists.

Breaking changes / migration

No breaking API or user-facing migration steps are required.

Rewrite the bonded branch of BleRadioTransport.findDevice() so a bonded
device is only returned after a fresh advertisement is observed,
escalating from the targeted scan to a bounded scan before failing with
a clear error.

BleRadioTransport.kt:
- findDevice() bonded branch runs the targeted scan
  (TARGETED_SCAN_TIMEOUT) first, then escalates to a bounded scan at
  SCAN_TIMEOUT before throwing RadioNotConnectedException; the stale
  bonded handle is never returned
- Extract scanForFreshDevice(timeout: Duration) helper that performs a
  single scan attempt and returns the first matching BleDevice or null;
  shared by the bonded and non-bonded branches so retry policy stays
  in findDevice()
- Non-bonded branch keeps SCAN_RETRY_COUNT attempts at SCAN_TIMEOUT
  with SCAN_RETRY_DELAY between attempts, now expressed via
  scanForFreshDevice
- RadioNotConnectedException for the bonded-not-advertising case
  reports "Bonded device is not advertising"
- SCAN_TIMEOUT gains a KDoc explaining its dual role (bonded
  escalation and non-bonded retry) and the bounded-scan budget
  rationale
- Preserve address.anonymize() at every log site in findDevice() and
  scanForFreshDevice() (bonded targeted miss, bonded escalated hit,
  bonded scan timeout, non-bonded scan entry, scan failure catch)

Tests:
- BleRadioTransportReconnectCrashTest.kt adds a local sequenced scanner
  driving two cases: a bonded device missed by the targeted scan but
  surfaced by the escalated scan returns the fresh scanned device, and
  a bonded device that never advertises produces zero connectAndAwait
  calls and a bounded failure, proving no stale-handle fallback
- BleRadioTransportTest.kt aligns existing assertions with the
  scanForFreshDevice helper and the bounded-escalation flow

No behavior change for: KableBleConnection, BleReconnectPolicy, the
attemptConnection race/gate logic, CONNECTION_TIMEOUT, UI code, TCP,
USB, unified transport work.
Stop active discovery scans on the Connections screen once
app-level connection state reaches Connected, and skip screen-entry
BLE auto-discovery when a device is already selected so the selected
device can reconnect through the transport's fresh-advertisement scan.

ScannerViewModel.kt:
- init block collects serviceRepository.connectionState and calls
  stopAllScans() on ConnectionState.Connected
- Add startBleAutoScan() that starts a BLE scan only when no device
  is selected (selectedAddressFlow null or NO_DEVICE_SELECTED); manual
  scan toggles still call startBleScan() directly so users can discover
  and switch devices while connected
- KDoc on startBleAutoScan() documents that Connections-screen
  discovery and selected-device reconnect scan are independent

ConnectionsScreen.kt:
- LifecycleStartEffect screen-entry auto-start calls
  scanModel.startBleAutoScan() instead of startBleScan()
- Refresh the surrounding comment to describe discovery semantics and
  the skip-when-selected behavior

Tests:
- Rename and invert the existing connectionState-transition test to
  assert that a Connected transition cancels an active BLE scan
- Add a mirror case asserting a Connected transition cancels an active
  network scan
- Add two startBleAutoScan cases: scan is skipped when a device is
  already selected, and scan starts when no device is selected
- Replace the "Scanning allowed in any connection state" section header
  with "Manual scan control"

No behavior change for: manual scan start/stop, BLE scan permissions,
scan lifecycle outside the Connected transition, transport-level
fresh-advertisement scan in BleRadioTransport.findDevice().
@github-actions github-actions Bot added the bugfix PR tag label Jun 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📄 Docs staleness check — advisory

This PR modifies user-facing UI source files but does not update any page under docs/en/user/ or docs/en/developer/.

⚠️ Doc changes propagate to 3 consumers: in-app docs browser, Jekyll site (GitHub Pages), and meshtastic.org (Docusaurus sync). Updating a page in docs/en/ automatically flows to all three.

Changed source files:

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt

What to check:

Changed area Likely doc page
feature/messaging/ docs/en/user/messages-and-channels.md
feature/node/ docs/en/user/nodes.md or docs/en/user/node-metrics.md
feature/map/ docs/en/user/map-and-waypoints.md
feature/connections/ docs/en/user/connections.md
feature/settings/ docs/en/user/settings-radio-user.md or docs/en/user/settings-module-admin.md
feature/firmware/ docs/en/user/firmware.md
feature/intro/ docs/en/user/onboarding.md
feature/discovery/ docs/en/user/discovery.md
feature/docs/ Internal docs infrastructure
core/ui/ docs/en/developer/codebase.md or component-specific user pages

New page checklist (if adding a new doc page):

  1. Create the .md file in docs/en/user/ or docs/en/developer/ with last_updated frontmatter
  2. Register in DocBundleLoader.kt with string resources (in-app browser)
  3. Jekyll and Docusaurus sync pick up new pages automatically — no config change needed

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this check.

Cross-platform note: This check is advisory while doc coverage matures. Both Android and Apple repos use the same skip-docs-check label and advisory severity. See meshtastic/design standards for shared conventions.

@github-actions

Copy link
Copy Markdown
Contributor

🖼️ Preview staleness check — advisory

This PR modifies UI composables but does not update any *Previews.kt files.

Previews power screenshot tests and in-app docs screenshots. Keeping them current ensures visual regression coverage stays accurate.

Changed UI files:

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt

What to check:

Pattern Preview file convention
feature/{name}/…/ui/ or component/ feature/{name}/…/*Previews.kt
core/ui/…/ core/ui/…/ (previews colocated)

Adding previews checklist:

  1. Create or update a *Previews.kt file in the same module with @PreviewLightDark
  2. Add @Suppress("PreviewPublic") if the preview is consumed by screenshot-tests
  3. Add corresponding @PreviewTest function in screenshot-tests/src/screenshotTest/
  4. Run ./gradlew :screenshot-tests:updateDebugScreenshotTest to generate reference images

If this PR does not require preview updates (e.g., logic-only change, non-visual refactor), add the skip-preview-check label to dismiss.

@jeremiah-k jeremiah-k marked this pull request as ready for review June 23, 2026 12:00
@jamesarich

Copy link
Copy Markdown
Collaborator

Nit — stale KDoc in MeshtasticBleDevice (out of this diff, low-pri follow-up)

core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt:28–35 says:

"When created from the OS bonded device list (address only), [advertisement] is null and the peripheral is constructed via createPeripheral(address) with autoConnect = true."

After this PR, BleRadioTransport.findDevice() either returns a freshly-scanned device (which always has an advertisement) or throws RadioNotConnectedException. The advertisement == null / createPeripheral(address) / autoConnect = true path is no longer reachable via the normal reconnect flow.

Suggested replacement:

/**
 * Unified [BleDevice] implementation for all BLE devices — scanned, bonded, or both.
 *
 * When created from a live BLE scan, [advertisement] is populated and used for optimal peripheral
 * construction via `Peripheral(advertisement)` with a direct (non-autoConnect) connection attempt.
 *
 * Bonded-only devices (address only, [advertisement] null) can still be constructed — e.g. for
 * display in the device list — but [BleRadioTransport.findDevice] requires a fresh advertisement
 * before connecting and will throw [RadioNotConnectedException] rather than returning a stale handle.
 *
 * @param address The device's MAC address (or platform identifier string).
 * @param name The device's display name, if known.
 * @param advertisement The Kable [Advertisement] from a live scan, or `null` for bonded-only devices.
 */

Fine to handle in a separate follow-up; just flagging so the doc doesn't mislead the next reader.

@jamesarich jamesarich added this pull request to the merge queue Jun 23, 2026
Merged via the queue into meshtastic:main with commit 31c2c2b Jun 23, 2026
22 checks passed
@jeremiah-k jeremiah-k deleted the bugfix/ble-reconnect-fresh-advertisement branch June 23, 2026 15:42
jeremiah-k pushed a commit to jeremiah-k/Meshtastic-Android that referenced this pull request Jun 23, 2026
…econnect flow (meshtastic#5913)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants