Skip to content

feat(manager): add AUTO scanning mode with on-demand active windows - #448

Merged
bdraco merged 80 commits into
mainfrom
auto-scan-mode
May 22, 2026
Merged

feat(manager): add AUTO scanning mode with on-demand active windows#448
bdraco merged 80 commits into
mainfrom
auto-scan-mode

Conversation

@bdraco

@bdraco bdraco commented May 22, 2026

Copy link
Copy Markdown
Member

Today every scanner runs in one fixed mode for its entire lifetime, so an integration that just needs an occasional 5s active sweep every few minutes forces the whole scanner into permanent active scanning. This adds a third BluetoothScanningMode value, AUTO, where scanners default to passive and the manager schedules short active windows on demand.

Callers declare a per-device active-scan need via BluetoothManager.async_register_active_scan(address, scan_interval, scan_duration). The scheduler indexes by address so the on_advertisement hot path is just two dict lookups; nothing is iterated when the advertisement does not match a registered address. Multiple registrations for the same address with different cadences coexist and fire on their own intervals, coalescing only when due in the same tick. scan_interval and scan_duration are validated at registration time. Each AUTO scanner also runs a 15s rediscovery sweep 10 minutes after it joins and every 12 hours thereafter, staggered (first-sweep offset wraps within the initial-sweep window) so concurrently-registered scanners do not all flip ACTIVE in the same second.

Each AUTO scanner gets one persistent worker task that sleeps on an asyncio.Event with a wait_for timeout until the next due event; new advertisements and registrations just set the wake event, so no task is allocated per dispatch.

BaseHaScanner grows a no-op async_request_active_window(duration); HaScanner implements it by stop/restarting the BleakScanner in ACTIVE mode under the existing _start_stop_lock with a scheduled return to the prior mode. On Linux the 4th-attempt fallback to PASSIVE is detected and reported as a failed window so the scheduler does not believe an active sweep ran; if the active restart itself raises ScannerStartError the scanner is brought back up in the underlying AUTO/passive mode rather than left stopped. Remote scanners override on their side; the bleak-esphome wiring to aioesphomeapi.bluetooth_scanner_set_mode lands in Bluetooth-Devices/bleak-esphome#343 and the aioshelly wiring in home-assistant-libs/aioshelly#1147. AUTO is opt in, existing ACTIVE/PASSIVE callers are unchanged.

The bleak callback registration path is unchanged; bleak itself has no cadence concept so the kwargs an earlier revision tried to add there were dropped. Home Assistant integrations reach the new API via async_register_callback kwargs in home-assistant/core#171806.

Introduces a third BluetoothScanningMode value (AUTO) where scanners
default to passive and the manager schedules short active windows on
demand. Callbacks registered with scan_interval and scan_duration drive
those windows on whichever scanner currently sees the matched device,
and a 4h global rediscovery sweep cycles through AUTO scanners one at a
time so radio overlap stays bounded.

Registering an ACTIVE bleak callback without scan_interval now emits a
DeprecationWarning, nudging integrations to declare their cadence so
coordinated scanners can stay passive most of the time.

BaseHaScanner gains a no-op async_request_active_window(duration);
HaScanner implements it by stop/restarting the BleakScanner in ACTIVE
mode under the existing start_stop_lock with a scheduled return to the
prior mode after duration seconds. Remote scanners can override to call
into the proxy's mode-set channel (bleak-esphome lands separately).
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.93814% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.77%. Comparing base (7d7cc00) to head (5f009c7).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/habluetooth/scanner.py 93.70% 7 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #448      +/-   ##
==========================================
+ Coverage   96.45%   96.77%   +0.31%     
==========================================
  Files          14       15       +1     
  Lines        1976     2358     +382     
  Branches      247      315      +68     
==========================================
+ Hits         1906     2282     +376     
- Misses         34       40       +6     
  Partials       36       36              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented May 22, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 16 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing auto-scan-mode (b4e25aa) with main (bb6aac7)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@bdraco

bdraco commented May 22, 2026

Copy link
Copy Markdown
Member Author

@bluetoothbot review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an AUTO Bluetooth scanning mode to allow scanners to run passive by default while the manager schedules short, on-demand ACTIVE windows for specific callbacks/devices (plus a periodic rediscovery sweep), reducing continuous active-scanning requirements.

Changes:

  • Introduces BluetoothScanningMode.AUTO and maps AUTO scanners to start in passive mode.
  • Adds AutoScanScheduler to track per-callback scan_interval/scan_duration needs and schedule active windows + staggered global sweeps.
  • Extends HaScanner / BaseHaScanner with async_request_active_window() to support temporary active scanning.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/test_auto_scheduler.py New test coverage for AUTO scheduling behavior, coalescing, sweeps, and deprecation warning.
src/habluetooth/auto_scheduler.py Implements the on-demand active-window scheduler and periodic rediscovery sweep logic.
src/habluetooth/manager.py Wires scheduler into manager lifecycle and adds callback registration metadata + deprecation warning behavior.
src/habluetooth/base_scanner.py Adds a default no-op async_request_active_window() API for scanners.
src/habluetooth/scanner.py Implements active-window mode flipping in HaScanner via stop/start with an override mode.
src/habluetooth/models.py Adds the AUTO scanning mode enum value.
src/habluetooth/const.py Adds AUTO scheduling constants (sweep interval/duration, window clamp bounds).
src/habluetooth/manager.pxd Exposes new BleakCallback fields and manager scheduler handle for Cython.
src/habluetooth/scanner.pxd Exposes new HaScanner active-window state fields for Cython.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/habluetooth/scanner.py Outdated
Comment thread src/habluetooth/auto_scheduler.py Outdated
Comment thread src/habluetooth/auto_scheduler.py Outdated
Comment thread src/habluetooth/auto_scheduler.py Outdated
Comment thread src/habluetooth/auto_scheduler.py Outdated
Comment thread src/habluetooth/auto_scheduler.py Outdated
Hot path: track callbacks with a scan_interval in a separate
_interval_callbacks set so on_advertisement early-returns when no
integration declared a cadence. Recovers the codspeed regression
introduced by iterating the full bleak_callbacks set on every adv.

Busy-loop: when _dispatch_per_device sees a scanner that already has a
window in flight, defer the affected callbacks' due times to just past
the window end instead of leaving them in the past. Without this,
_next_event_time stayed in the past and the tick re-fired every 50ms
until the window drained.

Failed window: capture the bool return of async_request_active_window
and, on False or exception, clear _scanner_windows[source] immediately
so the source is not held busy for the full duration. Sweep failures
also leave _sweep_last_completed alone so the same scanner is retried
on the next eligible tick rather than waiting a full 4h.

Shutdown: AutoScanScheduler.stop now cancels every task in
_pending_tasks, clears _scanner_windows, and resets _sweep_in_flight,
so a manager shutdown mid-window does not let a stale window request
land on a stopped scanner.

Start: overwrite _sweep_last_completed entries on start instead of
setdefault. Pre-start add_scanner stores 0.0 as a placeholder; the
setdefault skipped those and triggered an immediate first sweep on
the first tick.

HaScanner.async_stop now also clears _scan_mode_override and
_active_window_end. Without this, stopping the scanner during an AUTO
active window would let a later async_start come back in continuous
ACTIVE mode because the override was still set.
@bdraco

bdraco commented May 22, 2026

Copy link
Copy Markdown
Member Author

Pushed fixes for all 6 Copilot findings in d71c8ef:

  • Hot path: track callbacks with scan_interval in a separate set so on_advertisement early-returns when none are registered. Should recover the codspeed regression.
  • Busy loop: when _dispatch_per_device skips a busy scanner, defer the affected callbacks' due times past the window end so the next tick does not re-fire every 50ms.
  • Failed window: capture the bool return; on False or exception, drop the _scanner_windows busy marker immediately and (for sweeps) leave _sweep_last_completed alone so the same scanner is retried.
  • Shutdown: stop() now cancels every _pending_tasks entry, clears _scanner_windows, and resets _sweep_in_flight.
  • Pre-start sweep timing: start() overwrites _sweep_last_completed instead of setdefault, so placeholders from pre-start add_scanner calls don't trigger an immediate first sweep.
  • HaScanner.async_stop: also clears _scan_mode_override and _active_window_end so a later restart cannot come back in continuous ACTIVE.

Four new tests cover the hot-path early return, the busy-scanner deferral, the False-return path, and the stop-cancels-pending-tasks shutdown.

Without a pxd, _auto_scheduler was typed as object on the cythonized
BluetoothManager, so self._auto_scheduler.on_advertisement(...) in the
hot _scanner_adv_received path went through a Python attribute lookup
plus a Python method dispatch on every advertisement. This is what
showed up as the 15 to 20 percent codspeed regression on the inject
benchmarks.

Add auto_scheduler.py to TO_CYTHONIZE and ship an auto_scheduler.pxd
that declares the AutoScanScheduler cdef class with cpdef versions of
the methods called from the manager (add_callback, remove_callback,
add_scanner, remove_scanner, on_advertisement, start, stop). manager.pxd
cimports AutoScanScheduler and types _auto_scheduler accordingly so the
dispatch compiles to a direct vtable call.

Attributes stay cdef public so existing tests that poke at _needs,
_scanner_windows, _interval_callbacks, etc continue to work both on
the cython build and the SKIP_CYTHON=1 build.
@bdraco

bdraco commented May 22, 2026

Copy link
Copy Markdown
Member Author

Pushed cfec48f: cythonize AutoScanScheduler so the per-advertisement dispatch from manager._scanner_adv_received goes through a direct vtable call instead of a Python attribute lookup plus method dispatch. This is the actual root cause of the codspeed regression; auto_scheduler.py is now in TO_CYTHONIZE and an auto_scheduler.pxd cdefs the class with cpdef versions of the methods called from cython code. Manager.pxd cimports it and types _auto_scheduler accordingly.

@bluetoothbot

bluetoothbot commented May 22, 2026

Copy link
Copy Markdown
Contributor

PR Review — feat(manager): add AUTO scanning mode with on-demand active windows

Solid implementation of AUTO-mode active windows. The design — per-scanner worker tasks, address-keyed dispatch, pre-await _advance_due to defeat ownership-flip races, sweep staggering, separate fast/slow restart paths — is clearly thought through, and the docstrings (Flow / Migration / Invariants) are unusually good. Test coverage for the scanner-side state machine and the scheduler's race surfaces is broad. No critical/blocking bugs found; the issues raised in prior Copilot/bot rounds (busy-loop on stuck scanner, false-success on Linux passive fallback, stop() not cleaning timers, hot-path early-return) are addressed in the current state.

Flagged concerns are mostly fragility/maintenance-tax: bleak private-attribute access with no version guard or test that catches breakage, a minor inconsistency where stop() no longer clears _loop despite the previous design assuming it, and a hot-path loop.time() per iteration. None block merge but at least the bleak attribute risk is worth a follow-up issue/test.



Checklist

  • No hardcoded secrets
  • Input validation at boundaries
  • No bare except / swallowed errors — suggestion #6
  • Resource cleanup on error paths
  • No race conditions in concurrent code
  • No unbounded collection growth
  • Tests cover new branches
  • Tests verify behavior, not implementation text
  • Cython .pxd in sync with .py
  • Public API additions exported / documented
  • No fragile private-API dependencies — warning #1

Summary

Solid implementation of AUTO-mode active windows. The design — per-scanner worker tasks, address-keyed dispatch, pre-await _advance_due to defeat ownership-flip races, sweep staggering, separate fast/slow restart paths — is clearly thought through, and the docstrings (Flow / Migration / Invariants) are unusually good. Test coverage for the scanner-side state machine and the scheduler's race surfaces is broad. No critical/blocking bugs found; the issues raised in prior Copilot/bot rounds (busy-loop on stuck scanner, false-success on Linux passive fallback, stop() not cleaning timers, hot-path early-return) are addressed in the current state.

Flagged concerns are mostly fragility/maintenance-tax: bleak private-attribute access with no version guard or test that catches breakage, a minor inconsistency where stop() no longer clears _loop despite the previous design assuming it, and a hot-path loop.time() per iteration. None block merge but at least the bleak attribute risk is worth a follow-up issue/test.


Automated review by Kōan0ed1602
d71c8ef
cfec48f
6c0f3a0
7ab17b8
7797260
d54f0b4
4d3f3ba
056e2d1
b4e25aa
68b6515
e055ce5
e169359
0eb280c
3540555
251518c
8c6f1cf
fc21c43
1d2c995
da09455
2547285
807c853
da771a0
4e467b9
ee07cf2
d419a9b
9069dc9
d95c599
491cc94
5ed7d6d
8be974c
f376b8d
95a95ec
5e7cee6
4909024
a30e0ef
f01f9de
91be515
4531049
2c2a58e
afeecef
06ef79a
0c6be07
c421373
dd1520e
84e1e06
2c57faf
7428a88
472d953
4636739
cf00c17
06c91a4
6e4220c
23ab2df
64e1e6c
ffddf59
21b69eb
ced1404
7d71e83
719d92c
e8ccd76
5d127bc
3a5f9ad
b70e8cf
8ef7014
f26dbdc
da66e9c
522181a
7f4aeac
17da0ca
31677b3
67efc9b
04d4ff9
960e426
ba3787e
e74f49d
c3e93c5
b44664b
cf55c0b

bdraco added 3 commits May 21, 2026 22:29
cython.locals on the hot on_advertisement path so address, existing,
callback, and interval are typed in the generated C, and _matches is
declared as a cdef function in the pxd so the inner-loop call is a
direct C call instead of going through a Python function lookup. Also
type the cpdef add_callback / remove_callback / add_scanner /
remove_scanner / on_advertisement parameters with their concrete cdef
classes (BleakCallback, BaseHaScanner, BluetoothServiceInfoBleak) so
no Python boxing happens at the call boundary.

Adds tests covering HaScanner.async_request_active_window happy path,
non-AUTO rejection, overlap extension, and async_stop clearing the
override, plus auto_scheduler edge cases (UUID filter miss, remove
scanner clears sweep_in_flight, history-missing pruning, pre-start
add_scanner placeholder, stop idempotency). Patch coverage moves from
73 percent toward 95 percent.
…e_scan

The previous shape extended async_register_bleak_callback with
scanning_mode, scan_interval, and scan_duration kwargs, but bleak
itself has no notion of a scan cadence so that surface did not really
belong on the bleak callback path; callers using the bleak path can
not meaningfully express it. Replace it with a dedicated registration
method on BluetoothManager that takes address and / or service_uuid
plus the cadence.

BluetoothManager.async_register_active_scan(scan_interval, address,
service_uuid, scan_duration) returns a cancel callable; the scheduler
stores ActiveScanRequest entries indexed by address and service_uuid
so the on_advertisement hot path only iterates candidates whose own
fields match the advertisement (not every registered request),
addressing the O(n) cost concern. Multiple registrations for the same
address with different scan_intervals coexist and fire on their own
cadences, coalescing only when actually due in the same tick.

BleakCallback is restored to its original (callback, filters) shape;
the DeprecationWarning on ACTIVE-without-interval is removed. The pxd
declares ActiveScanRequest as a cdef class with typed fields so
manager.py's call into the scheduler stays a direct C call.

Tests rewritten against the new API; multi-interval coexistence, dual
address-and-service_uuid AND semantics, and the indexed lookup are all
covered.
async_register_active_scan now takes a positional address plus
scan_interval and scan_duration; the service_uuid and combined matcher
logic added in the prior commit was more flexibility than callers
need. The scheduler keeps a single dict[address, set[request]] index,
so on_advertisement is two dict lookups (the registry presence check
and the per-address bucket) and no iteration when the advertisement's
address has no registered requests.

ActiveScanRequest shrinks to (address, scan_interval, scan_duration);
add_matcher / remove_matcher become add_request / remove_request.
Tests rewritten to use the simpler signature.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread src/habluetooth/scanner.py Outdated
Comment thread src/habluetooth/auto_scheduler.py Outdated
bdraco added 2 commits May 22, 2026 07:23
…window

On Linux, _async_start_attempt silently falls back to PASSIVE on the
fourth retry when ACTIVE fails to start. The window swap would then
return True even though the scanner was no longer actually in active
mode, leaving the scheduler to believe the active window had engaged.
After the swap, verify current_mode is ACTIVE; if not, clear
_scan_mode_override and return False so the scheduler can treat it as
a failed window.

Also drops the stale "address and / or service UUID" wording from the
auto_scheduler module docstring; the API was narrowed to address-only
in 7797260 but the docstring was not updated.
Two bluetoothbot review items.

Sweep backoff: when a scanner's async_request_active_window returns
False for a sweep, _run_window also advances _sweep_last_completed to
now instead of leaving it at its old past value. Without this,
_next_event_time stays in the past, _reschedule fires the floor delay
of 50ms, the tick picks the same scanner, the call fails again, and
the manager hammers the scanner with stop/start cycles. Treating the
failure as a completed-for-now sweep moves the next attempt one full
interval out, which is fine since global sweeps are best effort.

Hot path: on_advertisement no longer calls _reschedule per newly added
request; it tracks an added flag and reschedules once after the loop.
The math is the same and the tick timer no longer thrashes when
multiple requests target the same address.
@bdraco

bdraco commented May 22, 2026

Copy link
Copy Markdown
Member Author

Addressed the bluetoothbot review across d54f0b4 and 4d3f3ba:

  • 🔴 active fallback: async_request_active_window now checks current_mode after the swap and returns False on the Linux passive fallback path so the scheduler does not record a window that did not actually run
  • 🟡 sweep backoff: _run_window advances _sweep_last_completed on failure too, preventing the 50ms tight retry loop when a sweep target persistently returns False
  • 🟡 stale docstring: dropped the 'address and / or service UUID' wording that lingered after the address-only refactor
  • 🟢 hot path: hoisted the _reschedule call out of the on_advertisement loop so multiple registrations for the same address only re-arm the tick timer once

Full suite green on the cython build, new test covers the sweep-backoff path.

Scheduler DRY: collapse start()'s indirect dict access to .values(),
drop the dead `interval is not None` guards (ActiveScanRequest.scan_interval
is required, the checks were leftover from when BleakCallback held the
cadence), and merge _dispatch_per_device's two identical "advance
next-due" loops into a single tail loop that runs whether we fired a
window or skipped a non-AUTO scanner.

HaScanner race: state mutations on _scan_mode_override, _active_window_handle,
and _active_window_end now all run under _start_stop_lock. The previous
shape released the lock between the restart and the handle/timer set,
which let an async_stop interleave to clear the handle and then have
the window task immediately re-set it; and let an _async_end_active_window
task clear the override after a new async_request_active_window had set
it, causing the restart to come back in PASSIVE instead of ACTIVE.
_async_end_active_window now checks _active_window_handle inside the
lock and defers to the new window if one has taken over. Adds two
small helpers, _clear_active_window_state and _arm_active_window_timer,
so the per-stage state mutation is named and reused.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread src/habluetooth/scanner.py Outdated
Comment thread src/habluetooth/manager.py
Comment thread src/habluetooth/auto_scheduler.py Outdated
bdraco added 2 commits May 22, 2026 08:00
Reshape the global rediscovery sweep cadence: instead of one 30s sweep
every 4 hours, fire a 15s sweep AUTO_INITIAL_SWEEP_DELAY (10 minutes)
after a scanner joins and every AUTO_REDISCOVERY_INTERVAL (12 hours)
thereafter. The initial delay keeps HA startup from being crowded by
ACTIVE scans on every adapter at once; the 12h cadence is sufficient
to feed discovery for devices that broadcast their identity in
SCAN_RSP without paying the per-day ACTIVE budget the prior 4h
cadence implied.

Both start() and add_scanner() now seed _sweep_last_completed with a
fake past value such that last + AUTO_REDISCOVERY_INTERVAL equals
now + AUTO_INITIAL_SWEEP_DELAY; works the same whether a scanner is
registered before or after the scheduler starts.
…-dispatch

Replace the call_later tick plus per-window create_task model with one
long-running _ScannerWorker task per AUTO-mode scanner. Each worker
sleeps on an asyncio.Event using wait_for with a timeout equal to the
delay to its next event (next due per-device window or next sweep);
state mutations (new request, on_advertisement, scanner registration)
just set the wake event, no task is allocated per dispatch.

Before this change every active-window dispatch allocated a new asyncio
Task that lived only long enough to call scanner.async_request_active_window
and clear the busy marker. For a typical setup with N devices on a 2
minute cadence across M scanners, that was 30 * N * M task allocations
per hour. After this change the only persistent tasks are one per AUTO
scanner (typically <10 total) and dispatches happen inline inside the
worker loop, so per-window allocation cost goes to zero.

Cross-scanner sweep serialization moves from a _sweep_in_flight flag
to a shared asyncio.Lock; the "at most one sweep at a time" guarantee
is preserved, and each worker independently fires its sweep when the
lock is free and the per-worker sweep clock is past due. Per-scanner
busy state moves from a manager-level _scanner_windows dict to each
worker's own _window_end field; concurrency across scanners is still
allowed (independent radios) and per-scanner concurrency is naturally
prevented by serial awaits inside the worker.

Tests rewritten to drive worker._tick() deterministically instead of
poking the old _async_tick. Existing invariants covered: per-device
coalescing, multi-interval coexistence, non-AUTO scanner exclusion,
unseen-address pruning, initial sweep delay, sweep-failure backoff,
cross-scanner sweep serialization, and worker teardown on stop /
remove_scanner.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread src/habluetooth/manager.py
Comment thread src/habluetooth/manager.py
Comment thread tests/test_auto_scheduler.py
bdraco added 3 commits May 22, 2026 17:10
Docstrings across auto_scheduler.py, scanner.py, and the
async_register_active_scan docstring on manager.py had drifted long
across the review iterations. Compress each to lead with the rule
and keep only the load-bearing why (invariants, races,
non-obvious contracts). Same content, shorter lines.

Correctness fix prompted by the latest review:

- address.upper() in async_register_active_scan broke macOS
  CoreBluetooth UUIDs (case-preserving identifiers, conventionally
  lowercase). Only upper-case colon-form MAC addresses now;
  UUIDs pass through unchanged so the on_advertisement dict lookup
  matches what CoreBluetooth records. Added
  test_register_active_scan_uuid_passes_through_unchanged.

Docstring polish on the related items also surfaced:

- async_request_active_window now spells out that shorter follow-
  ups are a no-op on the timer (vs longer ones that extend it),
  rather than the old vague "extend in place".
- The except BaseException comment in async_request_active_window
  now names SystemExit / KeyboardInterrupt alongside CancelledError
  and an unexpected BleakError leak, so a future reader doesn't
  narrow it to except Exception thinking it's overbroad.
Each AUTO active-window cycle was running _async_stop_then_start_under_lock
twice — once on entry (PASSIVE -> ACTIVE) and once on restore
(ACTIVE -> PASSIVE). Each leg created a fresh BleakScanner instance
(_async_stop_scanner sets self.scanner = None, _async_start
constructs a new one) and re-issued restore_discoveries through
_async_on_successful_start. With many cached devices and a tight
60s cadence this is real CPU per minute.

Add _async_toggle_active_window_mode, called from
async_request_active_window and _async_end_active_window. It
keeps the same BleakScanner instance, stops it, mutates
self.scanner._backend._scanning_mode (BlueZ backend reads the
attribute on every start), and starts the same instance again.
Bleak's internal device cache survives same-instance stop+start
so BleakClient(address) lookups keep working across the flip.

Linux/BlueZ only. On macOS CoreBluetooth doesn't support passive,
so create_bleak_scanner translates AUTO -> ACTIVE at construction
and async_request_active_window early-returns True (the radio is
already in the right mode, no flip needed); the toggle helper
never runs on macOS so the private-attribute access is bounded.

Falls back to the full _async_stop_then_start_under_lock path if
the toggle fails (stop or start raises) so we don't leave the
scanner stuck. The watchdog and async_stop / async_start paths
still go through full teardown.

Test updates:
- Existing tests that tracked BleakScanner factory calls
  (per-construction) now track the single passive construction and
  assert against the backend's _scanning_mode for the flip. Adapt
  the existing _factory mocks with a _backend SimpleNamespace so
  the toggle can mutate _scanning_mode.
- Most active-window tests now wrap in
  patch("habluetooth.scanner.IS_MACOS", False) so they exercise
  the Linux/toggle path regardless of the host running them.
- create_bleak_scanner now does the AUTO->ACTIVE translation
  inside the function (call-time IS_MACOS lookup) so tests can
  patch it.
- New tests cover the toggle's bail paths
  (test_async_toggle_active_window_mode_returns_false_when_no_scanner,
  _returns_false_on_stop_error) so coverage stays clean.
CI failed with sweep_2 - sweep_1 == 15.015 vs the previous abs=0.01
tolerance: the diff is SWEEP_DURATION + (loop.time() drift between
two _spawn_worker calls), and on a slow CI runner that drift can
easily exceed 10ms. Assert the floor (>= SWEEP_DURATION) with a
1s upper bound instead of an exact-match approx — the only invariant
the stagger actually guarantees is that each successive worker's
first sweep is at least one sweep-duration later than the previous
one's, plus a small unbounded loop.time() drift.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread tests/test_scanner.py Outdated
Comment thread src/habluetooth/scanner.py
Comment thread src/habluetooth/auto_scheduler.py Outdated
bdraco added 4 commits May 22, 2026 17:46
Both _async_start_attempt and _async_toggle_active_window_mode
computed `_scan_mode_override or requested_mode`. Wrap it in
_effective_mode() so the precedence rule lives in one place; the
short docstring explains why the override takes precedence over
the integration-declared requested_mode.
…d branch

Bot caught that the lockless fast path at the top of
async_request_active_window guards against re-arming with a shorter
duration, but the locked early-return at the
\`current_mode is BluetoothScanningMode.ACTIVE\` branch did not.
A concurrent caller with duration=5 entering after another caller's
toggle finished would re-arm the in-flight 100s timer at end=now+5,
shrinking someone else's window.

Apply the same loop.time() + duration > _active_window_end check
inside the locked branch so shorter callers no-op on the timer
instead of stealing the window.

Bot's second item: stop() left self._loop set, so post-stop
add_request seeded _needs with timestamps against the cancelled
loop. Null _loop in stop() too so add_request / on_advertisement
fall back to the record-only / no-op path.

Tests:
- New test_async_request_active_window_still_active_does_not_shrink
  asserts _active_window_end is preserved when a shorter caller hits
  the still-ACTIVE locked branch.
- New test_stop_clears_loop_so_post_stop_add_request_is_record_only
  asserts _loop is None after stop() and that add_request /
  on_advertisement skip _needs seeding without crashing.
- Refactor the 14 \`with patch("habluetooth.scanner.IS_MACOS", False):\`
  blocks into a force_linux_scanner_mode pytest fixture so the
  Linux/BlueZ AUTO flow tests are easier to read and maintain.
- Update test_async_request_active_window_skips_restart_if_still_active
  to use a longer follow-up (200s) so the still-ACTIVE re-arm path
  is still exercised (a shorter follow-up is now covered by the new
  shrink test).

The third bot item (orphan _needs only pruned on AUTO worker tick)
is informational - the bot itself noted it's not catastrophic, just
asymmetric with remove_scanner. Not addressed in this PR.
Three Copilot items in one commit:

- The in-place \`_async_toggle_active_window_mode\` mutates
  \`self.scanner._backend._scanning_mode\`, which is a BlueZ
  implementation detail. On non-Linux non-macOS platforms (e.g.
  Windows) that attribute may not exist and the mutation would
  AttributeError. Gate the toggle on IS_LINUX at the call sites
  and fall back to the full stop+recreate+start path otherwise.
- Split the two branches of async_request_active_window into
  named helpers (_async_enter_via_toggle for Linux, _async_enter_via_restart
  for everything else) so the outer method just picks the strategy
  and arms the timer. The error-recovery + override-clear logic
  lives inside each helper.
- Cache \`loop.time()\` once before the per-request seed loop in
  on_advertisement; per-request \`self._loop.time()\` calls were
  pointless on the hot path. .pxd updated with the new
  \`now=double\` local.
- Wrap test_async_end_active_window_handles_start_error's body in
  try/finally that resets fail_until and calls scanner.async_stop()
  so a long-running active window can't leak watchdog timers /
  background tasks into later tests.
- force_linux_scanner_mode fixture now also patches
  habluetooth.scanner.IS_LINUX=True so the Linux/BlueZ toggle path
  is reachable on non-Linux test hosts.
_async_enter_via_toggle / _async_enter_via_restart ->
_async_begin_active_window_via_toggle /
_async_begin_active_window_via_restart so the names spell out what
they do rather than relying on the surrounding context.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread tests/test_scanner.py
Comment thread src/habluetooth/scanner.py Outdated
Comment thread src/habluetooth/auto_scheduler.py Outdated
bdraco added 2 commits May 22, 2026 18:11
Several function docstrings in scanner.py and auto_scheduler.py had
crept back over the just-keep-the-load-bearing-bits bar across the
review iterations. Same content; shorter prose. Also trim a couple
of inline comments in the toggle helper.
Three small extractions on top of the active-window plumbing:

- scanner.py: _arm_active_window_timer_if_extends() folds the
  "loop.time()+duration > _active_window_end -> _arm" guard that
  was duplicated between the lockless fast path and the locked
  still-ACTIVE branch into one helper. Both call sites now read as
  a single line.
- scanner.py: _async_abort_active_window() folds the
  "clear override, recover via stop+start, return False" rollback
  shared by _async_begin_active_window_via_toggle (toggle returned
  False) and _async_begin_active_window_via_restart (ScannerStartError
  caught) into one helper.
- auto_scheduler.py: _seed_requests() lifts the
  "setdefault(address, {}); for r in requests: if r not in existing:
  existing[r] = now + r.scan_interval" loop into one helper called
  by both on_advertisement() and start()'s replay. on_advertisement
  also drops the per-iteration `if existing is None` check in
  favor of a single setdefault. .pxd grows a matching
  _seed_requests cython.locals decl.

No behavior changes. 371 tests pass; cython rebuild clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread src/habluetooth/auto_scheduler.py
Comment thread src/habluetooth/scanner.py Outdated
…l, stale docstring

Three items from the latest bluetoothbot review:

- _tick's broad except Exception only wrapped the scanner await,
  so a sync-phase failure (e.g. async_last_service_info raising on
  a corrupt history entry inside _collect_due_buckets) would crash
  the worker task silently. Wrap the whole post-now body in an
  outer try/except that logs and lets the worker keep running; the
  existing inner except still handles scanner-call failures with
  the first-failure-traceback / subsequent-warning dance.
- _async_toggle_active_window_mode left self.scanning = True on
  both the stop-error and start-error paths even though the bleak
  scanner had been torn down or never came back. Clear
  self.scanning = False on both branches so the wrapper's flag
  matches reality.
- add_scanner's docstring still said "stop() leaves _loop set" -
  stale after the earlier stop()-clears-_loop fix. Update so the
  comment matches what stop() actually does now.

Tests for the UI mode-switch flow + the two scanner-side fixes:

- test_mode_switch_unregister_then_register_picks_up_existing_request:
  walks AUTO -> PASSIVE -> AUTO on the same source so the scheduler
  exercises remove_scanner (worker dropped, _needs entries pruned,
  _requests_by_address preserved) and add_scanner (new worker
  spawned, on_advertisement bootstraps tracking from the still-
  registered request). This is the path HA's config-entry reload
  takes when the user changes scanner mode in the UI.
- test_tick_sync_phase_exception_is_logged_and_worker_survives:
  stubs async_last_service_info to raise so _collect_due_buckets
  blows up, asserts the outer except logs and the worker stays
  alive.
- test_async_toggle_active_window_mode_marks_not_scanning_on_start_error:
  extends the existing stop-error test pattern with a start-error
  variant; both now assert self.scanning is False after the toggle
  helper returns False.

auto_scheduler.py stays at 100% line + 100% branch coverage.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread tests/test_auto_scheduler.py
Comment thread src/habluetooth/scanner.py
…tion; split stray test

Four Copilot review items:

* scanner._async_toggle_active_window_mode mutated the private bleak
  attribute self.scanner._backend._scanning_mode unguarded. If a
  future bleak refactor renames or drops it, the AttributeError
  would propagate out of async_request_active_window with the
  scanner already stopped and no recovery path attempted. Wrap the
  mutation in try/except AttributeError: log, mark self.scanning =
  False, return False so the caller falls back to the full
  stop+recreate+start path.

* AutoScanScheduler.stop() left _needs intact while nulling _loop.
  A later start(new_loop) under a loop with a different time()
  origin would have reused stale due-times and either fired
  windows instantly or never. Clear _needs in stop() so the start()
  replay seeds fresh due-times against the new loop's clock base.
  _requests_by_address is loop-independent and still survives.

* HaScanner.async_request_active_window accepted any float duration.
  NaN/inf/non-positive values would poison loop.call_later and the
  extension comparison (NaN ordering is always False; inf locks
  the window open). The scheduler clamps via _coalesce_duration
  but subclasses and direct callers may not. Reject non-finite
  and non-positive durations at the public entry with a warning.

* tests/test_auto_scheduler.py:
  test_mode_switch_unregister_then_register_picks_up_existing_request
  had a stray triple-quoted string mid-function followed by an
  unrelated test body that executed as part of the same test.
  Split into a standalone test_start_replays_pre_start_requests_into_needs
  so each scenario fails independently.

Tests:
* test_async_toggle_active_window_mode_attribute_error_marks_not_scanning
* test_stop_clears_needs_so_restart_does_not_reuse_stale_due_times
* test_async_request_active_window_rejects_invalid_duration (NaN, inf, -1.0, 0.0)
* test_start_replays_pre_start_requests_into_needs (split from above)

380 tests pass on both the cython and pure-python builds; full suite
under -W error::DeprecationWarning is clean.
@bdraco
bdraco marked this pull request as ready for review May 22, 2026 23:38
@bdraco
bdraco merged commit 89c374e into main May 22, 2026
39 checks passed
@bdraco
bdraco deleted the auto-scan-mode branch May 22, 2026 23:40
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.

3 participants