feat(manager): add AUTO scanning mode with on-demand active windows - #448
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
Merging this PR will not alter performance
Comparing Footnotes
|
|
@bluetoothbot review |
There was a problem hiding this comment.
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.AUTOand maps AUTO scanners to start in passive mode. - Adds
AutoScanSchedulerto track per-callbackscan_interval/scan_durationneeds and schedule active windows + staggered global sweeps. - Extends
HaScanner/BaseHaScannerwithasync_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.
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.
|
Pushed fixes for all 6 Copilot findings in d71c8ef:
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.
|
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. |
PR Review — feat(manager): add AUTO scanning mode with on-demand active windowsSolid implementation of AUTO-mode active windows. The design — per-scanner worker tasks, address-keyed dispatch, pre-await Flagged concerns are mostly fragility/maintenance-tax: bleak private-attribute access with no version guard or test that catches breakage, a minor inconsistency where Checklist
SummarySolid implementation of AUTO-mode active windows. The design — per-scanner worker tasks, address-keyed dispatch, pre-await Flagged concerns are mostly fragility/maintenance-tax: bleak private-attribute access with no version guard or test that catches breakage, a minor inconsistency where Automated review by Kōan0ed1602 |
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.
…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.
|
Addressed the bluetoothbot review across d54f0b4 and 4d3f3ba:
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.
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.
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.
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.
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.
…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.
…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.
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.