feat: include scanner slot diagnostics in no-backend BleakError - #422
feat: include scanner slot diagnostics in no-backend BleakError#422bluetoothbot wants to merge 7 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #422 +/- ##
==========================================
- Coverage 95.19% 95.13% -0.06%
==========================================
Files 14 14
Lines 1913 1933 +20
Branches 232 237 +5
==========================================
+ Hits 1821 1839 +18
- Misses 53 54 +1
- Partials 39 40 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Merging this PR will not alter performance
Performance Changes
Comparing |
|
We should include benchmarks. This may fire over and over |
PR Review — feat: include scanner slot diagnostics in no-backend BleakErrorThe diagnostic split ("heard but not usable" vs "never heard") is a real user-experience improvement for the stuck-proxy case in issue #340, and the test additions cover both branches. Two blocking-ish items before merge: (1) the 🟡 Important1. Duplicates rejection logic in `_async_get_backend_for_ble_device` — leads to unreachable `"unknown"` branch (`src/habluetooth/wrappers.py`, L698-705)This block re-derives the rejection reason that
🟢 Suggestions1. `"local slot unavailable"` is a misleading label (`src/habluetooth/wrappers.py`, L700)
2. Calling a private method on `BaseHaScanner` from `wrappers.py` (`src/habluetooth/wrappers.py`, L697)
3. Test name implies slot-exhaustion coverage, but exercises the connector-rejection branch (`tests/test_wrappers.py`, L1446-1481)The test is named 4. Docstring is longer than the function and restates the PR description (`src/habluetooth/wrappers.py`, L658-672)Per the repo's CLAUDE.md ( 5. Benchmark doesn't include the `sorted_devices` lookup it depends on (`tests/test_benchmark_base_scanner.py`, L1061-1086)
6. PR description and code disagree on the trailing message (`src/habluetooth/wrappers.py`, L649-651)The PR body advertises the new message as Checklist
SummaryThe diagnostic split ("heard but not usable" vs "never heard") is a real user-experience improvement for the stuck-proxy case in issue #340, and the test additions cover both branches. Two blocking-ish items before merge: (1) the Automated review by Kōan909e202 |
Agree, and I think this should block merge. The diagnostic only fires on the terminal raise path in
If you want I can fold those into this PR. |
Rebase with requested adjustmentsBranch Changes applied
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
There was a problem hiding this comment.
Pull request overview
This PR improves the actionable diagnostics when HaBleakClientWrapper.connect() exhausts all candidate scanners and raises the “No backend with an available connection slot…” BleakError, by appending scanner-specific context (e.g., which scanners heard the device and their slot/in-progress state).
Changes:
- Append a new detail string to the existing “No backend with an available connection slot…”
BleakErrorto help distinguish “not heard recently” vs “heard but not usable”. - Add wrapper tests covering both the “heard” and “not heard” diagnostic branches.
- Add a benchmark measuring the cost of generating the diagnostic string with many scanners.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/habluetooth/wrappers.py |
Adds _describe_unavailable_scanners() and appends its output to the no-backend BleakError. |
tests/test_wrappers.py |
Adds unit tests asserting the new diagnostic text is included in the error message. |
tests/test_benchmark_base_scanner.py |
Adds a benchmark for generating the diagnostic string with 20 scanners. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| details: list[str] = [] | ||
| for device in sorted_devices: | ||
| scanner = device.scanner | ||
| allocations = scanner.get_allocations() | ||
| slot_info = ( | ||
| f"slots={allocations.free}/{allocations.slots} free" | ||
| if allocations is not None | ||
| else "no slot info" | ||
| ) | ||
| details.append( | ||
| f"{scanner.name} ({slot_info}, " | ||
| f"in_progress={scanner._connections_in_progress()})" | ||
| ) | ||
| return ( | ||
| f"Tried {len(sorted_devices)} scanner(s) that heard this address, " | ||
| f"none had a free connection slot: {'; '.join(details)}" | ||
| ) |
| scanners: list[BaseHaRemoteScanner] = [] | ||
| cancels: list[Callable[[], None]] = [] | ||
| unsetups: list[Callable[[], None]] = [] | ||
| for i in range(20): | ||
| source = f"proxy_{i:02d}" | ||
| scanner = StuckProxyScanner(source, source, connector, True) | ||
| unsetups.append(scanner.async_setup()) | ||
| cancels.append(manager.async_register_scanner(scanner)) |
| """Error message should describe which scanners were tried and their slot state.""" | ||
| manager = _get_manager() | ||
| scanner = FakeScanner("proxy_a", "proxy_a_name", None, True) | ||
| cancel = manager.async_register_scanner(scanner) | ||
|
|
||
| address = "00:00:00:00:00:42" | ||
| device = generate_ble_device(address, "Test Device", {"source": "proxy_a"}) | ||
| adv_data = generate_advertisement_data( | ||
| local_name="Test Device", service_uuids=[], rssi=-50 | ||
| ) | ||
| scanner.inject_advertisement(device, adv_data) | ||
| await asyncio.sleep(0) | ||
|
|
||
| # FakeScanner has no connector → can_connect() path returns no backend, | ||
| # so the loop exhausts and the diagnostic branch fires. |
This is addressed in the current diff: One nit worth flagging while we're tuning this path: since the message fires every failed connect for affected devices, the per-iteration cost will be dominated by |
Rebase with requested adjustmentsBranch Changes applied
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
82a6129 to
a85db13
Compare
This line is uncovered. The PR cannot be merged without 100% cover |
Two ways to close this: Option A (quickest, one test): Add a test for the local-scanner path. A Option B (better long-term): Refactor If the goal is to ship this PR fast, Option A is the path. If we expect to add more reason strings (e.g., when |
When connect() exhausts all candidate scanners, the BleakError now explains why instead of just saying "no backend was found". The two operationally distinct failure modes — no scanner heard the device vs. all scanners are out of slots — produce different messages, so users hitting issue #340-style stuck proxies see the slot state in the error itself without having to enable debug logging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o-backend diagnostic
Option B it is |
Rebase with requested adjustmentsBranch StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
ec7e8fc to
ed05dd2
Compare
Agreed this needs to be resolved before merge. Two options, in order of preference:
If you take (1) for this PR to land the user-facing improvement quickly, please open a follow-up issue for (2) so the duplicate classification doesn't outlive the urgency of #340. |
+1 on Option B as the structural answer. Worth noting the third benefit beyond the two you listed: it lets the manager loop accumulate one rejection reason per scanner at the moment of rejection, which closes the race window where If this PR ships Option A (with a |
Benchmark is included in this PR — |
Rebase with requested adjustmentsBranch StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
|
coverage is missing .. needs 100% |
Rebase with requested adjustmentsBranch StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
|
@bluetoothbot recreate |
Recreated: feat: include scanner slot diagnostics in no-backend BleakErrorBranch Branch Diff: 3 files changed, 265 insertions(+), 17 deletions(-) Tests: FAILED (non-blocking) Actions
Automated by Kōan |
…diagnostics in no-backend BleakError
Rebase with requested adjustmentsBranch Changes applied
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
What
When
HaBleakClientWrapper.connect()exhausts all candidate scanners, theBleakErrornow describes why no scanner could connect.Why
Issue #340 (stuck proxy slots) surfaces in user logs as the opaque message:
Two operationally distinct failure modes collapse into the same string:
The first needs device/range troubleshooting; the second needs a proxy
reboot. Users have no way to tell which from the error alone — they have to
turn on debug logging and find the prior INFO log to see scanner state.
This PR puts the diagnostic in the error itself so production logs are
actionable on the first failure.
How
Added
_describe_unavailable_scanners()towrappers.py. It branches onwhether any scanner heard the address:
"No connectable scanner has detected this address recently (N connectable scanner(s) registered).""Tried N scanner(s) that heard this address, none had a free connection slot: name (slots=0/3 free, in_progress=0); ..."The leading prefix (
"No backend with an available connection slot that can reach address … was found.") is preserved so existing tests / log grepskeep working — the diagnostic is appended.
Testing
test_no_backend_error_includes_scanner_slot_diagnostics: onescanner heard the device, FakeScanner has no connector → loop exhausts
→ asserts the new "Tried 1 scanner(s)" detail with scanner name + slot
state appears.
test_no_backend_error_when_no_scanner_heard_address: scannerregistered but never injects → asserts the "No connectable scanner has
detected this address recently" branch.
🤖 Generated with Claude Code
Quality Report
Changes: 2 files changed, 128 insertions(+), 29 deletions(-)
Code scan: clean
Tests: failed (FAILED)
Branch hygiene: clean
Generated by Kōan post-mission quality pipeline