From 0ed1602ad54ef384105dca9a091d321579a034ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 21:32:51 -0500 Subject: [PATCH 01/75] feat(manager): add AUTO scanning mode with on-demand active windows 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). --- src/habluetooth/auto_scheduler.py | 310 +++++++++++++++++++++ src/habluetooth/base_scanner.py | 14 + src/habluetooth/const.py | 13 + src/habluetooth/manager.pxd | 4 + src/habluetooth/manager.py | 75 ++++- src/habluetooth/models.py | 4 + src/habluetooth/scanner.pxd | 3 + src/habluetooth/scanner.py | 95 ++++++- tests/test_auto_scheduler.py | 441 ++++++++++++++++++++++++++++++ 9 files changed, 950 insertions(+), 9 deletions(-) create mode 100644 src/habluetooth/auto_scheduler.py create mode 100644 tests/test_auto_scheduler.py diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py new file mode 100644 index 00000000..07a6bf6a --- /dev/null +++ b/src/habluetooth/auto_scheduler.py @@ -0,0 +1,310 @@ +""" +Auto-mode active-window scheduler for the bluetooth manager. + +Coordinates two distinct kinds of active scanning windows on AUTO-mode +scanners: + +* Per-callback windows. Bleak callbacks registered with + ``scan_interval``/``scan_duration`` cause a short active window on the + scanner that currently sees each matched device, fired once per + ``scan_interval`` seconds. Multiple matching callbacks for the same + address on the same scanner coalesce into one window whose duration is + the max of the coalesced durations. + +* Global rediscovery sweeps. Every ``AUTO_REDISCOVERY_INTERVAL`` seconds + each AUTO-mode scanner gets a ``AUTO_REDISCOVERY_SWEEP_DURATION`` + active window. Sweeps are staggered across scanners so that at most + one scanner is mid-sweep at a time — the radio coverage gap stays + bounded. + +The scheduler is a single per-manager instance driven by one +``loop.call_at`` handle. ``on_advertisement`` is on the manager's hot +path; it must return cheaply when there are no per-device callbacks +registered. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from .const import ( + AUTO_REDISCOVERY_INTERVAL, + AUTO_REDISCOVERY_SWEEP_DURATION, + AUTO_WINDOW_MAX_DURATION, + AUTO_WINDOW_MIN_DURATION, +) +from .models import BluetoothScanningMode + +if TYPE_CHECKING: + from .base_scanner import BaseHaScanner + from .manager import BleakCallback, BluetoothManager + from .models import BluetoothServiceInfoBleak + + +_LOGGER = logging.getLogger(__name__) + + +def _matches(callback: BleakCallback, service_info: BluetoothServiceInfoBleak) -> bool: + """Return whether a service_info matches a callback's UUID filter.""" + uuids = callback.filters.get("UUIDs") + if uuids is None: + return True + return bool(uuids.intersection(service_info.service_uuids)) + + +class AutoScanScheduler: + """Schedules on-demand active windows across AUTO-mode scanners.""" + + __slots__ = ( + "_loop", + "_manager", + "_needs", + "_pending_tasks", + "_running", + "_scanner_windows", + "_sweep_in_flight", + "_sweep_last_completed", + "_tick_handle", + ) + + def __init__(self, manager: BluetoothManager) -> None: + """Initialize the scheduler bound to a manager.""" + self._manager = manager + # address -> {callback: next_due_loop_time} + self._needs: dict[str, dict[BleakCallback, float]] = {} + # source -> loop time when the current window ends (0.0 = idle) + self._scanner_windows: dict[str, float] = {} + # source -> last sweep completion loop time + self._sweep_last_completed: dict[str, float] = {} + # source currently running a global sweep, or None + self._sweep_in_flight: str | None = None + self._tick_handle: asyncio.TimerHandle | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._running = False + self._pending_tasks: set[asyncio.Task[None]] = set() + + def start(self, loop: asyncio.AbstractEventLoop) -> None: + """Bind the scheduler to the event loop and schedule the first tick.""" + self._loop = loop + self._running = True + # Initialize last-sweep so the first sweep is one interval out. + now = loop.time() + for source in self._manager._sources: + scanner = self._manager._sources[source] + if scanner.requested_mode is BluetoothScanningMode.AUTO: + self._sweep_last_completed.setdefault(source, now) + self._reschedule() + + def stop(self) -> None: + """Cancel any pending tick and refuse further work.""" + self._running = False + if self._tick_handle is not None: + self._tick_handle.cancel() + self._tick_handle = None + + def add_scanner(self, scanner: BaseHaScanner) -> None: + """Register an AUTO-mode scanner for the global rediscovery sweep.""" + if scanner.requested_mode is not BluetoothScanningMode.AUTO: + return + if self._loop is None: + self._sweep_last_completed.setdefault(scanner.source, 0.0) + return + self._sweep_last_completed.setdefault(scanner.source, self._loop.time()) + self._reschedule() + + def remove_scanner(self, scanner: BaseHaScanner) -> None: + """Drop scheduler state for a scanner that's leaving the manager.""" + self._sweep_last_completed.pop(scanner.source, None) + self._scanner_windows.pop(scanner.source, None) + if self._sweep_in_flight == scanner.source: + self._sweep_in_flight = None + self._reschedule() + + def remove_callback(self, callback: BleakCallback) -> None: + """Drop per-(address, callback) tracking for a removed registration.""" + if callback.scan_interval is None: + return + empty_addresses: list[str] = [] + for address, callbacks in self._needs.items(): + if callback in callbacks: + del callbacks[callback] + if not callbacks: + empty_addresses.append(address) + for address in empty_addresses: + del self._needs[address] + self._reschedule() + + def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: + """Hot path. Record a tracking entry for any callback that wants one.""" + if not self._manager._bleak_callbacks or self._loop is None: + return + address = service_info.address + existing = self._needs.get(address) + for callback in self._manager._bleak_callbacks: + if callback.scan_interval is None: + continue + if not _matches(callback, service_info): + continue + if existing is None: + existing = self._needs[address] = {} + if callback not in existing: + # First time we see this address for this callback: fire one + # window soon, then settle into the cadence. + existing[callback] = self._loop.time() + callback.scan_interval + self._reschedule() + + def _reschedule(self) -> None: + """Schedule the next tick based on the earliest pending due time.""" + if not self._running or self._loop is None: + return + next_event = self._next_event_time(self._loop.time()) + if self._tick_handle is not None: + self._tick_handle.cancel() + self._tick_handle = None + if next_event is None: + return + # Add a small floor so we never spin. + delay = max(0.05, next_event - self._loop.time()) + self._tick_handle = self._loop.call_later(delay, self._async_tick) + + def _next_event_time(self, now: float) -> float | None: + """Return the earliest upcoming event loop-time, or None if idle.""" + candidates: list[float] = [] + for callbacks in self._needs.values(): + if callbacks: + candidates.append(min(callbacks.values())) + for source, last in self._sweep_last_completed.items(): + if self._sweep_in_flight == source: + continue + candidates.append(last + AUTO_REDISCOVERY_INTERVAL) + if not candidates: + return None + return min(candidates) + + def _async_tick(self) -> None: + """Process all due windows and reschedule.""" + self._tick_handle = None + if not self._running or self._loop is None: + return + now = self._loop.time() + # Drop expired window markers. + for source in list(self._scanner_windows): + if self._scanner_windows[source] <= now: + del self._scanner_windows[source] + self._dispatch_per_device(now) + self._dispatch_global_sweep(now) + self._reschedule() + + def _dispatch_per_device(self, now: float) -> None: + """Fire windows for any (address, callback) whose due time has passed.""" + for address, callbacks in list(self._needs.items()): + due_callbacks = [cb for cb, due in callbacks.items() if due <= now] + if not due_callbacks: + continue + history = self._manager._all_history.get(address) + if history is None: + # No recent sight; drop the tracking entries — they'll come + # back the next time the device advertises. + del self._needs[address] + continue + source = history.source + if source in self._scanner_windows: + # Scanner already busy; the next tick will retry. + continue + scanner = self._manager._sources.get(source) + if scanner is None or scanner.requested_mode is not ( + BluetoothScanningMode.AUTO + ): + # Not an AUTO scanner: it's already fixed-mode, so don't + # bother requesting. Advance the next-due times so we + # don't busy-loop on the same advertisement. + for cb in due_callbacks: + interval = cb.scan_interval + if interval is not None: + callbacks[cb] = now + interval + continue + duration = self._coalesce_duration(due_callbacks) + self._request_window(scanner, duration) + for cb in due_callbacks: + interval = cb.scan_interval + if interval is not None: + callbacks[cb] = now + interval + + def _dispatch_global_sweep(self, now: float) -> None: + """Run a rediscovery sweep on the next eligible scanner, if any.""" + if self._sweep_in_flight is not None: + return + eligible: str | None = None + oldest: float = now + for source, last in self._sweep_last_completed.items(): + if last + AUTO_REDISCOVERY_INTERVAL > now: + continue + if source in self._scanner_windows: + continue + scanner = self._manager._sources.get(source) + if ( + scanner is None + or scanner.requested_mode is not BluetoothScanningMode.AUTO + ): + continue + if last <= oldest: + oldest = last + eligible = source + if eligible is None: + return + scanner = self._manager._sources[eligible] + self._sweep_in_flight = eligible + self._request_window( + scanner, AUTO_REDISCOVERY_SWEEP_DURATION, sweep_source=eligible + ) + + def _coalesce_duration(self, callbacks: list[BleakCallback]) -> float: + """Pick the max requested duration, clamped to the configured range.""" + requested = max( + (cb.scan_duration for cb in callbacks if cb.scan_duration is not None), + default=AUTO_WINDOW_MIN_DURATION, + ) + if requested < AUTO_WINDOW_MIN_DURATION: + return AUTO_WINDOW_MIN_DURATION + if requested > AUTO_WINDOW_MAX_DURATION: + return AUTO_WINDOW_MAX_DURATION + return requested + + def _request_window( + self, + scanner: BaseHaScanner, + duration: float, + sweep_source: str | None = None, + ) -> None: + """Mark the scanner busy and kick off the active-window request.""" + if self._loop is None: + return + self._scanner_windows[scanner.source] = self._loop.time() + duration + task = self._loop.create_task(self._run_window(scanner, duration, sweep_source)) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + + async def _run_window( + self, + scanner: BaseHaScanner, + duration: float, + sweep_source: str | None, + ) -> None: + """Await the scanner's active window and clear in-flight state.""" + try: + await scanner.async_request_active_window(duration) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "%s: error running active window of %.1fs", + scanner.name, + duration, + ) + finally: + if sweep_source is not None: + if self._loop is not None: + self._sweep_last_completed[sweep_source] = self._loop.time() + if self._sweep_in_flight == sweep_source: + self._sweep_in_flight = None + self._reschedule() diff --git a/src/habluetooth/base_scanner.py b/src/habluetooth/base_scanner.py index d76561b2..3884ac7b 100644 --- a/src/habluetooth/base_scanner.py +++ b/src/habluetooth/base_scanner.py @@ -712,6 +712,20 @@ def set_current_mode(self, mode: BluetoothScanningMode | None) -> None: self.current_mode = mode self._manager.scanner_mode_changed(self) + async def async_request_active_window(self, duration: float) -> bool: + """ + Run an active scan for ``duration`` seconds, then restore prior mode. + + Default no-op implementation. Subclasses that can flip the underlying + adapter / proxy into active scanning on demand should override and + return True on success. The manager's auto-mode scheduler relies on + a True return value to know the window actually ran. + """ + _LOGGER.debug( + "%s: scanner does not support on-demand active windows", self.name + ) + return False + class BaseHaRemoteScanner(BaseHaScanner): """Base class for a high availability remote BLE scanner.""" diff --git a/src/habluetooth/const.py b/src/habluetooth/const.py index 931aeea9..ce4a5f41 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -54,6 +54,19 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 +# AUTO scanning mode: each scanner in AUTO mode receives a periodic +# active "sweep" so new devices are still discovered. The manager +# staggers sweeps across scanners so at most one is active at a time. +AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 4 # 4 hours per scanner +AUTO_REDISCOVERY_SWEEP_DURATION: Final = 30.0 # seconds per scanner per sweep + +# AUTO scanning mode: bounds on the per-callback `scan_duration` value. +# Callers requesting an active window for a single device are clamped +# into this range to keep individual windows short and predictable. +AUTO_WINDOW_MIN_DURATION: Final = 1.0 +AUTO_WINDOW_MAX_DURATION: Final = 30.0 + + FAILED_ADAPTER_MAC = "00:00:00:00:00:00" diff --git a/src/habluetooth/manager.pxd b/src/habluetooth/manager.pxd index 61969b0b..51e13282 100644 --- a/src/habluetooth/manager.pxd +++ b/src/habluetooth/manager.pxd @@ -33,6 +33,9 @@ cdef class BleakCallback: cdef public object callback cdef public dict filters + cdef public object scanning_mode + cdef public object scan_interval + cdef public object scan_duration cdef class BluetoothManager: @@ -69,6 +72,7 @@ cdef class BluetoothManager: cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl + cdef public object _auto_scheduler @cython.locals(stale_seconds=double) cdef bint _prefer_previous_adv_from_different_source( diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 8a6a8099..8fb2ef5c 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -6,6 +6,7 @@ import itertools import logging import platform +import warnings from collections.abc import Callable, Iterable from dataclasses import asdict from functools import partial @@ -31,6 +32,7 @@ TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) +from .auto_scheduler import AutoScanScheduler from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl from .const import ( ADV_RSSI_SWITCH_THRESHOLD, @@ -40,6 +42,7 @@ UNAVAILABLE_TRACK_SECONDS, ) from .models import ( + BluetoothScanningMode, BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, HaScannerModeChange, @@ -97,14 +100,28 @@ def _dispatch_bleak_callback( class BleakCallback: """Bleak callback.""" - __slots__ = ("callback", "filters") + __slots__ = ( + "callback", + "filters", + "scan_duration", + "scan_interval", + "scanning_mode", + ) def __init__( - self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] + self, + callback: AdvertisementDataCallback, + filters: dict[str, set[str]], + scanning_mode: BluetoothScanningMode | None = None, + scan_interval: float | None = None, + scan_duration: float | None = None, ) -> None: """Init bleak callback.""" self.callback = callback self.filters = filters + self.scanning_mode = scanning_mode + self.scan_interval = scan_interval + self.scan_duration = scan_duration class BluetoothManager: @@ -118,6 +135,7 @@ class BluetoothManager: "_all_history", "_allocations", "_allocations_callbacks", + "_auto_scheduler", "_bleak_callbacks", "_bluetooth_adapters", "_cancel_allocation_callbacks", @@ -209,6 +227,7 @@ def __init__( ] = {} self._subclass_discover_info = self._discover_service_info self._mgmt_ctl: MGMTBluetoothCtl | None = None + self._auto_scheduler = AutoScanScheduler(self) if ( self._discover_service_info.__func__ # type: ignore[attr-defined] is BluetoothManager._discover_service_info @@ -363,6 +382,7 @@ async def async_setup(self) -> None: await self._async_refresh_adapters() install_multiple_bleak_catcher() self.async_setup_unavailable_tracking() + self._auto_scheduler.start(self._loop) if not IS_LINUX: return self._mgmt_ctl = MGMTBluetoothCtl(10.0, self._side_channel_scanners) @@ -389,6 +409,7 @@ def async_stop(self) -> None: if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking.cancel() self._cancel_unavailable_tracking = None + self._auto_scheduler.stop() uninstall_multiple_bleak_catcher() self._cancel_allocation_callbacks() if self._mgmt_ctl: @@ -880,6 +901,7 @@ def _scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None bleak_callback, service_info.device, advertisement_data ) + self._auto_scheduler.on_advertisement(service_info) self._subclass_discover_info(service_info) def async_clear_advertisement_history(self, address: str) -> None: @@ -995,6 +1017,7 @@ def _async_unregister_scanner_internal( self.slot_manager.remove_adapter(scanner.adapter) if (idx := scanner.adapter_idx) is not None: self._side_channel_scanners.pop(idx, None) + self._auto_scheduler.remove_scanner(scanner) self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.REMOVED) def async_register_scanner( @@ -1022,16 +1045,51 @@ def async_register_scanner( self.async_on_allocation_changed( self.slot_manager.get_allocations(scanner.adapter) ) + self._auto_scheduler.add_scanner(scanner) self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.ADDED) return partial( self._async_unregister_scanner_internal, scanners, scanner, connection_slots ) def async_register_bleak_callback( - self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] + self, + callback: AdvertisementDataCallback, + filters: dict[str, set[str]], + *, + scanning_mode: BluetoothScanningMode | None = None, + scan_interval: float | None = None, + scan_duration: float | None = None, ) -> CALLBACK_TYPE: - """Register a callback.""" - callback_entry = BleakCallback(callback, filters) + """ + Register a callback. + + ``scanning_mode`` declares whether the caller needs active scanning + for matched devices. ``scan_interval`` (seconds between active + sweeps) and ``scan_duration`` (length of each sweep, seconds) tell + the auto-mode scheduler how often and how long to flip an AUTO-mode + scanner into active for the matched devices. + + Registering an ACTIVE callback without ``scan_interval`` is + deprecated: integrations should declare their actual cadence so + coordinated scanners can stay passive most of the time. + """ + if scanning_mode is BluetoothScanningMode.ACTIVE and scan_interval is None: + warnings.warn( + f"Bleak callback {getattr(callback, '__qualname__', callback)!r} " + "registered with ACTIVE scanning mode but no scan_interval; " + "this forces continuous active scanning. Pass " + "scan_interval= and scan_duration= so " + "AUTO-mode scanners can schedule windowed active scans.", + DeprecationWarning, + stacklevel=2, + ) + callback_entry = BleakCallback( + callback, + filters, + scanning_mode=scanning_mode, + scan_interval=scan_interval, + scan_duration=scan_duration, + ) self._bleak_callbacks.add(callback_entry) # Replay the history since otherwise we miss devices # that were already discovered before the callback was registered @@ -1041,7 +1099,12 @@ def async_register_bleak_callback( callback_entry, history.device, history.advertisement ) - return partial(self._bleak_callbacks.remove, callback_entry) + return partial(self._async_remove_bleak_callback, callback_entry) + + def _async_remove_bleak_callback(self, callback_entry: BleakCallback) -> None: + """Unregister a bleak callback and drop any scheduler tracking.""" + self._bleak_callbacks.discard(callback_entry) + self._auto_scheduler.remove_callback(callback_entry) def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" diff --git a/src/habluetooth/models.py b/src/habluetooth/models.py index ece16bf1..c53bcaa2 100644 --- a/src/habluetooth/models.py +++ b/src/habluetooth/models.py @@ -99,6 +99,10 @@ class BluetoothScanningMode(Enum): PASSIVE = "passive" ACTIVE = "active" + # AUTO starts the scanner in PASSIVE and lets the manager promote it to + # ACTIVE on demand via BaseHaScanner.async_request_active_window — used + # for per-callback active windows and the periodic rediscovery sweep. + AUTO = "auto" class BluetoothServiceInfo: diff --git a/src/habluetooth/scanner.pxd b/src/habluetooth/scanner.pxd index 2bbab8d2..b92d32b6 100644 --- a/src/habluetooth/scanner.pxd +++ b/src/habluetooth/scanner.pxd @@ -19,6 +19,9 @@ cdef class HaScanner(BaseHaScanner): cdef public object _background_tasks cdef public object scanner cdef public object _start_future + cdef public object _scan_mode_override + cdef public object _active_window_handle + cdef public double _active_window_end @cython.locals(service_info=BluetoothServiceInfoBleak) cpdef void _async_detection_callback( diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index a4a7e48b..44116d8e 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -7,7 +7,7 @@ import platform from collections.abc import Coroutine, Iterable from functools import lru_cache -from typing import Any, no_type_check +from typing import TYPE_CHECKING, Any, no_type_check import async_interrupt import bleak @@ -102,6 +102,9 @@ class InvalidMessageError(Exception): # type: ignore[no-redef] SCANNING_MODE_TO_BLEAK = { BluetoothScanningMode.ACTIVE: "active", BluetoothScanningMode.PASSIVE: "passive", + # AUTO starts in passive; the scheduler will request transient ACTIVE + # windows by calling HaScanner.async_request_active_window. + BluetoothScanningMode.AUTO: "passive", } # The minimum number of seconds to know @@ -200,7 +203,10 @@ class HaScanner(BaseHaScanner): """ __slots__ = ( + "_active_window_end", + "_active_window_handle", "_background_tasks", + "_scan_mode_override", "_start_future", "_start_stop_lock", "mac_address", @@ -223,6 +229,13 @@ def __init__( self._background_tasks: set[asyncio.Task[Any]] = set() self.scanner: bleak.BleakScanner | None = None self._start_future: asyncio.Future[None] | None = None + # Set while an on-demand active window (auto-mode) is in flight. + # When set, `_async_start_attempt` uses this mode instead of + # `requested_mode`. `requested_mode` itself stays at AUTO so external + # listeners still see the integration's intent. + self._scan_mode_override: BluetoothScanningMode | None = None + self._active_window_handle: asyncio.TimerHandle | None = None + self._active_window_end: float = 0.0 def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None: """Create a background task and add it to the background tasks set.""" @@ -360,7 +373,8 @@ async def _async_start_attempt(self, attempt: int) -> bool: self._loop is not None ), "Loop is not set, call async_setup first" - self.set_current_mode(self.requested_mode) + effective_mode = self._scan_mode_override or self.requested_mode + self.set_current_mode(effective_mode) # 1st attempt - no auto reset # 2nd attempt - try to reset the adapter and wait a bit # 3th attempt - no auto reset @@ -369,7 +383,7 @@ async def _async_start_attempt(self, attempt: int) -> bool: if ( IS_LINUX and attempt == START_ATTEMPTS - and self.requested_mode is BluetoothScanningMode.ACTIVE + and effective_mode is BluetoothScanningMode.ACTIVE ): _LOGGER.debug( "%s: Falling back to passive scanning mode " @@ -623,12 +637,87 @@ async def _async_reset_adapter(self, gone_silent: bool) -> None: async def async_stop(self) -> None: """Stop bluetooth scanner.""" + if self._active_window_handle is not None: + self._active_window_handle.cancel() + self._active_window_handle = None if self._start_future is not None and not self._start_future.done(): self._start_future.set_exception(_AbortStartError()) async with self._start_stop_lock: self._async_stop_scanner_watchdog() await self._async_stop_scanner() + async def async_request_active_window(self, duration: float) -> bool: + """ + Run an active scan for ``duration`` seconds then restore prior mode. + + Only effective for AUTO-mode scanners; ACTIVE/PASSIVE scanners are + already in a fixed mode and the call is a no-op. + + Overlapping requests on the same scanner coalesce: a request whose + end time extends past the currently running window simply extends + the existing window, avoiding a second restart cycle. + """ + if self.requested_mode is not BluetoothScanningMode.AUTO: + return False + if TYPE_CHECKING: + assert self._loop is not None + new_end = self._loop.time() + duration + if self._active_window_handle is not None: + # A window is already running; extend it if the new request + # reaches further than the existing end. + if new_end > self._active_window_end: + self._active_window_handle.cancel() + self._active_window_end = new_end + self._active_window_handle = self._loop.call_later( + duration, self._schedule_end_active_window + ) + return True + self._scan_mode_override = BluetoothScanningMode.ACTIVE + try: + await self._async_swap_scanner_for_window() + except ScannerStartError: + self._scan_mode_override = None + return False + self._active_window_end = new_end + self._active_window_handle = self._loop.call_later( + duration, self._schedule_end_active_window + ) + return True + + def _schedule_end_active_window(self) -> None: + """Schedule the end-of-window restart as a background task.""" + self._active_window_handle = None + self._create_background_task(self._async_end_active_window()) + + async def _async_end_active_window(self) -> None: + """Restore the scanner to its underlying mode after an active window.""" + self._scan_mode_override = None + if not self.scanning: + # Scanner was stopped while the window was active; nothing to do. + return + try: + await self._async_swap_scanner_for_window() + except ScannerStartError as ex: + _LOGGER.warning( + "%s: Failed to restart scanner after active window: %s", + self.name, + ex, + ) + + async def _async_swap_scanner_for_window(self) -> None: + """ + Stop and restart the BleakScanner so a new mode takes effect. + + This is the simple stop+start used by AUTO-mode active windows. + It differs from the watchdog's ``_async_restart_scanner`` in that + it never resets the underlying adapter — switching mode is + cheap, but adapter reset is heavy and only appropriate when the + scanner has gone silent. + """ + async with self._start_stop_lock: + await self._async_stop_scanner() + await self._async_start() + async def _async_stop_scanner(self) -> None: """Stop bluetooth discovery under the lock.""" self.scanning = False diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py new file mode 100644 index 00000000..3135a058 --- /dev/null +++ b/tests/test_auto_scheduler.py @@ -0,0 +1,441 @@ +"""Tests for the auto-mode active-window scheduler.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from typing import Any + +import pytest +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData + +from habluetooth import ( + BaseHaScanner, + BluetoothScanningMode, + get_manager, +) +from habluetooth.const import ( + AUTO_REDISCOVERY_INTERVAL, + AUTO_REDISCOVERY_SWEEP_DURATION, + AUTO_WINDOW_MAX_DURATION, + AUTO_WINDOW_MIN_DURATION, +) +from habluetooth.manager import BleakCallback + +from . import generate_advertisement_data, generate_ble_device + + +class _RecordingAutoScanner(BaseHaScanner): + """BaseHaScanner subclass that records active-window calls.""" + + __slots__ = ("_block_event", "_return_value", "active_window_calls") + + def __init__( + self, + source: str, + mode: BluetoothScanningMode | None, + connectable: bool = True, + ) -> None: + super().__init__(source, source, requested_mode=mode) + self.connectable = connectable + self.active_window_calls: list[float] = [] + self._block_event: asyncio.Event | None = None + self._return_value = True + + async def async_request_active_window(self, duration: float) -> bool: + self.active_window_calls.append(duration) + if self._block_event is not None: + await self._block_event.wait() + return self._return_value + + @property + def discovered_devices(self) -> list[BLEDevice]: + return [] + + @property + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + return {} + + def get_discovered_device_advertisement_data( + self, address: str + ) -> tuple[BLEDevice, AdvertisementData] | None: + return None + + @property + def discovered_addresses(self) -> Iterable[str]: + return () + + +async def _drain(loop: asyncio.AbstractEventLoop) -> None: + """Yield once so scheduled tasks run.""" + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_register_active_without_interval_warns() -> None: + """ACTIVE registration without scan_interval emits DeprecationWarning.""" + manager = get_manager() + + def _cb(_device: Any, _adv: Any) -> None: ... + + with pytest.warns(DeprecationWarning, match="scan_interval"): + cancel = manager.async_register_bleak_callback( + _cb, {}, scanning_mode=BluetoothScanningMode.ACTIVE + ) + cancel() + + +@pytest.mark.asyncio +async def test_register_active_with_interval_does_not_warn( + recwarn: pytest.WarningsRecorder, +) -> None: + """ACTIVE with scan_interval should not emit the deprecation warning.""" + manager = get_manager() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.ACTIVE, + scan_interval=300.0, + scan_duration=5.0, + ) + cancel() + assert not any( + issubclass(w.category, DeprecationWarning) and "scan_interval" in str(w.message) + for w in recwarn.list + ) + + +@pytest.mark.asyncio +async def test_passive_or_auto_no_warn(recwarn: pytest.WarningsRecorder) -> None: + """PASSIVE / AUTO / unset registrations never emit the deprecation.""" + manager = get_manager() + + def _cb(_device: Any, _adv: Any) -> None: ... + + for mode in (None, BluetoothScanningMode.PASSIVE, BluetoothScanningMode.AUTO): + cancel = manager.async_register_bleak_callback(_cb, {}, scanning_mode=mode) + cancel() + assert not any( + issubclass(w.category, DeprecationWarning) and "scan_interval" in str(w.message) + for w in recwarn.list + ) + + +@pytest.mark.asyncio +async def test_advertisement_starts_tracking() -> None: + """on_advertisement should add a per-(address, callback) tracker entry.""" + manager = get_manager() + sched = manager._auto_scheduler + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=120.0, + scan_duration=3.0, + ) + + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "inkbird") + adv = generate_advertisement_data(local_name="inkbird") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + asyncio.get_running_loop().time(), + ) + assert "11:22:33:44:55:66" in sched._needs + assert len(sched._needs["11:22:33:44:55:66"]) == 1 + finally: + cancel() + register_cancel() + assert sched._needs == {} + + +@pytest.mark.asyncio +async def test_tick_requests_active_window_on_auto_scanner() -> None: + """When a tracker entry is due, the tick should call the scanner.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=120.0, + scan_duration=5.0, + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "inkbird") + adv = generate_advertisement_data(local_name="inkbird") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + # Force the entry due. + callbacks = sched._needs["11:22:33:44:55:66"] + bleak_callback = next(iter(callbacks)) + callbacks[bleak_callback] = loop.time() - 1.0 + sched._async_tick() + await _drain(loop) + assert scanner.active_window_calls == [5.0] + # next_due was advanced. + assert callbacks[bleak_callback] > loop.time() + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_tick_coalesces_overlapping_callbacks() -> None: + """Two callbacks for the same address coalesce into one window with max duration.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb1(_device: Any, _adv: Any) -> None: ... + + def _cb2(_device: Any, _adv: Any) -> None: ... + + cancel1 = manager.async_register_bleak_callback( + _cb1, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=120.0, + scan_duration=3.0, + ) + cancel2 = manager.async_register_bleak_callback( + _cb2, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=120.0, + scan_duration=10.0, + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "x") + adv = generate_advertisement_data(local_name="x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + callbacks = sched._needs["11:22:33:44:55:66"] + for cb in list(callbacks): + callbacks[cb] = loop.time() - 1.0 + sched._async_tick() + await _drain(loop) + assert scanner.active_window_calls == [10.0] + finally: + cancel1() + cancel2() + register_cancel() + + +@pytest.mark.asyncio +async def test_tick_skips_non_auto_scanner() -> None: + """An ACTIVE/PASSIVE scanner is not asked to run extra windows.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=120.0, + scan_duration=3.0, + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.ACTIVE) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "x") + adv = generate_advertisement_data(local_name="x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + callbacks = sched._needs.get("11:22:33:44:55:66", {}) + for cb in list(callbacks): + callbacks[cb] = loop.time() - 1.0 + sched._async_tick() + await _drain(loop) + assert scanner.active_window_calls == [] + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_global_sweep_runs_on_auto_scanner() -> None: + """The 4 h sweep fires async_request_active_window with SWEEP_DURATION.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + # Force the scanner's "last sweep" to be older than the interval. + sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( + loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + ) + sched._async_tick() + await _drain(loop) + assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] + assert sched._sweep_in_flight is None # cleared after window completes + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_global_sweep_one_scanner_at_a_time() -> None: + """While one scanner sweeps, no other scanner is asked to sweep.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + # Block the first scanner's active-window task so the sweep stays in flight. + blocking = asyncio.Event() + s1 = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + s1._block_event = blocking + s2 = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO) + c1 = manager.async_register_scanner(s1) + c2 = manager.async_register_scanner(s2) + try: + now = loop.time() + sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( + now - AUTO_REDISCOVERY_INTERVAL - 10 + ) + sched._sweep_last_completed["AA:BB:CC:DD:EE:11"] = ( + now - AUTO_REDISCOVERY_INTERVAL - 5 + ) + sched._async_tick() + await _drain(loop) + assert sched._sweep_in_flight == "AA:BB:CC:DD:EE:00" + # A second tick must NOT start s2's sweep while s1's is in flight. + sched._async_tick() + await _drain(loop) + assert s2.active_window_calls == [] + blocking.set() + # Drain the now-completed sweep. + await asyncio.sleep(0) + await asyncio.sleep(0) + assert sched._sweep_in_flight is None + finally: + c1() + c2() + + +@pytest.mark.asyncio +async def test_remove_callback_clears_tracking() -> None: + """Removing a registered callback prunes its per-(address, cb) entries.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=60.0, + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "x") + adv = generate_advertisement_data(local_name="x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + assert "11:22:33:44:55:66" in sched._needs + cancel() + assert "11:22:33:44:55:66" not in sched._needs + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_remove_scanner_clears_sweep_state() -> None: + """Unregistering a scanner drops its sweep / window state.""" + manager = get_manager() + sched = manager._auto_scheduler + + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + cancel = manager.async_register_scanner(scanner) + assert "AA:BB:CC:DD:EE:00" in sched._sweep_last_completed + cancel() + assert "AA:BB:CC:DD:EE:00" not in sched._sweep_last_completed + + +@pytest.mark.asyncio +async def test_duration_clamped_to_bounds() -> None: + """_coalesce_duration clamps the requested duration to the configured range.""" + manager = get_manager() + sched = manager._auto_scheduler + + def _cb(_device: Any, _adv: Any) -> None: ... + + too_small = BleakCallback(_cb, {}, scan_duration=0.01) + too_big = BleakCallback(_cb, {}, scan_duration=1000.0) + in_range = BleakCallback(_cb, {}, scan_duration=7.5) + + assert sched._coalesce_duration([too_small]) == AUTO_WINDOW_MIN_DURATION + assert sched._coalesce_duration([too_big]) == AUTO_WINDOW_MAX_DURATION + assert sched._coalesce_duration([in_range]) == 7.5 + # max() then clamp — the largest wins. + assert sched._coalesce_duration([too_small, in_range]) == 7.5 + assert sched._coalesce_duration([in_range, too_big]) == AUTO_WINDOW_MAX_DURATION From d71c8efb0163368e521b0359226f04c016c2b3a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 21:57:45 -0500 Subject: [PATCH 02/75] fix(auto): address review feedback on scheduler and HaScanner stop 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. --- src/habluetooth/auto_scheduler.py | 64 +++++++++--- src/habluetooth/manager.py | 1 + src/habluetooth/scanner.py | 5 + tests/test_auto_scheduler.py | 164 +++++++++++++++++++++++++++++- 4 files changed, 220 insertions(+), 14 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 07a6bf6a..dd2fdae1 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -58,6 +58,7 @@ class AutoScanScheduler: """Schedules on-demand active windows across AUTO-mode scanners.""" __slots__ = ( + "_interval_callbacks", "_loop", "_manager", "_needs", @@ -84,25 +85,40 @@ def __init__(self, manager: BluetoothManager) -> None: self._loop: asyncio.AbstractEventLoop | None = None self._running = False self._pending_tasks: set[asyncio.Task[None]] = set() + # Callbacks with a non-None scan_interval. Tracked separately from + # the manager's _bleak_callbacks so the on_advertisement hot path + # can early-return without iterating regular bleak callbacks. + self._interval_callbacks: set[BleakCallback] = set() def start(self, loop: asyncio.AbstractEventLoop) -> None: """Bind the scheduler to the event loop and schedule the first tick.""" self._loop = loop self._running = True # Initialize last-sweep so the first sweep is one interval out. + # Overwrite any placeholder timestamps left by pre-start add_scanner + # calls (which had no loop available); otherwise scanners registered + # before async_setup would have last_sweep=0.0 and trigger an + # immediate sweep on the first tick. now = loop.time() for source in self._manager._sources: scanner = self._manager._sources[source] if scanner.requested_mode is BluetoothScanningMode.AUTO: - self._sweep_last_completed.setdefault(source, now) + self._sweep_last_completed[source] = now self._reschedule() def stop(self) -> None: - """Cancel any pending tick and refuse further work.""" + """Cancel any pending tick and pending window tasks.""" self._running = False if self._tick_handle is not None: self._tick_handle.cancel() self._tick_handle = None + # Cancel any window tasks in flight so they cannot call + # async_request_active_window after shutdown has started. + for task in self._pending_tasks: + task.cancel() + self._pending_tasks.clear() + self._scanner_windows.clear() + self._sweep_in_flight = None def add_scanner(self, scanner: BaseHaScanner) -> None: """Register an AUTO-mode scanner for the global rediscovery sweep.""" @@ -122,10 +138,17 @@ def remove_scanner(self, scanner: BaseHaScanner) -> None: self._sweep_in_flight = None self._reschedule() + def add_callback(self, callback: BleakCallback) -> None: + """Register a callback so the on_advertisement hot path notices it.""" + if callback.scan_interval is None: + return + self._interval_callbacks.add(callback) + def remove_callback(self, callback: BleakCallback) -> None: """Drop per-(address, callback) tracking for a removed registration.""" if callback.scan_interval is None: return + self._interval_callbacks.discard(callback) empty_addresses: list[str] = [] for address, callbacks in self._needs.items(): if callback in callbacks: @@ -138,21 +161,23 @@ def remove_callback(self, callback: BleakCallback) -> None: def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: """Hot path. Record a tracking entry for any callback that wants one.""" - if not self._manager._bleak_callbacks or self._loop is None: + # Early return when no callback wants an interval. This is the common + # case, so the cost of being on the manager's adv hot path stays at + # one bound-method dispatch plus an empty-set check. + if not self._interval_callbacks or self._loop is None: return address = service_info.address existing = self._needs.get(address) - for callback in self._manager._bleak_callbacks: - if callback.scan_interval is None: - continue - if not _matches(callback, service_info): + for callback in self._interval_callbacks: + interval = callback.scan_interval + if interval is None or not _matches(callback, service_info): continue if existing is None: existing = self._needs[address] = {} if callback not in existing: # First time we see this address for this callback: fire one # window soon, then settle into the cadence. - existing[callback] = self._loop.time() + callback.scan_interval + existing[callback] = self._loop.time() + interval self._reschedule() def _reschedule(self) -> None: @@ -205,13 +230,20 @@ def _dispatch_per_device(self, now: float) -> None: continue history = self._manager._all_history.get(address) if history is None: - # No recent sight; drop the tracking entries — they'll come + # No recent sight; drop the tracking entries, they'll come # back the next time the device advertises. del self._needs[address] continue source = history.source - if source in self._scanner_windows: - # Scanner already busy; the next tick will retry. + if (busy_end := self._scanner_windows.get(source)) is not None: + # Scanner busy. Defer all due callbacks to just after the + # window ends; without this the next event time stays in + # the past and the tick re-fires every 50ms until the + # window drains. + deferred_due = busy_end + 0.05 + for cb in due_callbacks: + if callbacks[cb] < deferred_due: + callbacks[cb] = deferred_due continue scanner = self._manager._sources.get(source) if scanner is None or scanner.requested_mode is not ( @@ -293,8 +325,9 @@ async def _run_window( sweep_source: str | None, ) -> None: """Await the scanner's active window and clear in-flight state.""" + ok = False try: - await scanner.async_request_active_window(duration) + ok = await scanner.async_request_active_window(duration) except Exception: # pylint: disable=broad-except _LOGGER.exception( "%s: error running active window of %.1fs", @@ -302,8 +335,13 @@ async def _run_window( duration, ) finally: + # When the scanner could not honor the request (returned False + # or raised), drop the busy marker now so other work for that + # source isn't blocked for the full duration. + if not ok and self._scanner_windows.get(scanner.source) is not None: + del self._scanner_windows[scanner.source] if sweep_source is not None: - if self._loop is not None: + if ok and self._loop is not None: self._sweep_last_completed[sweep_source] = self._loop.time() if self._sweep_in_flight == sweep_source: self._sweep_in_flight = None diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 8fb2ef5c..120b57d3 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1091,6 +1091,7 @@ def async_register_bleak_callback( scan_duration=scan_duration, ) self._bleak_callbacks.add(callback_entry) + self._auto_scheduler.add_callback(callback_entry) # Replay the history since otherwise we miss devices # that were already discovered before the callback was registered # or we are in passive mode diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 44116d8e..b16338de 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -640,6 +640,11 @@ async def async_stop(self) -> None: if self._active_window_handle is not None: self._active_window_handle.cancel() self._active_window_handle = None + # Clear any in-flight AUTO active-window state. Without this a later + # async_start would still see _scan_mode_override == ACTIVE and the + # scanner would come back in continuous active mode. + self._scan_mode_override = None + self._active_window_end = 0.0 if self._start_future is not None and not self._start_future.done(): self._start_future.set_exception(_AbortStartError()) async with self._start_stop_lock: diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 3135a058..b6971f9a 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -436,6 +436,168 @@ def _cb(_device: Any, _adv: Any) -> None: ... assert sched._coalesce_duration([too_small]) == AUTO_WINDOW_MIN_DURATION assert sched._coalesce_duration([too_big]) == AUTO_WINDOW_MAX_DURATION assert sched._coalesce_duration([in_range]) == 7.5 - # max() then clamp — the largest wins. + # max() then clamp; the largest wins. assert sched._coalesce_duration([too_small, in_range]) == 7.5 assert sched._coalesce_duration([in_range, too_big]) == AUTO_WINDOW_MAX_DURATION + + +@pytest.mark.asyncio +async def test_on_advertisement_early_returns_with_no_interval_callbacks() -> None: + """Hot path is a no-op when no callback declared a scan_interval.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + # A regular bleak callback (no scan_interval) should NOT populate _needs. + cancel = manager.async_register_bleak_callback(_cb, {}) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "x") + adv = generate_advertisement_data(local_name="x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + assert sched._needs == {} + assert sched._interval_callbacks == set() + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_busy_scanner_defers_due_callbacks_not_busy_loops() -> None: + """If a scanner is mid-window, due callbacks are pushed past the window.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=60.0, + scan_duration=3.0, + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "x") + adv = generate_advertisement_data(local_name="x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + callbacks = sched._needs["11:22:33:44:55:66"] + bleak_callback = next(iter(callbacks)) + # Make the callback due and the scanner busy until 5s from now. + callbacks[bleak_callback] = loop.time() - 1.0 + busy_end = loop.time() + 5.0 + sched._scanner_windows[scanner.source] = busy_end + sched._async_tick() + await _drain(loop) + # No window request fired (scanner busy). + assert scanner.active_window_calls == [] + # The callback's due time was deferred past the busy window. + assert callbacks[bleak_callback] >= busy_end + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_failed_request_clears_busy_marker() -> None: + """A False return from async_request_active_window frees the scanner immediately.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=60.0, + scan_duration=3.0, + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + scanner._return_value = False + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "x") + adv = generate_advertisement_data(local_name="x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + callbacks = sched._needs["11:22:33:44:55:66"] + for cb in list(callbacks): + callbacks[cb] = loop.time() - 1.0 + sched._async_tick() + await _drain(loop) + # Scanner was asked; it returned False; busy marker was cleared. + assert scanner.active_window_calls == [3.0] + assert scanner.source not in sched._scanner_windows + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_stop_cancels_pending_window_tasks() -> None: + """Scheduler.stop cancels in-flight active-window tasks.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + blocking = asyncio.Event() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + scanner._block_event = blocking + register_cancel = manager.async_register_scanner(scanner) + try: + sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( + loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + ) + sched._async_tick() + await _drain(loop) + assert len(sched._pending_tasks) == 1 + pending = next(iter(sched._pending_tasks)) + sched.stop() + # Yield so the cancelled task can settle. + await asyncio.sleep(0) + assert pending.cancelled() or pending.done() + assert sched._pending_tasks == set() + assert sched._scanner_windows == {} + assert sched._sweep_in_flight is None + finally: + # Let the blocked task exit cleanly even after cancellation so the + # event loop has no dangling waiter at fixture teardown. + blocking.set() + register_cancel() From cfec48f2496ad95b0c1e954ac9c0604493698384 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 22:18:03 -0500 Subject: [PATCH 03/75] perf(auto): cythonize AutoScanScheduler so on_advertisement is a C call 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. --- build_ext.py | 1 + src/habluetooth/auto_scheduler.pxd | 29 +++++++++++++++++++++++++++++ src/habluetooth/manager.pxd | 3 ++- 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/habluetooth/auto_scheduler.pxd diff --git a/build_ext.py b/build_ext.py index 2ffdcef3..387b9172 100644 --- a/build_ext.py +++ b/build_ext.py @@ -15,6 +15,7 @@ TO_CYTHONIZE = [ "src/habluetooth/advertisement_tracker.py", + "src/habluetooth/auto_scheduler.py", "src/habluetooth/base_scanner.py", "src/habluetooth/manager.py", "src/habluetooth/models.py", diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd new file mode 100644 index 00000000..fd8e239e --- /dev/null +++ b/src/habluetooth/auto_scheduler.pxd @@ -0,0 +1,29 @@ +import cython + + +cdef class AutoScanScheduler: + + cdef public object _manager + cdef public dict _needs + cdef public dict _scanner_windows + cdef public dict _sweep_last_completed + cdef public object _sweep_in_flight + cdef public object _tick_handle + cdef public object _loop + cdef public bint _running + cdef public set _pending_tasks + cdef public set _interval_callbacks + + cpdef void add_callback(self, object callback) + + cpdef void remove_callback(self, object callback) + + cpdef void add_scanner(self, object scanner) + + cpdef void remove_scanner(self, object scanner) + + cpdef void on_advertisement(self, object service_info) + + cpdef void start(self, object loop) + + cpdef void stop(self) diff --git a/src/habluetooth/manager.pxd b/src/habluetooth/manager.pxd index 51e13282..f019ff9e 100644 --- a/src/habluetooth/manager.pxd +++ b/src/habluetooth/manager.pxd @@ -1,6 +1,7 @@ import cython from .advertisement_tracker cimport AdvertisementTracker +from .auto_scheduler cimport AutoScanScheduler from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak @@ -72,7 +73,7 @@ cdef class BluetoothManager: cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl - cdef public object _auto_scheduler + cdef public AutoScanScheduler _auto_scheduler @cython.locals(stale_seconds=double) cdef bint _prefer_previous_adv_from_different_source( From 6c0f3a05a8365e7cb786670fd4f5462e52bafe54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 22:29:24 -0500 Subject: [PATCH 04/75] perf(auto): annotate on_advertisement locals and cdef _matches 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. --- src/habluetooth/auto_scheduler.pxd | 23 +++-- tests/test_auto_scheduler.py | 118 ++++++++++++++++++++++++ tests/test_scanner.py | 140 +++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 5 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index fd8e239e..7ff562ec 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -1,5 +1,12 @@ import cython +from .base_scanner cimport BaseHaScanner +from .manager cimport BleakCallback +from .models cimport BluetoothServiceInfoBleak + + +cdef bint _matches(BleakCallback callback, BluetoothServiceInfoBleak service_info) + cdef class AutoScanScheduler: @@ -14,15 +21,21 @@ cdef class AutoScanScheduler: cdef public set _pending_tasks cdef public set _interval_callbacks - cpdef void add_callback(self, object callback) + cpdef void add_callback(self, BleakCallback callback) - cpdef void remove_callback(self, object callback) + cpdef void remove_callback(self, BleakCallback callback) - cpdef void add_scanner(self, object scanner) + cpdef void add_scanner(self, BaseHaScanner scanner) - cpdef void remove_scanner(self, object scanner) + cpdef void remove_scanner(self, BaseHaScanner scanner) - cpdef void on_advertisement(self, object service_info) + @cython.locals( + address=str, + existing=dict, + callback=BleakCallback, + interval=object, + ) + cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) cpdef void start(self, object loop) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index b6971f9a..670bd083 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -601,3 +601,121 @@ async def test_stop_cancels_pending_window_tasks() -> None: # event loop has no dangling waiter at fixture teardown. blocking.set() register_cancel() + + +@pytest.mark.asyncio +async def test_uuid_filter_excludes_non_matching_callback() -> None: + """A callback whose UUID filter does not intersect the adv is skipped.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {"UUIDs": {"0000abcd-0000-1000-8000-00805f9b34fb"}}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=60.0, + scan_duration=3.0, + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + device = generate_ble_device("11:22:33:44:55:66", "x") + # Service UUIDs that don't intersect the callback filter. + adv = generate_advertisement_data( + local_name="x", + service_uuids=["0000eeee-0000-1000-8000-00805f9b34fb"], + ) + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + loop.time(), + ) + # Callback did not match; no tracking entry was created. + assert sched._needs == {} + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_remove_scanner_clears_sweep_in_flight() -> None: + """Unregistering a scanner mid-sweep resets _sweep_in_flight.""" + manager = get_manager() + sched = manager._auto_scheduler + + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + cancel = manager.async_register_scanner(scanner) + sched._sweep_in_flight = scanner.source + cancel() + assert sched._sweep_in_flight is None + + +@pytest.mark.asyncio +async def test_dispatch_drops_tracking_for_unseen_address() -> None: + """A due address with no history entry is pruned, not retried.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + def _cb(_device: Any, _adv: Any) -> None: ... + + cancel = manager.async_register_bleak_callback( + _cb, + {}, + scanning_mode=BluetoothScanningMode.AUTO, + scan_interval=60.0, + ) + try: + # Inject a tracking entry directly so we can exercise the + # "history missing" branch without staging a real advertisement. + bleak_callback = next(iter(sched._interval_callbacks)) + sched._needs["aa:bb:cc:dd:ee:ff"] = {bleak_callback: loop.time() - 1.0} + sched._async_tick() + assert "aa:bb:cc:dd:ee:ff" not in sched._needs + finally: + cancel() + + +@pytest.mark.asyncio +async def test_add_scanner_before_start_stores_placeholder() -> None: + """A scanner registered before start() leaves a placeholder until start runs.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = sched._loop + # Detach the running loop so add_scanner takes the pre-start branch. + assert loop is not None + sched._loop = None + try: + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + sched.add_scanner(scanner) + assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] == 0.0 + # start() overwrites the 0.0 placeholder so the first sweep is one + # full interval out instead of immediate. + manager._sources[scanner.source] = scanner + sched.start(loop) + assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] > 0.0 + finally: + manager._sources.pop("AA:BB:CC:DD:EE:00", None) + sched._sweep_last_completed.pop("AA:BB:CC:DD:EE:00", None) + + +@pytest.mark.asyncio +async def test_stop_is_safe_when_already_idle() -> None: + """Calling stop() twice in a row is fully idempotent.""" + manager = get_manager() + sched = manager._auto_scheduler + sched.stop() + sched.stop() + assert sched._tick_handle is None + assert sched._pending_tasks == set() + assert sched._scanner_windows == {} + assert sched._sweep_in_flight is None diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 1b516e67..a5df3bfa 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1711,3 +1711,143 @@ async def test_on_scanner_start_callback( # Verify the callback was called assert len(manager.scanner_start_calls) == 1 assert manager.scanner_start_calls[0] is scanner + + +@pytest.mark.asyncio +async def test_async_request_active_window_rejected_when_not_auto() -> None: + """Non-AUTO scanners ignore active-window requests and return False.""" + scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + assert await scanner.async_request_active_window(1.0) is False + assert scanner._scan_mode_override is None + + +@pytest.mark.asyncio +async def test_async_request_active_window_restarts_scanner_in_active_mode() -> None: + """An AUTO scanner flips to ACTIVE and schedules a return to the prior mode.""" + + class MockBleakScanner: + def __init__(self): + self.start_modes: list[str] = [] + + async def start(self): + self.start_modes.append("started") + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + starts: list[str] = [] + + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() + + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Initial start: AUTO maps to passive in bleak's scanning_mode. + assert starts == ["passive"] + + # Window with 0 duration so call_later fires on the next loop turn. + assert await scanner.async_request_active_window(0.0) is True + # The restart cycle ran in ACTIVE mode. + assert starts == ["passive", "active"] + assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE + assert scanner._active_window_handle is not None + + # Let the call_later fire and the background restart task complete. + for _ in range(6): + await asyncio.sleep(0) + # End-of-window restored to passive (the underlying AUTO mode). + assert starts == ["passive", "active", "passive"] + assert scanner._scan_mode_override is None + assert scanner._active_window_handle is None # type: ignore[unreachable] + + await scanner.async_stop() + + +@pytest.mark.asyncio +async def test_async_request_active_window_extends_existing_window() -> None: + """A second request inside an active window extends the timer in place.""" + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + starts: list[str] = [] + + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() + + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + + assert await scanner.async_request_active_window(100.0) is True + first_handle = scanner._active_window_handle + first_end = scanner._active_window_end + # A longer request extends the existing window without a second restart. + assert await scanner.async_request_active_window(200.0) is True + assert scanner._active_window_handle is not first_handle + assert scanner._active_window_end > first_end + # Restart only happened once (initial + active), not three times. + assert starts == ["passive", "active"] + # A shorter follow-up is a no-op on the timer. + kept_end = scanner._active_window_end + assert await scanner.async_request_active_window(0.001) is True + assert scanner._active_window_end == kept_end + + await scanner.async_stop() + + +@pytest.mark.asyncio +async def test_async_stop_clears_active_window_state() -> None: + """Stopping mid-window cancels the timer and clears the override.""" + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + await scanner.async_request_active_window(100.0) + assert scanner._active_window_handle is not None + await scanner.async_stop() + assert scanner._active_window_handle is None + assert scanner._scan_mode_override is None # type: ignore[unreachable] + assert scanner._active_window_end == 0.0 From 7ab17b8c2f5c3d8006929e7931213c65a87886ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 22:45:57 -0500 Subject: [PATCH 05/75] feat(manager): replace BleakCallback kwargs with async_register_active_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. --- src/habluetooth/auto_scheduler.pxd | 22 +- src/habluetooth/auto_scheduler.py | 148 ++++--- src/habluetooth/manager.pxd | 5 +- src/habluetooth/manager.py | 98 ++--- tests/test_auto_scheduler.py | 626 +++++++++++------------------ 5 files changed, 382 insertions(+), 517 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 7ff562ec..e3f96e08 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -1,11 +1,15 @@ import cython from .base_scanner cimport BaseHaScanner -from .manager cimport BleakCallback from .models cimport BluetoothServiceInfoBleak -cdef bint _matches(BleakCallback callback, BluetoothServiceInfoBleak service_info) +cdef class ActiveScanRequest: + + cdef public object address + cdef public object service_uuid + cdef public double scan_interval + cdef public object scan_duration cdef class AutoScanScheduler: @@ -19,11 +23,12 @@ cdef class AutoScanScheduler: cdef public object _loop cdef public bint _running cdef public set _pending_tasks - cdef public set _interval_callbacks + cdef public dict _by_address + cdef public dict _by_service_uuid - cpdef void add_callback(self, BleakCallback callback) + cpdef void add_matcher(self, ActiveScanRequest request) - cpdef void remove_callback(self, BleakCallback callback) + cpdef void remove_matcher(self, ActiveScanRequest request) cpdef void add_scanner(self, BaseHaScanner scanner) @@ -32,8 +37,11 @@ cdef class AutoScanScheduler: @cython.locals( address=str, existing=dict, - callback=BleakCallback, - interval=object, + candidates=set, + by_addr=set, + by_uuid=set, + request=ActiveScanRequest, + uuid=str, ) cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index dd2fdae1..9c0b143e 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -4,23 +4,22 @@ Coordinates two distinct kinds of active scanning windows on AUTO-mode scanners: -* Per-callback windows. Bleak callbacks registered with - ``scan_interval``/``scan_duration`` cause a short active window on the - scanner that currently sees each matched device, fired once per - ``scan_interval`` seconds. Multiple matching callbacks for the same - address on the same scanner coalesce into one window whose duration is - the max of the coalesced durations. +* Per-device windows. Callers (Home Assistant's bluetooth integration is + the primary one) register an ``ActiveScanRequest`` matching an address + and / or service UUID with a ``scan_interval`` and ``scan_duration``. + When a matching advertisement arrives the scheduler asks the scanner + currently seeing the device to flip active for the requested duration, + repeating on the requested cadence. Multiple matching requests for the + same address coalesce into one window using the max of their durations. * Global rediscovery sweeps. Every ``AUTO_REDISCOVERY_INTERVAL`` seconds each AUTO-mode scanner gets a ``AUTO_REDISCOVERY_SWEEP_DURATION`` - active window. Sweeps are staggered across scanners so that at most - one scanner is mid-sweep at a time — the radio coverage gap stays - bounded. + active window. Sweeps are staggered across scanners so that at most one + scanner is mid-sweep at a time, keeping the radio coverage gap bounded. The scheduler is a single per-manager instance driven by one ``loop.call_at`` handle. ``on_advertisement`` is on the manager's hot -path; it must return cheaply when there are no per-device callbacks -registered. +path; it must return cheaply when no active-scan request is registered. """ from __future__ import annotations @@ -39,26 +38,45 @@ if TYPE_CHECKING: from .base_scanner import BaseHaScanner - from .manager import BleakCallback, BluetoothManager + from .manager import BluetoothManager from .models import BluetoothServiceInfoBleak _LOGGER = logging.getLogger(__name__) -def _matches(callback: BleakCallback, service_info: BluetoothServiceInfoBleak) -> bool: - """Return whether a service_info matches a callback's UUID filter.""" - uuids = callback.filters.get("UUIDs") - if uuids is None: - return True - return bool(uuids.intersection(service_info.service_uuids)) +class ActiveScanRequest: + """ + A registered need for on-demand active scans on matching devices. + + Created by ``BluetoothManager.async_register_active_scan``. Match is + by structured fields (``address``, ``service_uuid``) so the scheduler + can index lookups: an advertisement only iterates the requests that + match its own address or one of its service UUIDs, not the full set. + A request with multiple fields requires all of them to match. + """ + + __slots__ = ("address", "scan_duration", "scan_interval", "service_uuid") + + def __init__( + self, + address: str | None, + service_uuid: str | None, + scan_interval: float, + scan_duration: float | None, + ) -> None: + self.address = address + self.service_uuid = service_uuid + self.scan_interval = scan_interval + self.scan_duration = scan_duration class AutoScanScheduler: """Schedules on-demand active windows across AUTO-mode scanners.""" __slots__ = ( - "_interval_callbacks", + "_by_address", + "_by_service_uuid", "_loop", "_manager", "_needs", @@ -73,8 +91,8 @@ class AutoScanScheduler: def __init__(self, manager: BluetoothManager) -> None: """Initialize the scheduler bound to a manager.""" self._manager = manager - # address -> {callback: next_due_loop_time} - self._needs: dict[str, dict[BleakCallback, float]] = {} + # address -> {request: next_due_loop_time} + self._needs: dict[str, dict[ActiveScanRequest, float]] = {} # source -> loop time when the current window ends (0.0 = idle) self._scanner_windows: dict[str, float] = {} # source -> last sweep completion loop time @@ -85,10 +103,11 @@ def __init__(self, manager: BluetoothManager) -> None: self._loop: asyncio.AbstractEventLoop | None = None self._running = False self._pending_tasks: set[asyncio.Task[None]] = set() - # Callbacks with a non-None scan_interval. Tracked separately from - # the manager's _bleak_callbacks so the on_advertisement hot path - # can early-return without iterating regular bleak callbacks. - self._interval_callbacks: set[BleakCallback] = set() + # Indexed lookup of active-scan requests. Hot-path on_advertisement + # only iterates the requests whose declared address or service_uuid + # matches the advertisement, instead of every registered request. + self._by_address: dict[str, set[ActiveScanRequest]] = {} + self._by_service_uuid: dict[str, set[ActiveScanRequest]] = {} def start(self, loop: asyncio.AbstractEventLoop) -> None: """Bind the scheduler to the event loop and schedule the first tick.""" @@ -138,46 +157,69 @@ def remove_scanner(self, scanner: BaseHaScanner) -> None: self._sweep_in_flight = None self._reschedule() - def add_callback(self, callback: BleakCallback) -> None: - """Register a callback so the on_advertisement hot path notices it.""" - if callback.scan_interval is None: - return - self._interval_callbacks.add(callback) + def add_matcher(self, request: ActiveScanRequest) -> None: + """Register an active-scan request, indexing it by its fields.""" + if request.address is not None: + self._by_address.setdefault(request.address, set()).add(request) + if request.service_uuid is not None: + self._by_service_uuid.setdefault(request.service_uuid, set()).add(request) - def remove_callback(self, callback: BleakCallback) -> None: - """Drop per-(address, callback) tracking for a removed registration.""" - if callback.scan_interval is None: - return - self._interval_callbacks.discard(callback) + def remove_matcher(self, request: ActiveScanRequest) -> None: + """Drop the request from indexes and from any per-address tracking.""" + if request.address is not None and ( + bucket := self._by_address.get(request.address) + ): + bucket.discard(request) + if not bucket: + del self._by_address[request.address] + if request.service_uuid is not None and ( + bucket := self._by_service_uuid.get(request.service_uuid) + ): + bucket.discard(request) + if not bucket: + del self._by_service_uuid[request.service_uuid] empty_addresses: list[str] = [] - for address, callbacks in self._needs.items(): - if callback in callbacks: - del callbacks[callback] - if not callbacks: + for address, entries in self._needs.items(): + if request in entries: + del entries[request] + if not entries: empty_addresses.append(address) for address in empty_addresses: del self._needs[address] self._reschedule() def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: - """Hot path. Record a tracking entry for any callback that wants one.""" - # Early return when no callback wants an interval. This is the common - # case, so the cost of being on the manager's adv hot path stays at - # one bound-method dispatch plus an empty-set check. - if not self._interval_callbacks or self._loop is None: + """Hot path. Record a tracking entry for any matched request.""" + # Early return when nothing is registered. Common case, cheap. + if (not self._by_address and not self._by_service_uuid) or self._loop is None: return address = service_info.address + candidates: set[ActiveScanRequest] | None = None + if (by_addr := self._by_address.get(address)) is not None: + candidates = by_addr.copy() + for uuid in service_info.service_uuids: + if (by_uuid := self._by_service_uuid.get(uuid)) is not None: + if candidates is None: + candidates = by_uuid.copy() + else: + candidates.update(by_uuid) + if not candidates: + return existing = self._needs.get(address) - for callback in self._interval_callbacks: - interval = callback.scan_interval - if interval is None or not _matches(callback, service_info): + for request in candidates: + # Verify all declared fields match; a request indexed under + # address may still require service_uuid (or vice versa). + if request.address is not None and request.address != address: + continue + if ( + request.service_uuid is not None + and request.service_uuid not in service_info.service_uuids + ): continue if existing is None: existing = self._needs[address] = {} - if callback not in existing: - # First time we see this address for this callback: fire one - # window soon, then settle into the cadence. - existing[callback] = self._loop.time() + interval + if request not in existing: + existing[request] = self._loop.time() + request.scan_interval self._reschedule() def _reschedule(self) -> None: @@ -292,10 +334,10 @@ def _dispatch_global_sweep(self, now: float) -> None: scanner, AUTO_REDISCOVERY_SWEEP_DURATION, sweep_source=eligible ) - def _coalesce_duration(self, callbacks: list[BleakCallback]) -> float: + def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """Pick the max requested duration, clamped to the configured range.""" requested = max( - (cb.scan_duration for cb in callbacks if cb.scan_duration is not None), + (e.scan_duration for e in entries if e.scan_duration is not None), default=AUTO_WINDOW_MIN_DURATION, ) if requested < AUTO_WINDOW_MIN_DURATION: diff --git a/src/habluetooth/manager.pxd b/src/habluetooth/manager.pxd index f019ff9e..b3dcafc3 100644 --- a/src/habluetooth/manager.pxd +++ b/src/habluetooth/manager.pxd @@ -1,7 +1,7 @@ import cython from .advertisement_tracker cimport AdvertisementTracker -from .auto_scheduler cimport AutoScanScheduler +from .auto_scheduler cimport ActiveScanRequest, AutoScanScheduler from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak @@ -34,9 +34,6 @@ cdef class BleakCallback: cdef public object callback cdef public dict filters - cdef public object scanning_mode - cdef public object scan_interval - cdef public object scan_duration cdef class BluetoothManager: diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 120b57d3..a5c06650 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -6,7 +6,6 @@ import itertools import logging import platform -import warnings from collections.abc import Callable, Iterable from dataclasses import asdict from functools import partial @@ -32,7 +31,7 @@ TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) -from .auto_scheduler import AutoScanScheduler +from .auto_scheduler import ActiveScanRequest, AutoScanScheduler from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl from .const import ( ADV_RSSI_SWITCH_THRESHOLD, @@ -42,7 +41,6 @@ UNAVAILABLE_TRACK_SECONDS, ) from .models import ( - BluetoothScanningMode, BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, HaScannerModeChange, @@ -100,28 +98,14 @@ def _dispatch_bleak_callback( class BleakCallback: """Bleak callback.""" - __slots__ = ( - "callback", - "filters", - "scan_duration", - "scan_interval", - "scanning_mode", - ) + __slots__ = ("callback", "filters") def __init__( - self, - callback: AdvertisementDataCallback, - filters: dict[str, set[str]], - scanning_mode: BluetoothScanningMode | None = None, - scan_interval: float | None = None, - scan_duration: float | None = None, + self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> None: """Init bleak callback.""" self.callback = callback self.filters = filters - self.scanning_mode = scanning_mode - self.scan_interval = scan_interval - self.scan_duration = scan_duration class BluetoothManager: @@ -1052,46 +1036,11 @@ def async_register_scanner( ) def async_register_bleak_callback( - self, - callback: AdvertisementDataCallback, - filters: dict[str, set[str]], - *, - scanning_mode: BluetoothScanningMode | None = None, - scan_interval: float | None = None, - scan_duration: float | None = None, + self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> CALLBACK_TYPE: - """ - Register a callback. - - ``scanning_mode`` declares whether the caller needs active scanning - for matched devices. ``scan_interval`` (seconds between active - sweeps) and ``scan_duration`` (length of each sweep, seconds) tell - the auto-mode scheduler how often and how long to flip an AUTO-mode - scanner into active for the matched devices. - - Registering an ACTIVE callback without ``scan_interval`` is - deprecated: integrations should declare their actual cadence so - coordinated scanners can stay passive most of the time. - """ - if scanning_mode is BluetoothScanningMode.ACTIVE and scan_interval is None: - warnings.warn( - f"Bleak callback {getattr(callback, '__qualname__', callback)!r} " - "registered with ACTIVE scanning mode but no scan_interval; " - "this forces continuous active scanning. Pass " - "scan_interval= and scan_duration= so " - "AUTO-mode scanners can schedule windowed active scans.", - DeprecationWarning, - stacklevel=2, - ) - callback_entry = BleakCallback( - callback, - filters, - scanning_mode=scanning_mode, - scan_interval=scan_interval, - scan_duration=scan_duration, - ) + """Register a callback.""" + callback_entry = BleakCallback(callback, filters) self._bleak_callbacks.add(callback_entry) - self._auto_scheduler.add_callback(callback_entry) # Replay the history since otherwise we miss devices # that were already discovered before the callback was registered # or we are in passive mode @@ -1100,12 +1049,37 @@ def async_register_bleak_callback( callback_entry, history.device, history.advertisement ) - return partial(self._async_remove_bleak_callback, callback_entry) + return partial(self._bleak_callbacks.remove, callback_entry) + + def async_register_active_scan( + self, + scan_interval: float, + *, + address: str | None = None, + service_uuid: str | None = None, + scan_duration: float | None = None, + ) -> CALLBACK_TYPE: + """ + Declare an on-demand active-scan need for matching advertisements. + + At least one of ``address`` or ``service_uuid`` must be provided; + if both are given the device must match both. The scheduler picks + the AUTO-mode scanner currently in range of the matched address + and asks it to flip into active for ``scan_duration`` seconds, + repeating every ``scan_interval`` seconds while the device is + being seen. ACTIVE and PASSIVE scanners ignore the request. - def _async_remove_bleak_callback(self, callback_entry: BleakCallback) -> None: - """Unregister a bleak callback and drop any scheduler tracking.""" - self._bleak_callbacks.discard(callback_entry) - self._auto_scheduler.remove_callback(callback_entry) + Returns a cancel callable that removes the registration and any + per-(address, request) tracking it accumulated. + """ + if address is None and service_uuid is None: + raise ValueError( + "async_register_active_scan requires at least one of " + "address or service_uuid to be specified" + ) + request = ActiveScanRequest(address, service_uuid, scan_interval, scan_duration) + self._auto_scheduler.add_matcher(request) + return partial(self._auto_scheduler.remove_matcher, request) def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 670bd083..8b7704f3 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Iterable -from typing import Any import pytest from bleak.backends.device import BLEDevice @@ -15,13 +14,13 @@ BluetoothScanningMode, get_manager, ) +from habluetooth.auto_scheduler import ActiveScanRequest from habluetooth.const import ( AUTO_REDISCOVERY_INTERVAL, AUTO_REDISCOVERY_SWEEP_DURATION, AUTO_WINDOW_MAX_DURATION, AUTO_WINDOW_MIN_DURATION, ) -from habluetooth.manager import BleakCallback from . import generate_advertisement_data, generate_ble_device @@ -69,196 +68,161 @@ def discovered_addresses(self) -> Iterable[str]: return () -async def _drain(loop: asyncio.AbstractEventLoop) -> None: - """Yield once so scheduled tasks run.""" - await asyncio.sleep(0) +SERVICE_UUID = "0000fe07-0000-1000-8000-00805f9b34fb" -@pytest.mark.asyncio -async def test_register_active_without_interval_warns() -> None: - """ACTIVE registration without scan_interval emits DeprecationWarning.""" - manager = get_manager() +def _inject( + scanner: _RecordingAutoScanner, + address: str, + service_uuids: list[str] | None = None, +) -> None: + """Drive a fake advertisement through the scanner's normal path.""" + adv = generate_advertisement_data( + local_name="x", + service_uuids=service_uuids or [], + ) + device = generate_ble_device(address, "x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + asyncio.get_running_loop().time(), + ) - def _cb(_device: Any, _adv: Any) -> None: ... - with pytest.warns(DeprecationWarning, match="scan_interval"): - cancel = manager.async_register_bleak_callback( - _cb, {}, scanning_mode=BluetoothScanningMode.ACTIVE - ) - cancel() +async def _drain() -> None: + await asyncio.sleep(0) @pytest.mark.asyncio -async def test_register_active_with_interval_does_not_warn( - recwarn: pytest.WarningsRecorder, -) -> None: - """ACTIVE with scan_interval should not emit the deprecation warning.""" +async def test_register_requires_address_or_service_uuid() -> None: + """async_register_active_scan rejects empty registrations.""" manager = get_manager() + with pytest.raises(ValueError, match="address or service_uuid"): + manager.async_register_active_scan(scan_interval=60.0) - def _cb(_device: Any, _adv: Any) -> None: ... - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.ACTIVE, - scan_interval=300.0, - scan_duration=5.0, - ) - cancel() - assert not any( - issubclass(w.category, DeprecationWarning) and "scan_interval" in str(w.message) - for w in recwarn.list +@pytest.mark.asyncio +async def test_advertisement_by_address_starts_tracking() -> None: + """A matching address advertisement creates a per-(address, request) entry.""" + manager = get_manager() + sched = manager._auto_scheduler + cancel = manager.async_register_active_scan( + scan_interval=120.0, address="11:22:33:44:55:66", scan_duration=3.0 ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, "11:22:33:44:55:66") + assert "11:22:33:44:55:66" in sched._needs + finally: + cancel() + register_cancel() + assert sched._needs == {} @pytest.mark.asyncio -async def test_passive_or_auto_no_warn(recwarn: pytest.WarningsRecorder) -> None: - """PASSIVE / AUTO / unset registrations never emit the deprecation.""" +async def test_advertisement_by_service_uuid_starts_tracking() -> None: + """A matching service UUID advertisement creates a tracking entry.""" manager = get_manager() - - def _cb(_device: Any, _adv: Any) -> None: ... - - for mode in (None, BluetoothScanningMode.PASSIVE, BluetoothScanningMode.AUTO): - cancel = manager.async_register_bleak_callback(_cb, {}, scanning_mode=mode) - cancel() - assert not any( - issubclass(w.category, DeprecationWarning) and "scan_interval" in str(w.message) - for w in recwarn.list + sched = manager._auto_scheduler + cancel = manager.async_register_active_scan( + scan_interval=120.0, service_uuid=SERVICE_UUID, scan_duration=3.0 ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, "11:22:33:44:55:66", service_uuids=[SERVICE_UUID]) + assert "11:22:33:44:55:66" in sched._needs + finally: + cancel() + register_cancel() @pytest.mark.asyncio -async def test_advertisement_starts_tracking() -> None: - """on_advertisement should add a per-(address, callback) tracker entry.""" +async def test_address_and_service_uuid_requires_both() -> None: + """A request with both fields skips an ad that only matches one.""" manager = get_manager() sched = manager._auto_scheduler - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=120.0, + cancel = manager.async_register_active_scan( + scan_interval=60.0, + address="11:22:33:44:55:66", + service_uuid=SERVICE_UUID, scan_duration=3.0, ) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "inkbird") - adv = generate_advertisement_data(local_name="inkbird") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - asyncio.get_running_loop().time(), + # Address matches, service_uuid does not. + _inject( + scanner, + "11:22:33:44:55:66", + service_uuids=["0000eeee-0000-1000-8000-00805f9b34fb"], ) + assert sched._needs == {} + # Service uuid matches, address does not. + _inject(scanner, "AA:AA:AA:AA:AA:AA", service_uuids=[SERVICE_UUID]) + assert sched._needs == {} + # Both match. + _inject(scanner, "11:22:33:44:55:66", service_uuids=[SERVICE_UUID]) assert "11:22:33:44:55:66" in sched._needs - assert len(sched._needs["11:22:33:44:55:66"]) == 1 finally: cancel() register_cancel() - assert sched._needs == {} @pytest.mark.asyncio async def test_tick_requests_active_window_on_auto_scanner() -> None: - """When a tracker entry is due, the tick should call the scanner.""" + """A due tracker entry triggers an active window on the owning scanner.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=120.0, - scan_duration=5.0, + cancel = manager.async_register_active_scan( + scan_interval=120.0, address="11:22:33:44:55:66", scan_duration=5.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "inkbird") - adv = generate_advertisement_data(local_name="inkbird") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - # Force the entry due. - callbacks = sched._needs["11:22:33:44:55:66"] - bleak_callback = next(iter(callbacks)) - callbacks[bleak_callback] = loop.time() - 1.0 + _inject(scanner, "11:22:33:44:55:66") + entries = sched._needs["11:22:33:44:55:66"] + request = next(iter(entries)) + entries[request] = loop.time() - 1.0 sched._async_tick() - await _drain(loop) + await _drain() assert scanner.active_window_calls == [5.0] - # next_due was advanced. - assert callbacks[bleak_callback] > loop.time() + assert entries[request] > loop.time() finally: cancel() register_cancel() @pytest.mark.asyncio -async def test_tick_coalesces_overlapping_callbacks() -> None: - """Two callbacks for the same address coalesce into one window with max duration.""" +async def test_tick_coalesces_overlapping_requests() -> None: + """Two requests for the same address coalesce into one window using max duration.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - - def _cb1(_device: Any, _adv: Any) -> None: ... - - def _cb2(_device: Any, _adv: Any) -> None: ... - - cancel1 = manager.async_register_bleak_callback( - _cb1, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=120.0, - scan_duration=3.0, + address = "11:22:33:44:55:66" + cancel1 = manager.async_register_active_scan( + scan_interval=120.0, address=address, scan_duration=3.0 ) - cancel2 = manager.async_register_bleak_callback( - _cb2, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=120.0, - scan_duration=10.0, + cancel2 = manager.async_register_active_scan( + scan_interval=120.0, address=address, scan_duration=10.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "x") - adv = generate_advertisement_data(local_name="x") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - callbacks = sched._needs["11:22:33:44:55:66"] - for cb in list(callbacks): - callbacks[cb] = loop.time() - 1.0 + _inject(scanner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 sched._async_tick() - await _drain(loop) + await _drain() assert scanner.active_window_calls == [10.0] finally: cancel1() @@ -268,41 +232,23 @@ def _cb2(_device: Any, _adv: Any) -> None: ... @pytest.mark.asyncio async def test_tick_skips_non_auto_scanner() -> None: - """An ACTIVE/PASSIVE scanner is not asked to run extra windows.""" + """ACTIVE / PASSIVE scanners are not asked to flip; due times advance.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=120.0, - scan_duration=3.0, + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + scan_interval=120.0, address=address, scan_duration=3.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.ACTIVE) register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "x") - adv = generate_advertisement_data(local_name="x") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - callbacks = sched._needs.get("11:22:33:44:55:66", {}) - for cb in list(callbacks): - callbacks[cb] = loop.time() - 1.0 + _inject(scanner, address) + entries = sched._needs.get(address, {}) + for req in list(entries): + entries[req] = loop.time() - 1.0 sched._async_tick() - await _drain(loop) + await _drain() assert scanner.active_window_calls == [] finally: cancel() @@ -311,22 +257,20 @@ def _cb(_device: Any, _adv: Any) -> None: ... @pytest.mark.asyncio async def test_global_sweep_runs_on_auto_scanner() -> None: - """The 4 h sweep fires async_request_active_window with SWEEP_DURATION.""" + """The 4h sweep fires async_request_active_window with SWEEP_DURATION.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - # Force the scanner's "last sweep" to be older than the interval. sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 ) sched._async_tick() - await _drain(loop) + await _drain() assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] - assert sched._sweep_in_flight is None # cleared after window completes + assert sched._sweep_in_flight is None finally: register_cancel() @@ -337,8 +281,6 @@ async def test_global_sweep_one_scanner_at_a_time() -> None: manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - - # Block the first scanner's active-window task so the sweep stays in flight. blocking = asyncio.Event() s1 = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) s1._block_event = blocking @@ -354,171 +296,63 @@ async def test_global_sweep_one_scanner_at_a_time() -> None: now - AUTO_REDISCOVERY_INTERVAL - 5 ) sched._async_tick() - await _drain(loop) + await _drain() assert sched._sweep_in_flight == "AA:BB:CC:DD:EE:00" - # A second tick must NOT start s2's sweep while s1's is in flight. sched._async_tick() - await _drain(loop) + await _drain() assert s2.active_window_calls == [] blocking.set() - # Drain the now-completed sweep. await asyncio.sleep(0) await asyncio.sleep(0) assert sched._sweep_in_flight is None finally: + blocking.set() c1() c2() @pytest.mark.asyncio -async def test_remove_callback_clears_tracking() -> None: - """Removing a registered callback prunes its per-(address, cb) entries.""" +async def test_remove_matcher_clears_tracking() -> None: + """Cancelling a registration removes its per-(address, request) entries.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=60.0, - ) + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan(scan_interval=60.0, address=address) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "x") - adv = generate_advertisement_data(local_name="x") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - assert "11:22:33:44:55:66" in sched._needs + _inject(scanner, address) + assert address in sched._needs cancel() - assert "11:22:33:44:55:66" not in sched._needs + assert address not in sched._needs + assert sched._by_address == {} finally: register_cancel() -@pytest.mark.asyncio -async def test_remove_scanner_clears_sweep_state() -> None: - """Unregistering a scanner drops its sweep / window state.""" - manager = get_manager() - sched = manager._auto_scheduler - - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - cancel = manager.async_register_scanner(scanner) - assert "AA:BB:CC:DD:EE:00" in sched._sweep_last_completed - cancel() - assert "AA:BB:CC:DD:EE:00" not in sched._sweep_last_completed - - -@pytest.mark.asyncio -async def test_duration_clamped_to_bounds() -> None: - """_coalesce_duration clamps the requested duration to the configured range.""" - manager = get_manager() - sched = manager._auto_scheduler - - def _cb(_device: Any, _adv: Any) -> None: ... - - too_small = BleakCallback(_cb, {}, scan_duration=0.01) - too_big = BleakCallback(_cb, {}, scan_duration=1000.0) - in_range = BleakCallback(_cb, {}, scan_duration=7.5) - - assert sched._coalesce_duration([too_small]) == AUTO_WINDOW_MIN_DURATION - assert sched._coalesce_duration([too_big]) == AUTO_WINDOW_MAX_DURATION - assert sched._coalesce_duration([in_range]) == 7.5 - # max() then clamp; the largest wins. - assert sched._coalesce_duration([too_small, in_range]) == 7.5 - assert sched._coalesce_duration([in_range, too_big]) == AUTO_WINDOW_MAX_DURATION - - -@pytest.mark.asyncio -async def test_on_advertisement_early_returns_with_no_interval_callbacks() -> None: - """Hot path is a no-op when no callback declared a scan_interval.""" - manager = get_manager() - sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - # A regular bleak callback (no scan_interval) should NOT populate _needs. - cancel = manager.async_register_bleak_callback(_cb, {}) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) - try: - device = generate_ble_device("11:22:33:44:55:66", "x") - adv = generate_advertisement_data(local_name="x") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - assert sched._needs == {} - assert sched._interval_callbacks == set() - finally: - cancel() - register_cancel() - - @pytest.mark.asyncio async def test_busy_scanner_defers_due_callbacks_not_busy_loops() -> None: - """If a scanner is mid-window, due callbacks are pushed past the window.""" + """A scanner mid-window pushes due requests past the window end.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=60.0, - scan_duration=3.0, + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + scan_interval=60.0, address=address, scan_duration=3.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "x") - adv = generate_advertisement_data(local_name="x") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - callbacks = sched._needs["11:22:33:44:55:66"] - bleak_callback = next(iter(callbacks)) - # Make the callback due and the scanner busy until 5s from now. - callbacks[bleak_callback] = loop.time() - 1.0 + _inject(scanner, address) + entries = sched._needs[address] + request = next(iter(entries)) + entries[request] = loop.time() - 1.0 busy_end = loop.time() + 5.0 sched._scanner_windows[scanner.source] = busy_end sched._async_tick() - await _drain(loop) - # No window request fired (scanner busy). + await _drain() assert scanner.active_window_calls == [] - # The callback's due time was deferred past the busy window. - assert callbacks[bleak_callback] >= busy_end + assert entries[request] >= busy_end finally: cancel() register_cancel() @@ -526,43 +360,24 @@ def _cb(_device: Any, _adv: Any) -> None: ... @pytest.mark.asyncio async def test_failed_request_clears_busy_marker() -> None: - """A False return from async_request_active_window frees the scanner immediately.""" + """A False return from async_request_active_window frees the scanner.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=60.0, - scan_duration=3.0, + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + scan_interval=60.0, address=address, scan_duration=3.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) scanner._return_value = False register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "x") - adv = generate_advertisement_data(local_name="x") - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - callbacks = sched._needs["11:22:33:44:55:66"] - for cb in list(callbacks): - callbacks[cb] = loop.time() - 1.0 + _inject(scanner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 sched._async_tick() - await _drain(loop) - # Scanner was asked; it returned False; busy marker was cleared. + await _drain() assert scanner.active_window_calls == [3.0] assert scanner.source not in sched._scanner_windows finally: @@ -576,7 +391,6 @@ async def test_stop_cancels_pending_window_tasks() -> None: manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - blocking = asyncio.Event() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) scanner._block_event = blocking @@ -586,103 +400,60 @@ async def test_stop_cancels_pending_window_tasks() -> None: loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 ) sched._async_tick() - await _drain(loop) + await _drain() assert len(sched._pending_tasks) == 1 pending = next(iter(sched._pending_tasks)) sched.stop() - # Yield so the cancelled task can settle. await asyncio.sleep(0) assert pending.cancelled() or pending.done() assert sched._pending_tasks == set() assert sched._scanner_windows == {} assert sched._sweep_in_flight is None finally: - # Let the blocked task exit cleanly even after cancellation so the - # event loop has no dangling waiter at fixture teardown. blocking.set() register_cancel() @pytest.mark.asyncio -async def test_uuid_filter_excludes_non_matching_callback() -> None: - """A callback whose UUID filter does not intersect the adv is skipped.""" +async def test_dispatch_drops_tracking_for_unseen_address() -> None: + """A due address with no history entry is pruned, not retried.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {"UUIDs": {"0000abcd-0000-1000-8000-00805f9b34fb"}}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=60.0, - scan_duration=3.0, + cancel = manager.async_register_active_scan( + scan_interval=60.0, address="aa:bb:cc:dd:ee:ff" ) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) try: - device = generate_ble_device("11:22:33:44:55:66", "x") - # Service UUIDs that don't intersect the callback filter. - adv = generate_advertisement_data( - local_name="x", - service_uuids=["0000eeee-0000-1000-8000-00805f9b34fb"], - ) - scanner._async_on_advertisement( - device.address, - adv.rssi, - device.name or "", - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - adv.tx_power, - {}, - loop.time(), - ) - # Callback did not match; no tracking entry was created. - assert sched._needs == {} + request = next(iter(sched._by_address["aa:bb:cc:dd:ee:ff"])) + sched._needs["aa:bb:cc:dd:ee:ff"] = {request: loop.time() - 1.0} + sched._async_tick() + assert "aa:bb:cc:dd:ee:ff" not in sched._needs finally: cancel() - register_cancel() @pytest.mark.asyncio -async def test_remove_scanner_clears_sweep_in_flight() -> None: - """Unregistering a scanner mid-sweep resets _sweep_in_flight.""" +async def test_remove_scanner_clears_sweep_state() -> None: + """Unregistering a scanner drops its sweep / window state.""" manager = get_manager() sched = manager._auto_scheduler - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) cancel = manager.async_register_scanner(scanner) - sched._sweep_in_flight = scanner.source + assert "AA:BB:CC:DD:EE:00" in sched._sweep_last_completed cancel() - assert sched._sweep_in_flight is None + assert "AA:BB:CC:DD:EE:00" not in sched._sweep_last_completed @pytest.mark.asyncio -async def test_dispatch_drops_tracking_for_unseen_address() -> None: - """A due address with no history entry is pruned, not retried.""" +async def test_remove_scanner_clears_sweep_in_flight() -> None: + """Unregistering a scanner mid-sweep resets _sweep_in_flight.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - - def _cb(_device: Any, _adv: Any) -> None: ... - - cancel = manager.async_register_bleak_callback( - _cb, - {}, - scanning_mode=BluetoothScanningMode.AUTO, - scan_interval=60.0, - ) - try: - # Inject a tracking entry directly so we can exercise the - # "history missing" branch without staging a real advertisement. - bleak_callback = next(iter(sched._interval_callbacks)) - sched._needs["aa:bb:cc:dd:ee:ff"] = {bleak_callback: loop.time() - 1.0} - sched._async_tick() - assert "aa:bb:cc:dd:ee:ff" not in sched._needs - finally: - cancel() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + cancel = manager.async_register_scanner(scanner) + sched._sweep_in_flight = scanner.source + cancel() + assert sched._sweep_in_flight is None @pytest.mark.asyncio @@ -691,15 +462,12 @@ async def test_add_scanner_before_start_stores_placeholder() -> None: manager = get_manager() sched = manager._auto_scheduler loop = sched._loop - # Detach the running loop so add_scanner takes the pre-start branch. assert loop is not None sched._loop = None try: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) sched.add_scanner(scanner) assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] == 0.0 - # start() overwrites the 0.0 placeholder so the first sweep is one - # full interval out instead of immediate. manager._sources[scanner.source] = scanner sched.start(loop) assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] > 0.0 @@ -719,3 +487,79 @@ async def test_stop_is_safe_when_already_idle() -> None: assert sched._pending_tasks == set() assert sched._scanner_windows == {} assert sched._sweep_in_flight is None + + +@pytest.mark.asyncio +async def test_duration_clamped_to_bounds() -> None: + """_coalesce_duration clamps the requested duration to the configured range.""" + sched = get_manager()._auto_scheduler + + def _req(duration: float | None) -> ActiveScanRequest: + return ActiveScanRequest("AA", None, 60.0, duration) + + assert sched._coalesce_duration([_req(0.01)]) == AUTO_WINDOW_MIN_DURATION + assert sched._coalesce_duration([_req(1000.0)]) == AUTO_WINDOW_MAX_DURATION + assert sched._coalesce_duration([_req(7.5)]) == 7.5 + assert sched._coalesce_duration([_req(0.01), _req(7.5)]) == 7.5 + assert ( + sched._coalesce_duration([_req(7.5), _req(1000.0)]) == AUTO_WINDOW_MAX_DURATION + ) + assert sched._coalesce_duration([_req(None)]) == AUTO_WINDOW_MIN_DURATION + + +@pytest.mark.asyncio +async def test_multiple_requests_same_address_track_independent_intervals() -> None: + """Two registrations for the same address fire on their own cadences.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel_fast = manager.async_register_active_scan( + scan_interval=60.0, address=address, scan_duration=2.0 + ) + cancel_slow = manager.async_register_active_scan( + scan_interval=300.0, address=address, scan_duration=4.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + assert len(entries) == 2 + fast, slow = sorted(entries, key=lambda r: r.scan_interval) + # Only the fast request is due; slow stays pending. Window uses the + # fast request's duration alone. + entries[fast] = loop.time() - 1.0 + entries[slow] = loop.time() + 200.0 + sched._async_tick() + await _drain() + assert scanner.active_window_calls == [2.0] + assert entries[fast] > loop.time() + assert entries[slow] > loop.time() + 100 # slow not advanced + # Now make both due; window coalesces to max duration. + sched._scanner_windows.clear() # simulate prior window expired + entries[fast] = loop.time() - 1.0 + entries[slow] = loop.time() - 1.0 + sched._async_tick() + await _drain() + assert scanner.active_window_calls == [2.0, 4.0] + finally: + cancel_fast() + cancel_slow() + register_cancel() + + +@pytest.mark.asyncio +async def test_on_advertisement_early_returns_with_no_requests() -> None: + """Hot path is a no-op when no active-scan request is registered.""" + manager = get_manager() + sched = manager._auto_scheduler + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, "11:22:33:44:55:66") + assert sched._needs == {} + assert sched._by_address == {} + assert sched._by_service_uuid == {} + finally: + register_cancel() From 779726024378932590523e9c434e0114b508d9bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 22:50:44 -0500 Subject: [PATCH 06/75] refactor(auto): drop service_uuid, simplify to address-only API 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. --- src/habluetooth/auto_scheduler.pxd | 15 +-- src/habluetooth/auto_scheduler.py | 93 +++++---------- src/habluetooth/manager.py | 31 ++--- tests/test_auto_scheduler.py | 178 ++++++++++------------------- 4 files changed, 103 insertions(+), 214 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index e3f96e08..bd3d4cb8 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -6,8 +6,7 @@ from .models cimport BluetoothServiceInfoBleak cdef class ActiveScanRequest: - cdef public object address - cdef public object service_uuid + cdef public str address cdef public double scan_interval cdef public object scan_duration @@ -15,6 +14,7 @@ cdef class ActiveScanRequest: cdef class AutoScanScheduler: cdef public object _manager + cdef public dict _requests_by_address cdef public dict _needs cdef public dict _scanner_windows cdef public dict _sweep_last_completed @@ -23,12 +23,10 @@ cdef class AutoScanScheduler: cdef public object _loop cdef public bint _running cdef public set _pending_tasks - cdef public dict _by_address - cdef public dict _by_service_uuid - cpdef void add_matcher(self, ActiveScanRequest request) + cpdef void add_request(self, ActiveScanRequest request) - cpdef void remove_matcher(self, ActiveScanRequest request) + cpdef void remove_request(self, ActiveScanRequest request) cpdef void add_scanner(self, BaseHaScanner scanner) @@ -37,11 +35,8 @@ cdef class AutoScanScheduler: @cython.locals( address=str, existing=dict, - candidates=set, - by_addr=set, - by_uuid=set, + requests=set, request=ActiveScanRequest, - uuid=str, ) cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 9c0b143e..dafaa8c7 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -47,26 +47,23 @@ class ActiveScanRequest: """ - A registered need for on-demand active scans on matching devices. + A registered need for on-demand active scans on a specific address. - Created by ``BluetoothManager.async_register_active_scan``. Match is - by structured fields (``address``, ``service_uuid``) so the scheduler - can index lookups: an advertisement only iterates the requests that - match its own address or one of its service UUIDs, not the full set. - A request with multiple fields requires all of them to match. + Created by ``BluetoothManager.async_register_active_scan``. The scheduler + indexes requests by ``address`` so the on_advertisement hot path is an + O(1) dict lookup; nothing is iterated when the advertisement's address + has no registered request. """ - __slots__ = ("address", "scan_duration", "scan_interval", "service_uuid") + __slots__ = ("address", "scan_duration", "scan_interval") def __init__( self, - address: str | None, - service_uuid: str | None, + address: str, scan_interval: float, scan_duration: float | None, ) -> None: self.address = address - self.service_uuid = service_uuid self.scan_interval = scan_interval self.scan_duration = scan_duration @@ -75,12 +72,11 @@ class AutoScanScheduler: """Schedules on-demand active windows across AUTO-mode scanners.""" __slots__ = ( - "_by_address", - "_by_service_uuid", "_loop", "_manager", "_needs", "_pending_tasks", + "_requests_by_address", "_running", "_scanner_windows", "_sweep_in_flight", @@ -91,6 +87,8 @@ class AutoScanScheduler: def __init__(self, manager: BluetoothManager) -> None: """Initialize the scheduler bound to a manager.""" self._manager = manager + # address -> registered requests for that address + self._requests_by_address: dict[str, set[ActiveScanRequest]] = {} # address -> {request: next_due_loop_time} self._needs: dict[str, dict[ActiveScanRequest, float]] = {} # source -> loop time when the current window ends (0.0 = idle) @@ -103,11 +101,6 @@ def __init__(self, manager: BluetoothManager) -> None: self._loop: asyncio.AbstractEventLoop | None = None self._running = False self._pending_tasks: set[asyncio.Task[None]] = set() - # Indexed lookup of active-scan requests. Hot-path on_advertisement - # only iterates the requests whose declared address or service_uuid - # matches the advertisement, instead of every registered request. - self._by_address: dict[str, set[ActiveScanRequest]] = {} - self._by_service_uuid: dict[str, set[ActiveScanRequest]] = {} def start(self, loop: asyncio.AbstractEventLoop) -> None: """Bind the scheduler to the event loop and schedule the first tick.""" @@ -157,65 +150,33 @@ def remove_scanner(self, scanner: BaseHaScanner) -> None: self._sweep_in_flight = None self._reschedule() - def add_matcher(self, request: ActiveScanRequest) -> None: - """Register an active-scan request, indexing it by its fields.""" - if request.address is not None: - self._by_address.setdefault(request.address, set()).add(request) - if request.service_uuid is not None: - self._by_service_uuid.setdefault(request.service_uuid, set()).add(request) - - def remove_matcher(self, request: ActiveScanRequest) -> None: - """Drop the request from indexes and from any per-address tracking.""" - if request.address is not None and ( - bucket := self._by_address.get(request.address) - ): - bucket.discard(request) - if not bucket: - del self._by_address[request.address] - if request.service_uuid is not None and ( - bucket := self._by_service_uuid.get(request.service_uuid) - ): + def add_request(self, request: ActiveScanRequest) -> None: + """Register an active-scan request for its address.""" + self._requests_by_address.setdefault(request.address, set()).add(request) + + def remove_request(self, request: ActiveScanRequest) -> None: + """Drop the request from the index and from any pending tracking.""" + if (bucket := self._requests_by_address.get(request.address)) is not None: bucket.discard(request) if not bucket: - del self._by_service_uuid[request.service_uuid] - empty_addresses: list[str] = [] - for address, entries in self._needs.items(): - if request in entries: - del entries[request] - if not entries: - empty_addresses.append(address) - for address in empty_addresses: - del self._needs[address] + del self._requests_by_address[request.address] + if (entries := self._needs.get(request.address)) is not None: + entries.pop(request, None) + if not entries: + del self._needs[request.address] self._reschedule() def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: - """Hot path. Record a tracking entry for any matched request.""" + """Hot path. Track requests for the advertisement's address.""" # Early return when nothing is registered. Common case, cheap. - if (not self._by_address and not self._by_service_uuid) or self._loop is None: + if not self._requests_by_address or self._loop is None: return address = service_info.address - candidates: set[ActiveScanRequest] | None = None - if (by_addr := self._by_address.get(address)) is not None: - candidates = by_addr.copy() - for uuid in service_info.service_uuids: - if (by_uuid := self._by_service_uuid.get(uuid)) is not None: - if candidates is None: - candidates = by_uuid.copy() - else: - candidates.update(by_uuid) - if not candidates: + requests = self._requests_by_address.get(address) + if requests is None: return existing = self._needs.get(address) - for request in candidates: - # Verify all declared fields match; a request indexed under - # address may still require service_uuid (or vice versa). - if request.address is not None and request.address != address: - continue - if ( - request.service_uuid is not None - and request.service_uuid not in service_info.service_uuids - ): - continue + for request in requests: if existing is None: existing = self._needs[address] = {} if request not in existing: diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index a5c06650..33955abc 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1053,33 +1053,22 @@ def async_register_bleak_callback( def async_register_active_scan( self, + address: str, scan_interval: float, - *, - address: str | None = None, - service_uuid: str | None = None, scan_duration: float | None = None, ) -> CALLBACK_TYPE: """ - Declare an on-demand active-scan need for matching advertisements. - - At least one of ``address`` or ``service_uuid`` must be provided; - if both are given the device must match both. The scheduler picks - the AUTO-mode scanner currently in range of the matched address - and asks it to flip into active for ``scan_duration`` seconds, - repeating every ``scan_interval`` seconds while the device is - being seen. ACTIVE and PASSIVE scanners ignore the request. + Declare an on-demand active-scan need for a specific address. - Returns a cancel callable that removes the registration and any - per-(address, request) tracking it accumulated. + The scheduler asks the AUTO-mode scanner currently in range of + ``address`` to flip active for ``scan_duration`` seconds every + ``scan_interval`` seconds while the device is being seen. + ACTIVE and PASSIVE scanners ignore the request. Returns a + cancel callable. """ - if address is None and service_uuid is None: - raise ValueError( - "async_register_active_scan requires at least one of " - "address or service_uuid to be specified" - ) - request = ActiveScanRequest(address, service_uuid, scan_interval, scan_duration) - self._auto_scheduler.add_matcher(request) - return partial(self._auto_scheduler.remove_matcher, request) + request = ActiveScanRequest(address, scan_interval, scan_duration) + self._auto_scheduler.add_request(request) + return partial(self._auto_scheduler.remove_request, request) def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 8b7704f3..d2b974ed 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -68,19 +68,9 @@ def discovered_addresses(self) -> Iterable[str]: return () -SERVICE_UUID = "0000fe07-0000-1000-8000-00805f9b34fb" - - -def _inject( - scanner: _RecordingAutoScanner, - address: str, - service_uuids: list[str] | None = None, -) -> None: +def _inject(scanner: _RecordingAutoScanner, address: str) -> None: """Drive a fake advertisement through the scanner's normal path.""" - adv = generate_advertisement_data( - local_name="x", - service_uuids=service_uuids or [], - ) + adv = generate_advertisement_data(local_name="x") device = generate_ble_device(address, "x") scanner._async_on_advertisement( device.address, @@ -100,20 +90,12 @@ async def _drain() -> None: @pytest.mark.asyncio -async def test_register_requires_address_or_service_uuid() -> None: - """async_register_active_scan rejects empty registrations.""" - manager = get_manager() - with pytest.raises(ValueError, match="address or service_uuid"): - manager.async_register_active_scan(scan_interval=60.0) - - -@pytest.mark.asyncio -async def test_advertisement_by_address_starts_tracking() -> None: +async def test_advertisement_starts_tracking() -> None: """A matching address advertisement creates a per-(address, request) entry.""" manager = get_manager() sched = manager._auto_scheduler cancel = manager.async_register_active_scan( - scan_interval=120.0, address="11:22:33:44:55:66", scan_duration=3.0 + "11:22:33:44:55:66", scan_interval=120.0, scan_duration=3.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) @@ -127,50 +109,18 @@ async def test_advertisement_by_address_starts_tracking() -> None: @pytest.mark.asyncio -async def test_advertisement_by_service_uuid_starts_tracking() -> None: - """A matching service UUID advertisement creates a tracking entry.""" - manager = get_manager() - sched = manager._auto_scheduler - cancel = manager.async_register_active_scan( - scan_interval=120.0, service_uuid=SERVICE_UUID, scan_duration=3.0 - ) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) - try: - _inject(scanner, "11:22:33:44:55:66", service_uuids=[SERVICE_UUID]) - assert "11:22:33:44:55:66" in sched._needs - finally: - cancel() - register_cancel() - - -@pytest.mark.asyncio -async def test_address_and_service_uuid_requires_both() -> None: - """A request with both fields skips an ad that only matches one.""" +async def test_advertisement_for_unrelated_address_is_ignored() -> None: + """An advertisement for an unregistered address creates no tracking.""" manager = get_manager() sched = manager._auto_scheduler cancel = manager.async_register_active_scan( - scan_interval=60.0, - address="11:22:33:44:55:66", - service_uuid=SERVICE_UUID, - scan_duration=3.0, + "11:22:33:44:55:66", scan_interval=120.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - # Address matches, service_uuid does not. - _inject( - scanner, - "11:22:33:44:55:66", - service_uuids=["0000eeee-0000-1000-8000-00805f9b34fb"], - ) - assert sched._needs == {} - # Service uuid matches, address does not. - _inject(scanner, "AA:AA:AA:AA:AA:AA", service_uuids=[SERVICE_UUID]) + _inject(scanner, "AA:AA:AA:AA:AA:AA") assert sched._needs == {} - # Both match. - _inject(scanner, "11:22:33:44:55:66", service_uuids=[SERVICE_UUID]) - assert "11:22:33:44:55:66" in sched._needs finally: cancel() register_cancel() @@ -183,7 +133,7 @@ async def test_tick_requests_active_window_on_auto_scanner() -> None: sched = manager._auto_scheduler loop = asyncio.get_running_loop() cancel = manager.async_register_active_scan( - scan_interval=120.0, address="11:22:33:44:55:66", scan_duration=5.0 + "11:22:33:44:55:66", scan_interval=120.0, scan_duration=5.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) @@ -203,16 +153,16 @@ async def test_tick_requests_active_window_on_auto_scanner() -> None: @pytest.mark.asyncio async def test_tick_coalesces_overlapping_requests() -> None: - """Two requests for the same address coalesce into one window using max duration.""" + """Two requests for the same address coalesce into one max-duration window.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel1 = manager.async_register_active_scan( - scan_interval=120.0, address=address, scan_duration=3.0 + address, scan_interval=120.0, scan_duration=3.0 ) cancel2 = manager.async_register_active_scan( - scan_interval=120.0, address=address, scan_duration=10.0 + address, scan_interval=120.0, scan_duration=10.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) @@ -230,6 +180,45 @@ async def test_tick_coalesces_overlapping_requests() -> None: register_cancel() +@pytest.mark.asyncio +async def test_multiple_requests_same_address_track_independent_intervals() -> None: + """Two registrations for the same address fire on their own cadences.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel_fast = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=2.0 + ) + cancel_slow = manager.async_register_active_scan( + address, scan_interval=300.0, scan_duration=4.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + assert len(entries) == 2 + fast, slow = sorted(entries, key=lambda r: r.scan_interval) + entries[fast] = loop.time() - 1.0 + entries[slow] = loop.time() + 200.0 + sched._async_tick() + await _drain() + assert scanner.active_window_calls == [2.0] + assert entries[fast] > loop.time() + assert entries[slow] > loop.time() + 100 + sched._scanner_windows.clear() + entries[fast] = loop.time() - 1.0 + entries[slow] = loop.time() - 1.0 + sched._async_tick() + await _drain() + assert scanner.active_window_calls == [2.0, 4.0] + finally: + cancel_fast() + cancel_slow() + register_cancel() + + @pytest.mark.asyncio async def test_tick_skips_non_auto_scanner() -> None: """ACTIVE / PASSIVE scanners are not asked to flip; due times advance.""" @@ -238,7 +227,7 @@ async def test_tick_skips_non_auto_scanner() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( - scan_interval=120.0, address=address, scan_duration=3.0 + address, scan_interval=120.0, scan_duration=3.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.ACTIVE) register_cancel = manager.async_register_scanner(scanner) @@ -312,12 +301,12 @@ async def test_global_sweep_one_scanner_at_a_time() -> None: @pytest.mark.asyncio -async def test_remove_matcher_clears_tracking() -> None: +async def test_remove_request_clears_tracking() -> None: """Cancelling a registration removes its per-(address, request) entries.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" - cancel = manager.async_register_active_scan(scan_interval=60.0, address=address) + cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: @@ -325,7 +314,7 @@ async def test_remove_matcher_clears_tracking() -> None: assert address in sched._needs cancel() assert address not in sched._needs - assert sched._by_address == {} + assert sched._requests_by_address == {} finally: register_cancel() @@ -338,7 +327,7 @@ async def test_busy_scanner_defers_due_callbacks_not_busy_loops() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( - scan_interval=60.0, address=address, scan_duration=3.0 + address, scan_interval=60.0, scan_duration=3.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) @@ -366,7 +355,7 @@ async def test_failed_request_clears_busy_marker() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( - scan_interval=60.0, address=address, scan_duration=3.0 + address, scan_interval=60.0, scan_duration=3.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) scanner._return_value = False @@ -420,11 +409,9 @@ async def test_dispatch_drops_tracking_for_unseen_address() -> None: manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - cancel = manager.async_register_active_scan( - scan_interval=60.0, address="aa:bb:cc:dd:ee:ff" - ) + cancel = manager.async_register_active_scan("aa:bb:cc:dd:ee:ff", scan_interval=60.0) try: - request = next(iter(sched._by_address["aa:bb:cc:dd:ee:ff"])) + request = next(iter(sched._requests_by_address["aa:bb:cc:dd:ee:ff"])) sched._needs["aa:bb:cc:dd:ee:ff"] = {request: loop.time() - 1.0} sched._async_tick() assert "aa:bb:cc:dd:ee:ff" not in sched._needs @@ -495,7 +482,7 @@ async def test_duration_clamped_to_bounds() -> None: sched = get_manager()._auto_scheduler def _req(duration: float | None) -> ActiveScanRequest: - return ActiveScanRequest("AA", None, 60.0, duration) + return ActiveScanRequest("AA", 60.0, duration) assert sched._coalesce_duration([_req(0.01)]) == AUTO_WINDOW_MIN_DURATION assert sched._coalesce_duration([_req(1000.0)]) == AUTO_WINDOW_MAX_DURATION @@ -507,48 +494,6 @@ def _req(duration: float | None) -> ActiveScanRequest: assert sched._coalesce_duration([_req(None)]) == AUTO_WINDOW_MIN_DURATION -@pytest.mark.asyncio -async def test_multiple_requests_same_address_track_independent_intervals() -> None: - """Two registrations for the same address fire on their own cadences.""" - manager = get_manager() - sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - address = "11:22:33:44:55:66" - cancel_fast = manager.async_register_active_scan( - scan_interval=60.0, address=address, scan_duration=2.0 - ) - cancel_slow = manager.async_register_active_scan( - scan_interval=300.0, address=address, scan_duration=4.0 - ) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) - try: - _inject(scanner, address) - entries = sched._needs[address] - assert len(entries) == 2 - fast, slow = sorted(entries, key=lambda r: r.scan_interval) - # Only the fast request is due; slow stays pending. Window uses the - # fast request's duration alone. - entries[fast] = loop.time() - 1.0 - entries[slow] = loop.time() + 200.0 - sched._async_tick() - await _drain() - assert scanner.active_window_calls == [2.0] - assert entries[fast] > loop.time() - assert entries[slow] > loop.time() + 100 # slow not advanced - # Now make both due; window coalesces to max duration. - sched._scanner_windows.clear() # simulate prior window expired - entries[fast] = loop.time() - 1.0 - entries[slow] = loop.time() - 1.0 - sched._async_tick() - await _drain() - assert scanner.active_window_calls == [2.0, 4.0] - finally: - cancel_fast() - cancel_slow() - register_cancel() - - @pytest.mark.asyncio async def test_on_advertisement_early_returns_with_no_requests() -> None: """Hot path is a no-op when no active-scan request is registered.""" @@ -559,7 +504,6 @@ async def test_on_advertisement_early_returns_with_no_requests() -> None: try: _inject(scanner, "11:22:33:44:55:66") assert sched._needs == {} - assert sched._by_address == {} - assert sched._by_service_uuid == {} + assert sched._requests_by_address == {} finally: register_cancel() From d54f0b495a7df8f9849ac42fbbf56a18e1d7ebf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 07:23:21 -0500 Subject: [PATCH 07/75] fix(auto): detect ACTIVE fallback to PASSIVE in async_request_active_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. --- src/habluetooth/auto_scheduler.py | 12 ++++++------ src/habluetooth/scanner.py | 7 +++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index dafaa8c7..86fde8d1 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -5,12 +5,12 @@ scanners: * Per-device windows. Callers (Home Assistant's bluetooth integration is - the primary one) register an ``ActiveScanRequest`` matching an address - and / or service UUID with a ``scan_interval`` and ``scan_duration``. - When a matching advertisement arrives the scheduler asks the scanner - currently seeing the device to flip active for the requested duration, - repeating on the requested cadence. Multiple matching requests for the - same address coalesce into one window using the max of their durations. + the primary one) register an ``ActiveScanRequest`` for a specific + address with a ``scan_interval`` and ``scan_duration``. When a matching + advertisement arrives the scheduler asks the scanner currently seeing + the device to flip active for the requested duration, repeating on the + requested cadence. Multiple matching requests for the same address + coalesce into one window using the max of their durations. * Global rediscovery sweeps. Every ``AUTO_REDISCOVERY_INTERVAL`` seconds each AUTO-mode scanner gets a ``AUTO_REDISCOVERY_SWEEP_DURATION`` diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index b16338de..67d5c710 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -683,6 +683,13 @@ async def async_request_active_window(self, duration: float) -> bool: except ScannerStartError: self._scan_mode_override = None return False + if self.current_mode is not BluetoothScanningMode.ACTIVE: + # _async_start_attempt silently falls back to PASSIVE on Linux + # when ACTIVE fails on the final retry. Treat that as a failed + # window: the scanner is back up but not actually active, so + # the scheduler must not believe the window engaged. + self._scan_mode_override = None + return False self._active_window_end = new_end self._active_window_handle = self._loop.call_later( duration, self._schedule_end_active_window From 4d3f3ba02748509d9f98c4ed6b3d947bd5ae112f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 07:26:33 -0500 Subject: [PATCH 08/75] fix(auto): back off failed sweeps and hoist reschedule out of hot loop 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. --- src/habluetooth/auto_scheduler.pxd | 1 + src/habluetooth/auto_scheduler.py | 15 +++++++++++++-- tests/test_auto_scheduler.py | 25 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index bd3d4cb8..82bc86d7 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -37,6 +37,7 @@ cdef class AutoScanScheduler: existing=dict, requests=set, request=ActiveScanRequest, + added=bint, ) cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 86fde8d1..34f43f4a 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -176,12 +176,18 @@ def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: if requests is None: return existing = self._needs.get(address) + added = False for request in requests: if existing is None: existing = self._needs[address] = {} if request not in existing: existing[request] = self._loop.time() + request.scan_interval - self._reschedule() + added = True + if added: + # Reschedule once after the whole batch instead of per entry; on + # the hot path multiple registrations for the same address would + # otherwise cancel and re-arm the tick timer N times. + self._reschedule() def _reschedule(self) -> None: """Schedule the next tick based on the earliest pending due time.""" @@ -344,7 +350,12 @@ async def _run_window( if not ok and self._scanner_windows.get(scanner.source) is not None: del self._scanner_windows[scanner.source] if sweep_source is not None: - if ok and self._loop is not None: + # Update _sweep_last_completed even on failure so the next + # sweep is a full interval out instead of immediately + # re-eligible; otherwise _next_event_time would stay in + # the past and the tick would re-fire every 50ms, hammering + # the scanner with stop/start cycles. + if self._loop is not None: self._sweep_last_completed[sweep_source] = self._loop.time() if self._sweep_in_flight == sweep_source: self._sweep_in_flight = None diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index d2b974ed..bb266b70 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -374,6 +374,31 @@ async def test_failed_request_clears_busy_marker() -> None: register_cancel() +@pytest.mark.asyncio +async def test_failed_sweep_advances_sweep_last_completed() -> None: + """A False return on a sweep updates _sweep_last_completed so we don't busy-loop.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + scanner._return_value = False + register_cancel = manager.async_register_scanner(scanner) + try: + sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( + loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + ) + before = sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] + sched._async_tick() + await _drain() + assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] + # _sweep_last_completed was advanced even though the window failed, + # so the next sweep is one full interval out instead of immediate. + assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] > before + assert sched._sweep_in_flight is None + finally: + register_cancel() + + @pytest.mark.asyncio async def test_stop_cancels_pending_window_tasks() -> None: """Scheduler.stop cancels in-flight active-window tasks.""" From 056e2d12c7c07128bfddce32c700ea33d3081c83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 07:33:38 -0500 Subject: [PATCH 09/75] refactor(auto): tighten cold paths and close active-window state races 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. --- src/habluetooth/auto_scheduler.py | 48 +++++-------- src/habluetooth/scanner.py | 114 ++++++++++++++++-------------- 2 files changed, 79 insertions(+), 83 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 34f43f4a..86295af0 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -112,10 +112,9 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: # before async_setup would have last_sweep=0.0 and trigger an # immediate sweep on the first tick. now = loop.time() - for source in self._manager._sources: - scanner = self._manager._sources[source] + for scanner in self._manager._sources.values(): if scanner.requested_mode is BluetoothScanningMode.AUTO: - self._sweep_last_completed[source] = now + self._sweep_last_completed[scanner.source] = now self._reschedule() def stop(self) -> None: @@ -232,10 +231,10 @@ def _async_tick(self) -> None: self._reschedule() def _dispatch_per_device(self, now: float) -> None: - """Fire windows for any (address, callback) whose due time has passed.""" - for address, callbacks in list(self._needs.items()): - due_callbacks = [cb for cb, due in callbacks.items() if due <= now] - if not due_callbacks: + """Fire windows for any (address, request) whose due time has passed.""" + for address, entries in list(self._needs.items()): + due = [r for r, t in entries.items() if t <= now] + if not due: continue history = self._manager._all_history.get(address) if history is None: @@ -245,33 +244,24 @@ def _dispatch_per_device(self, now: float) -> None: continue source = history.source if (busy_end := self._scanner_windows.get(source)) is not None: - # Scanner busy. Defer all due callbacks to just after the - # window ends; without this the next event time stays in - # the past and the tick re-fires every 50ms until the + # Scanner busy. Defer due entries past the window end so + # _next_event_time doesn't stay in the past, which would + # otherwise busy-loop the tick every 50ms until the # window drains. - deferred_due = busy_end + 0.05 - for cb in due_callbacks: - if callbacks[cb] < deferred_due: - callbacks[cb] = deferred_due + deferred = busy_end + 0.05 + for request in due: + if entries[request] < deferred: + entries[request] = deferred continue scanner = self._manager._sources.get(source) - if scanner is None or scanner.requested_mode is not ( + if scanner is not None and scanner.requested_mode is ( BluetoothScanningMode.AUTO ): - # Not an AUTO scanner: it's already fixed-mode, so don't - # bother requesting. Advance the next-due times so we - # don't busy-loop on the same advertisement. - for cb in due_callbacks: - interval = cb.scan_interval - if interval is not None: - callbacks[cb] = now + interval - continue - duration = self._coalesce_duration(due_callbacks) - self._request_window(scanner, duration) - for cb in due_callbacks: - interval = cb.scan_interval - if interval is not None: - callbacks[cb] = now + interval + self._request_window(scanner, self._coalesce_duration(due)) + # Whether we fired or skipped (non-AUTO scanner), advance each + # due entry to its next cadence so we don't re-fire next tick. + for request in due: + entries[request] = now + request.scan_interval def _dispatch_global_sweep(self, now: float) -> None: """Run a rediscovery sweep on the next eligible scanner, if any.""" diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 67d5c710..4e52649d 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -637,20 +637,33 @@ async def _async_reset_adapter(self, gone_silent: bool) -> None: async def async_stop(self) -> None: """Stop bluetooth scanner.""" - if self._active_window_handle is not None: - self._active_window_handle.cancel() - self._active_window_handle = None - # Clear any in-flight AUTO active-window state. Without this a later - # async_start would still see _scan_mode_override == ACTIVE and the - # scanner would come back in continuous active mode. - self._scan_mode_override = None - self._active_window_end = 0.0 if self._start_future is not None and not self._start_future.done(): self._start_future.set_exception(_AbortStartError()) + # All state mutation runs inside _start_stop_lock so an in-flight + # async_request_active_window or _async_end_active_window can't + # set the handle / override after we've cleared them. async with self._start_stop_lock: + self._clear_active_window_state() self._async_stop_scanner_watchdog() await self._async_stop_scanner() + def _clear_active_window_state(self) -> None: + """Reset all AUTO active-window bookkeeping (must hold start/stop lock).""" + if self._active_window_handle is not None: + self._active_window_handle.cancel() + self._active_window_handle = None + self._scan_mode_override = None + self._active_window_end = 0.0 + + def _arm_active_window_timer(self, duration: float, new_end: float) -> None: + """Schedule the end-of-window callback and record the end time.""" + if TYPE_CHECKING: + assert self._loop is not None + self._active_window_end = new_end + self._active_window_handle = self._loop.call_later( + duration, self._schedule_end_active_window + ) + async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds then restore prior mode. @@ -672,28 +685,27 @@ async def async_request_active_window(self, duration: float) -> bool: # reaches further than the existing end. if new_end > self._active_window_end: self._active_window_handle.cancel() - self._active_window_end = new_end - self._active_window_handle = self._loop.call_later( - duration, self._schedule_end_active_window - ) + self._arm_active_window_timer(duration, new_end) return True - self._scan_mode_override = BluetoothScanningMode.ACTIVE - try: - await self._async_swap_scanner_for_window() - except ScannerStartError: - self._scan_mode_override = None - return False - if self.current_mode is not BluetoothScanningMode.ACTIVE: - # _async_start_attempt silently falls back to PASSIVE on Linux - # when ACTIVE fails on the final retry. Treat that as a failed - # window: the scanner is back up but not actually active, so - # the scheduler must not believe the window engaged. - self._scan_mode_override = None - return False - self._active_window_end = new_end - self._active_window_handle = self._loop.call_later( - duration, self._schedule_end_active_window - ) + # All state mutation runs inside _start_stop_lock so we don't race + # with _async_end_active_window (which may have been scheduled by + # the timer firing concurrently) or async_stop. + async with self._start_stop_lock: + self._scan_mode_override = BluetoothScanningMode.ACTIVE + try: + await self._async_stop_scanner() + await self._async_start() + except ScannerStartError: + self._scan_mode_override = None + return False + if self.current_mode is not BluetoothScanningMode.ACTIVE: + # _async_start_attempt silently falls back to PASSIVE on + # Linux when ACTIVE fails on the final retry. Treat that + # as a failed window: the scanner is back up but not in + # ACTIVE, so the scheduler must not believe it engaged. + self._scan_mode_override = None + return False + self._arm_active_window_timer(duration, new_end) return True def _schedule_end_active_window(self) -> None: @@ -703,32 +715,26 @@ def _schedule_end_active_window(self) -> None: async def _async_end_active_window(self) -> None: """Restore the scanner to its underlying mode after an active window.""" - self._scan_mode_override = None - if not self.scanning: - # Scanner was stopped while the window was active; nothing to do. - return - try: - await self._async_swap_scanner_for_window() - except ScannerStartError as ex: - _LOGGER.warning( - "%s: Failed to restart scanner after active window: %s", - self.name, - ex, - ) - - async def _async_swap_scanner_for_window(self) -> None: - """ - Stop and restart the BleakScanner so a new mode takes effect. - - This is the simple stop+start used by AUTO-mode active windows. - It differs from the watchdog's ``_async_restart_scanner`` in that - it never resets the underlying adapter — switching mode is - cheap, but adapter reset is heavy and only appropriate when the - scanner has gone silent. - """ async with self._start_stop_lock: - await self._async_stop_scanner() - await self._async_start() + if self._active_window_handle is not None: + # A new active window was started while this end-window + # task was queued; defer to it. The new window owns the + # override and the timer; clearing them here would race + # the new window into restarting in passive. + return + self._scan_mode_override = None + if not self.scanning: + # Scanner was stopped while the window was active. + return + try: + await self._async_stop_scanner() + await self._async_start() + except ScannerStartError as ex: + _LOGGER.warning( + "%s: Failed to restart scanner after active window: %s", + self.name, + ex, + ) async def _async_stop_scanner(self) -> None: """Stop bluetooth discovery under the lock.""" From b4e25aa7481c06a5836c83597a74d38c26176e4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:00:04 -0500 Subject: [PATCH 10/75] refactor(auto): 12h sweep interval, 15s duration, 10min initial delay 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. --- src/habluetooth/auto_scheduler.py | 35 ++++++++++++++++++++----------- src/habluetooth/const.py | 8 +++++-- tests/test_auto_scheduler.py | 32 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 86295af0..75eb9c41 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -12,10 +12,11 @@ requested cadence. Multiple matching requests for the same address coalesce into one window using the max of their durations. -* Global rediscovery sweeps. Every ``AUTO_REDISCOVERY_INTERVAL`` seconds - each AUTO-mode scanner gets a ``AUTO_REDISCOVERY_SWEEP_DURATION`` - active window. Sweeps are staggered across scanners so that at most one - scanner is mid-sweep at a time, keeping the radio coverage gap bounded. +* Global rediscovery sweeps. ``AUTO_INITIAL_SWEEP_DELAY`` after a scanner + joins, and every ``AUTO_REDISCOVERY_INTERVAL`` thereafter, each AUTO-mode + scanner gets a ``AUTO_REDISCOVERY_SWEEP_DURATION`` active window. Sweeps + are staggered across scanners so that at most one scanner is mid-sweep + at a time, keeping the radio coverage gap bounded. The scheduler is a single per-manager instance driven by one ``loop.call_at`` handle. ``on_advertisement`` is on the manager's hot @@ -29,6 +30,7 @@ from typing import TYPE_CHECKING from .const import ( + AUTO_INITIAL_SWEEP_DELAY, AUTO_REDISCOVERY_INTERVAL, AUTO_REDISCOVERY_SWEEP_DURATION, AUTO_WINDOW_MAX_DURATION, @@ -106,15 +108,18 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: """Bind the scheduler to the event loop and schedule the first tick.""" self._loop = loop self._running = True - # Initialize last-sweep so the first sweep is one interval out. - # Overwrite any placeholder timestamps left by pre-start add_scanner - # calls (which had no loop available); otherwise scanners registered - # before async_setup would have last_sweep=0.0 and trigger an - # immediate sweep on the first tick. - now = loop.time() + # Schedule the first sweep for each AUTO scanner AUTO_INITIAL_SWEEP_DELAY + # from now, so HA startup isn't crowded by ACTIVE scans on every + # adapter at once. We store a fake "last completed" in the past + # such that last + AUTO_REDISCOVERY_INTERVAL == now + initial_delay; + # this also overwrites any 0.0 placeholders left by pre-start + # add_scanner calls. + initial_last = ( + loop.time() + AUTO_INITIAL_SWEEP_DELAY - AUTO_REDISCOVERY_INTERVAL + ) for scanner in self._manager._sources.values(): if scanner.requested_mode is BluetoothScanningMode.AUTO: - self._sweep_last_completed[scanner.source] = now + self._sweep_last_completed[scanner.source] = initial_last self._reschedule() def stop(self) -> None: @@ -138,7 +143,13 @@ def add_scanner(self, scanner: BaseHaScanner) -> None: if self._loop is None: self._sweep_last_completed.setdefault(scanner.source, 0.0) return - self._sweep_last_completed.setdefault(scanner.source, self._loop.time()) + # First sweep AUTO_INITIAL_SWEEP_DELAY after this scanner joins, so + # a freshly connected proxy gets a chance to settle before its + # active sweep instead of firing immediately. + initial_last = ( + self._loop.time() + AUTO_INITIAL_SWEEP_DELAY - AUTO_REDISCOVERY_INTERVAL + ) + self._sweep_last_completed.setdefault(scanner.source, initial_last) self._reschedule() def remove_scanner(self, scanner: BaseHaScanner) -> None: diff --git a/src/habluetooth/const.py b/src/habluetooth/const.py index ce4a5f41..9661338c 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -57,8 +57,12 @@ # AUTO scanning mode: each scanner in AUTO mode receives a periodic # active "sweep" so new devices are still discovered. The manager # staggers sweeps across scanners so at most one is active at a time. -AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 4 # 4 hours per scanner -AUTO_REDISCOVERY_SWEEP_DURATION: Final = 30.0 # seconds per scanner per sweep +# Each scanner's first sweep fires AUTO_INITIAL_SWEEP_DELAY after it +# joins the manager so startup isn't crowded by ACTIVE scans on every +# adapter at once; subsequent sweeps follow AUTO_REDISCOVERY_INTERVAL. +AUTO_INITIAL_SWEEP_DELAY: Final = 60 * 10 # 10 minutes after registration +AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 12 # 12 hours per scanner +AUTO_REDISCOVERY_SWEEP_DURATION: Final = 15.0 # seconds per scanner per sweep # AUTO scanning mode: bounds on the per-callback `scan_duration` value. # Callers requesting an active window for a single device are clamped diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index bb266b70..03ebae4d 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -16,6 +16,7 @@ ) from habluetooth.auto_scheduler import ActiveScanRequest from habluetooth.const import ( + AUTO_INITIAL_SWEEP_DELAY, AUTO_REDISCOVERY_INTERVAL, AUTO_REDISCOVERY_SWEEP_DURATION, AUTO_WINDOW_MAX_DURATION, @@ -444,6 +445,37 @@ async def test_dispatch_drops_tracking_for_unseen_address() -> None: cancel() +@pytest.mark.asyncio +async def test_first_sweep_is_delayed_after_scanner_registers() -> None: + """A newly registered AUTO scanner's first sweep is AUTO_INITIAL_SWEEP_DELAY out.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + last = sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] + first_sweep_at = last + AUTO_REDISCOVERY_INTERVAL + now = loop.time() + # First sweep should land roughly AUTO_INITIAL_SWEEP_DELAY from now, + # not AUTO_REDISCOVERY_INTERVAL from now. + assert ( + AUTO_INITIAL_SWEEP_DELAY - 1.0 + <= first_sweep_at - now + <= AUTO_INITIAL_SWEEP_DELAY + 1.0 + ) + # Force the tick at the scheduled first sweep time and confirm + # the sweep would fire. + sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( + now - AUTO_REDISCOVERY_INTERVAL - 1.0 + ) + sched._async_tick() + await _drain() + assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] + finally: + register_cancel() + + @pytest.mark.asyncio async def test_remove_scanner_clears_sweep_state() -> None: """Unregistering a scanner drops its sweep / window state.""" From 68b65152b0198fc04e7de128ceb0a8a4ed0a8a57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:13:28 -0500 Subject: [PATCH 11/75] refactor(auto): one persistent worker task per scanner instead of per-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. --- src/habluetooth/auto_scheduler.pxd | 7 +- src/habluetooth/auto_scheduler.py | 425 ++++++++++++++--------------- tests/test_auto_scheduler.py | 268 +++++++----------- 3 files changed, 309 insertions(+), 391 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 82bc86d7..45b3b230 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -16,13 +16,10 @@ cdef class AutoScanScheduler: cdef public object _manager cdef public dict _requests_by_address cdef public dict _needs - cdef public dict _scanner_windows - cdef public dict _sweep_last_completed - cdef public object _sweep_in_flight - cdef public object _tick_handle + cdef public dict _workers + cdef public object _sweep_lock cdef public object _loop cdef public bint _running - cdef public set _pending_tasks cpdef void add_request(self, ActiveScanRequest request) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 75eb9c41..b2278633 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -18,14 +18,17 @@ are staggered across scanners so that at most one scanner is mid-sweep at a time, keeping the radio coverage gap bounded. -The scheduler is a single per-manager instance driven by one -``loop.call_at`` handle. ``on_advertisement`` is on the manager's hot -path; it must return cheaply when no active-scan request is registered. +Each AUTO scanner has one persistent ``_ScannerWorker`` task that sleeps +on an ``asyncio.Event`` with a ``wait_for`` timeout matching the next +scheduled event for that scanner. State mutations (new request, new +advertisement, scanner registration) just set the wake event; no task is +created per window dispatch. """ from __future__ import annotations import asyncio +import contextlib import logging from typing import TYPE_CHECKING @@ -70,20 +73,181 @@ def __init__( self.scan_duration = scan_duration +class _ScannerWorker: + """One persistent task per AUTO scanner; sleeps until next due event.""" + + __slots__ = ( + "_scanner", + "_scheduler", + "_sweep_last_completed", + "_task", + "_wake", + "_window_end", + ) + + def __init__(self, scheduler: AutoScanScheduler, scanner: BaseHaScanner) -> None: + self._scheduler = scheduler + self._scanner = scanner + self._wake = asyncio.Event() + self._task: asyncio.Task[None] | None = None + # When this scanner's current active window ends; 0.0 = idle. + self._window_end: float = 0.0 + # When this scanner last completed (or attempted) a global sweep. + self._sweep_last_completed: float = 0.0 + + def start(self, loop: asyncio.AbstractEventLoop) -> None: + """Start the worker task; first sweep AUTO_INITIAL_SWEEP_DELAY out.""" + self._sweep_last_completed = ( + loop.time() + AUTO_INITIAL_SWEEP_DELAY - AUTO_REDISCOVERY_INTERVAL + ) + self._task = loop.create_task(self._run()) + + def stop(self) -> None: + """Cancel the worker task; it will exit on its next CancelledError.""" + if self._task is not None and not self._task.done(): + self._task.cancel() + + def wake(self) -> None: + """Interrupt the worker's sleep so it re-evaluates pending work.""" + self._wake.set() + + def _next_event_at(self, now: float) -> float: + """Return the earliest loop-time at which this worker has work.""" + # If we have an active window in flight, next event is its end. + if self._window_end > now: + return self._window_end + # Sweep cadence. + next_at = self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + # Earliest per-device need owned by this scanner. A request's + # "owner" is whichever scanner currently sees its address. + source = self._scanner.source + needs = self._scheduler._needs + all_history = self._scheduler._manager._all_history + for address, entries in needs.items(): + if not entries: + continue + history = all_history.get(address) + if history is None or history.source != source: + continue + earliest = min(entries.values()) + if earliest < next_at: + next_at = earliest + return next_at + + async def _run(self) -> None: + """Main loop: sleep until next event or wake, then process due work.""" + try: + while True: + loop = self._scheduler._loop + if loop is None: + return + now = loop.time() + next_at = self._next_event_at(now) + self._wake.clear() + delay = max(0.0, next_at - now) + if delay > 0: + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._wake.wait(), timeout=delay) + if not self._scheduler._running: + return + await self._tick() + except asyncio.CancelledError: + raise + + async def _tick(self) -> None: + """Fire any due per-device windows for this scanner, then the sweep.""" + loop = self._scheduler._loop + if loop is None: + return + if self._window_end > loop.time(): + # Still inside the previous window; nothing to do yet. + return + self._window_end = 0.0 + await self._dispatch_per_device() + await self._dispatch_sweep() + + async def _dispatch_per_device(self) -> None: + """Process per-(address, request) needs that target this scanner.""" + loop = self._scheduler._loop + if loop is None: + return + source = self._scanner.source + needs = self._scheduler._needs + all_history = self._scheduler._manager._all_history + for address in list(needs): + entries = needs.get(address) + if not entries: + continue + history = all_history.get(address) + if history is None: + # Drop tracking for unseen addresses; on_advertisement will + # recreate the entry when the device next advertises. + del needs[address] + continue + if history.source != source: + continue + now = loop.time() + due = [r for r, t in entries.items() if t <= now] + if not due: + continue + duration = self._scheduler._coalesce_duration(due) + self._window_end = now + duration + await self._run_window(duration) + now = loop.time() + for request in due: + entries[request] = now + request.scan_interval + self._window_end = 0.0 + + async def _dispatch_sweep(self) -> None: + """Fire the global rediscovery sweep if it's our turn and due.""" + loop = self._scheduler._loop + if loop is None: + return + now = loop.time() + if now < self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL: + return + sweep_lock = self._scheduler._sweep_lock + if sweep_lock is None: + return + async with sweep_lock: + now = loop.time() + # Re-check after acquiring lock; another worker may have moved + # our sweep clock forward (unlikely but defensive). + if now < self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL: + return + duration = AUTO_REDISCOVERY_SWEEP_DURATION + self._window_end = now + duration + try: + await self._run_window(duration) + finally: + # Advance even on failure so we don't busy-loop the worker. + self._sweep_last_completed = loop.time() + self._window_end = 0.0 + + async def _run_window(self, duration: float) -> bool: + """Ask the scanner for an active window; swallow per-call exceptions.""" + try: + return await self._scanner.async_request_active_window(duration) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "%s: error running active window of %.1fs", + self._scanner.name, + duration, + ) + return False + + class AutoScanScheduler: - """Schedules on-demand active windows across AUTO-mode scanners.""" + """Coordinates on-demand active windows across AUTO-mode scanners.""" __slots__ = ( "_loop", "_manager", "_needs", - "_pending_tasks", "_requests_by_address", "_running", - "_scanner_windows", - "_sweep_in_flight", - "_sweep_last_completed", - "_tick_handle", + "_sweep_lock", + "_workers", ) def __init__(self, manager: BluetoothManager) -> None: @@ -93,76 +257,61 @@ def __init__(self, manager: BluetoothManager) -> None: self._requests_by_address: dict[str, set[ActiveScanRequest]] = {} # address -> {request: next_due_loop_time} self._needs: dict[str, dict[ActiveScanRequest, float]] = {} - # source -> loop time when the current window ends (0.0 = idle) - self._scanner_windows: dict[str, float] = {} - # source -> last sweep completion loop time - self._sweep_last_completed: dict[str, float] = {} - # source currently running a global sweep, or None - self._sweep_in_flight: str | None = None - self._tick_handle: asyncio.TimerHandle | None = None + # source -> persistent worker task + self._workers: dict[str, _ScannerWorker] = {} + # Serializes global sweeps so at most one scanner is mid-sweep + # at a time across the whole manager. + self._sweep_lock: asyncio.Lock | None = None self._loop: asyncio.AbstractEventLoop | None = None self._running = False - self._pending_tasks: set[asyncio.Task[None]] = set() def start(self, loop: asyncio.AbstractEventLoop) -> None: - """Bind the scheduler to the event loop and schedule the first tick.""" + """Bind the scheduler to the event loop and spawn one worker per scanner.""" self._loop = loop self._running = True - # Schedule the first sweep for each AUTO scanner AUTO_INITIAL_SWEEP_DELAY - # from now, so HA startup isn't crowded by ACTIVE scans on every - # adapter at once. We store a fake "last completed" in the past - # such that last + AUTO_REDISCOVERY_INTERVAL == now + initial_delay; - # this also overwrites any 0.0 placeholders left by pre-start - # add_scanner calls. - initial_last = ( - loop.time() + AUTO_INITIAL_SWEEP_DELAY - AUTO_REDISCOVERY_INTERVAL - ) + self._sweep_lock = asyncio.Lock() for scanner in self._manager._sources.values(): if scanner.requested_mode is BluetoothScanningMode.AUTO: - self._sweep_last_completed[scanner.source] = initial_last - self._reschedule() + self._spawn_worker(scanner) def stop(self) -> None: - """Cancel any pending tick and pending window tasks.""" + """Cancel all worker tasks; the manager is shutting down.""" self._running = False - if self._tick_handle is not None: - self._tick_handle.cancel() - self._tick_handle = None - # Cancel any window tasks in flight so they cannot call - # async_request_active_window after shutdown has started. - for task in self._pending_tasks: - task.cancel() - self._pending_tasks.clear() - self._scanner_windows.clear() - self._sweep_in_flight = None + for worker in self._workers.values(): + worker.stop() + self._workers.clear() def add_scanner(self, scanner: BaseHaScanner) -> None: - """Register an AUTO-mode scanner for the global rediscovery sweep.""" + """Register an AUTO-mode scanner; spawns its worker if start() has run.""" if scanner.requested_mode is not BluetoothScanningMode.AUTO: return - if self._loop is None: - self._sweep_last_completed.setdefault(scanner.source, 0.0) + if self._loop is None or scanner.source in self._workers: return - # First sweep AUTO_INITIAL_SWEEP_DELAY after this scanner joins, so - # a freshly connected proxy gets a chance to settle before its - # active sweep instead of firing immediately. - initial_last = ( - self._loop.time() + AUTO_INITIAL_SWEEP_DELAY - AUTO_REDISCOVERY_INTERVAL - ) - self._sweep_last_completed.setdefault(scanner.source, initial_last) - self._reschedule() + self._spawn_worker(scanner) def remove_scanner(self, scanner: BaseHaScanner) -> None: - """Drop scheduler state for a scanner that's leaving the manager.""" - self._sweep_last_completed.pop(scanner.source, None) - self._scanner_windows.pop(scanner.source, None) - if self._sweep_in_flight == scanner.source: - self._sweep_in_flight = None - self._reschedule() + """Stop the worker for a scanner that's leaving the manager.""" + worker = self._workers.pop(scanner.source, None) + if worker is not None: + worker.stop() + + def _spawn_worker(self, scanner: BaseHaScanner) -> None: + """Start a fresh worker task for an AUTO scanner.""" + assert self._loop is not None # noqa: S101 # set by start() + worker = _ScannerWorker(self, scanner) + worker.start(self._loop) + self._workers[scanner.source] = worker def add_request(self, request: ActiveScanRequest) -> None: """Register an active-scan request for its address.""" self._requests_by_address.setdefault(request.address, set()).add(request) + # Wake any worker that currently owns this address. + history = self._manager._all_history.get(request.address) + if ( + history is not None + and (worker := self._workers.get(history.source)) is not None + ): + worker.wake() def remove_request(self, request: ActiveScanRequest) -> None: """Drop the request from the index and from any pending tracking.""" @@ -174,7 +323,6 @@ def remove_request(self, request: ActiveScanRequest) -> None: entries.pop(request, None) if not entries: del self._needs[request.address] - self._reschedule() def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: """Hot path. Track requests for the advertisement's address.""" @@ -193,114 +341,11 @@ def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: if request not in existing: existing[request] = self._loop.time() + request.scan_interval added = True - if added: - # Reschedule once after the whole batch instead of per entry; on - # the hot path multiple registrations for the same address would - # otherwise cancel and re-arm the tick timer N times. - self._reschedule() - - def _reschedule(self) -> None: - """Schedule the next tick based on the earliest pending due time.""" - if not self._running or self._loop is None: - return - next_event = self._next_event_time(self._loop.time()) - if self._tick_handle is not None: - self._tick_handle.cancel() - self._tick_handle = None - if next_event is None: - return - # Add a small floor so we never spin. - delay = max(0.05, next_event - self._loop.time()) - self._tick_handle = self._loop.call_later(delay, self._async_tick) - - def _next_event_time(self, now: float) -> float | None: - """Return the earliest upcoming event loop-time, or None if idle.""" - candidates: list[float] = [] - for callbacks in self._needs.values(): - if callbacks: - candidates.append(min(callbacks.values())) - for source, last in self._sweep_last_completed.items(): - if self._sweep_in_flight == source: - continue - candidates.append(last + AUTO_REDISCOVERY_INTERVAL) - if not candidates: - return None - return min(candidates) - - def _async_tick(self) -> None: - """Process all due windows and reschedule.""" - self._tick_handle = None - if not self._running or self._loop is None: - return - now = self._loop.time() - # Drop expired window markers. - for source in list(self._scanner_windows): - if self._scanner_windows[source] <= now: - del self._scanner_windows[source] - self._dispatch_per_device(now) - self._dispatch_global_sweep(now) - self._reschedule() - - def _dispatch_per_device(self, now: float) -> None: - """Fire windows for any (address, request) whose due time has passed.""" - for address, entries in list(self._needs.items()): - due = [r for r, t in entries.items() if t <= now] - if not due: - continue - history = self._manager._all_history.get(address) - if history is None: - # No recent sight; drop the tracking entries, they'll come - # back the next time the device advertises. - del self._needs[address] - continue - source = history.source - if (busy_end := self._scanner_windows.get(source)) is not None: - # Scanner busy. Defer due entries past the window end so - # _next_event_time doesn't stay in the past, which would - # otherwise busy-loop the tick every 50ms until the - # window drains. - deferred = busy_end + 0.05 - for request in due: - if entries[request] < deferred: - entries[request] = deferred - continue - scanner = self._manager._sources.get(source) - if scanner is not None and scanner.requested_mode is ( - BluetoothScanningMode.AUTO - ): - self._request_window(scanner, self._coalesce_duration(due)) - # Whether we fired or skipped (non-AUTO scanner), advance each - # due entry to its next cadence so we don't re-fire next tick. - for request in due: - entries[request] = now + request.scan_interval - - def _dispatch_global_sweep(self, now: float) -> None: - """Run a rediscovery sweep on the next eligible scanner, if any.""" - if self._sweep_in_flight is not None: - return - eligible: str | None = None - oldest: float = now - for source, last in self._sweep_last_completed.items(): - if last + AUTO_REDISCOVERY_INTERVAL > now: - continue - if source in self._scanner_windows: - continue - scanner = self._manager._sources.get(source) - if ( - scanner is None - or scanner.requested_mode is not BluetoothScanningMode.AUTO - ): - continue - if last <= oldest: - oldest = last - eligible = source - if eligible is None: - return - scanner = self._manager._sources[eligible] - self._sweep_in_flight = eligible - self._request_window( - scanner, AUTO_REDISCOVERY_SWEEP_DURATION, sweep_source=eligible - ) + # Wake the worker that owns this scanner so it can pick up the + # new tracking entry; without the wake the worker would sleep + # until its previously computed next-event time. + if added and (worker := self._workers.get(service_info.source)) is not None: + worker.wake() def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """Pick the max requested duration, clamped to the configured range.""" @@ -313,51 +358,3 @@ def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: if requested > AUTO_WINDOW_MAX_DURATION: return AUTO_WINDOW_MAX_DURATION return requested - - def _request_window( - self, - scanner: BaseHaScanner, - duration: float, - sweep_source: str | None = None, - ) -> None: - """Mark the scanner busy and kick off the active-window request.""" - if self._loop is None: - return - self._scanner_windows[scanner.source] = self._loop.time() + duration - task = self._loop.create_task(self._run_window(scanner, duration, sweep_source)) - self._pending_tasks.add(task) - task.add_done_callback(self._pending_tasks.discard) - - async def _run_window( - self, - scanner: BaseHaScanner, - duration: float, - sweep_source: str | None, - ) -> None: - """Await the scanner's active window and clear in-flight state.""" - ok = False - try: - ok = await scanner.async_request_active_window(duration) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "%s: error running active window of %.1fs", - scanner.name, - duration, - ) - finally: - # When the scanner could not honor the request (returned False - # or raised), drop the busy marker now so other work for that - # source isn't blocked for the full duration. - if not ok and self._scanner_windows.get(scanner.source) is not None: - del self._scanner_windows[scanner.source] - if sweep_source is not None: - # Update _sweep_last_completed even on failure so the next - # sweep is a full interval out instead of immediately - # re-eligible; otherwise _next_event_time would stay in - # the past and the tick would re-fire every 50ms, hammering - # the scanner with stop/start cycles. - if self._loop is not None: - self._sweep_last_completed[sweep_source] = self._loop.time() - if self._sweep_in_flight == sweep_source: - self._sweep_in_flight = None - self._reschedule() diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 03ebae4d..208c779b 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -87,7 +87,15 @@ def _inject(scanner: _RecordingAutoScanner, address: str) -> None: async def _drain() -> None: - await asyncio.sleep(0) + """Yield several times so worker tasks can process.""" + for _ in range(4): + await asyncio.sleep(0) + + +async def _run_worker_tick(scheduler: object, source: str) -> None: + """Drive one worker through a single tick for deterministic testing.""" + worker = scheduler._workers[source] # type: ignore[attr-defined] + await worker._tick() @pytest.mark.asyncio @@ -128,8 +136,8 @@ async def test_advertisement_for_unrelated_address_is_ignored() -> None: @pytest.mark.asyncio -async def test_tick_requests_active_window_on_auto_scanner() -> None: - """A due tracker entry triggers an active window on the owning scanner.""" +async def test_worker_tick_fires_active_window() -> None: + """A due tracker entry causes the owning scanner's worker to fire a window.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -143,8 +151,7 @@ async def test_tick_requests_active_window_on_auto_scanner() -> None: entries = sched._needs["11:22:33:44:55:66"] request = next(iter(entries)) entries[request] = loop.time() - 1.0 - sched._async_tick() - await _drain() + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0] assert entries[request] > loop.time() finally: @@ -153,8 +160,8 @@ async def test_tick_requests_active_window_on_auto_scanner() -> None: @pytest.mark.asyncio -async def test_tick_coalesces_overlapping_requests() -> None: - """Two requests for the same address coalesce into one max-duration window.""" +async def test_worker_tick_coalesces_overlapping_requests() -> None: + """Multiple requests for the same address coalesce on max scan_duration.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -172,8 +179,7 @@ async def test_tick_coalesces_overlapping_requests() -> None: entries = sched._needs[address] for req in list(entries): entries[req] = loop.time() - 1.0 - sched._async_tick() - await _drain() + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [10.0] finally: cancel1() @@ -203,16 +209,13 @@ async def test_multiple_requests_same_address_track_independent_intervals() -> N fast, slow = sorted(entries, key=lambda r: r.scan_interval) entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() + 200.0 - sched._async_tick() - await _drain() + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [2.0] assert entries[fast] > loop.time() assert entries[slow] > loop.time() + 100 - sched._scanner_windows.clear() entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() - 1.0 - sched._async_tick() - await _drain() + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [2.0, 4.0] finally: cancel_fast() @@ -221,53 +224,39 @@ async def test_multiple_requests_same_address_track_independent_intervals() -> N @pytest.mark.asyncio -async def test_tick_skips_non_auto_scanner() -> None: - """ACTIVE / PASSIVE scanners are not asked to flip; due times advance.""" +async def test_no_worker_for_non_auto_scanner() -> None: + """ACTIVE / PASSIVE scanners don't get a worker; their windows are never fired.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - address = "11:22:33:44:55:66" - cancel = manager.async_register_active_scan( - address, scan_interval=120.0, scan_duration=3.0 - ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.ACTIVE) register_cancel = manager.async_register_scanner(scanner) try: - _inject(scanner, address) - entries = sched._needs.get(address, {}) - for req in list(entries): - entries[req] = loop.time() - 1.0 - sched._async_tick() - await _drain() - assert scanner.active_window_calls == [] + assert scanner.source not in sched._workers finally: - cancel() register_cancel() @pytest.mark.asyncio async def test_global_sweep_runs_on_auto_scanner() -> None: - """The 4h sweep fires async_request_active_window with SWEEP_DURATION.""" + """The sweep fires async_request_active_window with SWEEP_DURATION.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( - loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 - ) - sched._async_tick() - await _drain() + worker = sched._workers[scanner.source] + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] - assert sched._sweep_in_flight is None + assert worker._sweep_last_completed > loop.time() - 1.0 finally: register_cancel() @pytest.mark.asyncio async def test_global_sweep_one_scanner_at_a_time() -> None: - """While one scanner sweeps, no other scanner is asked to sweep.""" + """Two scanners both due for sweep do not sweep concurrently.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -278,23 +267,23 @@ async def test_global_sweep_one_scanner_at_a_time() -> None: c1 = manager.async_register_scanner(s1) c2 = manager.async_register_scanner(s2) try: - now = loop.time() - sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( - now - AUTO_REDISCOVERY_INTERVAL - 10 - ) - sched._sweep_last_completed["AA:BB:CC:DD:EE:11"] = ( - now - AUTO_REDISCOVERY_INTERVAL - 5 - ) - sched._async_tick() + w1 = sched._workers[s1.source] + w2 = sched._workers[s2.source] + w1._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 10 + w2._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 5 + # Kick worker 1 into its sweep (it'll block on the asyncio.Event). + t1 = asyncio.create_task(w1._tick()) await _drain() - assert sched._sweep_in_flight == "AA:BB:CC:DD:EE:00" - sched._async_tick() + # While w1's sweep is blocked, w2 attempts its sweep too. It must + # wait on the shared _sweep_lock and not fire concurrently. + t2 = asyncio.create_task(w2._tick()) await _drain() + assert s1.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] assert s2.active_window_calls == [] blocking.set() - await asyncio.sleep(0) - await asyncio.sleep(0) - assert sched._sweep_in_flight is None + await t1 + await t2 + assert s2.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] finally: blocking.set() c1() @@ -320,64 +309,9 @@ async def test_remove_request_clears_tracking() -> None: register_cancel() -@pytest.mark.asyncio -async def test_busy_scanner_defers_due_callbacks_not_busy_loops() -> None: - """A scanner mid-window pushes due requests past the window end.""" - manager = get_manager() - sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - address = "11:22:33:44:55:66" - cancel = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=3.0 - ) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) - try: - _inject(scanner, address) - entries = sched._needs[address] - request = next(iter(entries)) - entries[request] = loop.time() - 1.0 - busy_end = loop.time() + 5.0 - sched._scanner_windows[scanner.source] = busy_end - sched._async_tick() - await _drain() - assert scanner.active_window_calls == [] - assert entries[request] >= busy_end - finally: - cancel() - register_cancel() - - -@pytest.mark.asyncio -async def test_failed_request_clears_busy_marker() -> None: - """A False return from async_request_active_window frees the scanner.""" - manager = get_manager() - sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - address = "11:22:33:44:55:66" - cancel = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=3.0 - ) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - scanner._return_value = False - register_cancel = manager.async_register_scanner(scanner) - try: - _inject(scanner, address) - entries = sched._needs[address] - for req in list(entries): - entries[req] = loop.time() - 1.0 - sched._async_tick() - await _drain() - assert scanner.active_window_calls == [3.0] - assert scanner.source not in sched._scanner_windows - finally: - cancel() - register_cancel() - - @pytest.mark.asyncio async def test_failed_sweep_advances_sweep_last_completed() -> None: - """A False return on a sweep updates _sweep_last_completed so we don't busy-loop.""" + """A False return on a sweep advances the worker's sweep clock.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -385,64 +319,54 @@ async def test_failed_sweep_advances_sweep_last_completed() -> None: scanner._return_value = False register_cancel = manager.async_register_scanner(scanner) try: - sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( - loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 - ) - before = sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] - sched._async_tick() - await _drain() + worker = sched._workers[scanner.source] + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + before = worker._sweep_last_completed + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] - # _sweep_last_completed was advanced even though the window failed, - # so the next sweep is one full interval out instead of immediate. - assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] > before - assert sched._sweep_in_flight is None + # Even on False, the worker's sweep clock advanced so the next + # sweep is one full interval out instead of immediate. + assert worker._sweep_last_completed > before finally: register_cancel() @pytest.mark.asyncio -async def test_stop_cancels_pending_window_tasks() -> None: - """Scheduler.stop cancels in-flight active-window tasks.""" +async def test_stop_cancels_worker_tasks() -> None: + """Scheduler.stop cancels every worker task.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - blocking = asyncio.Event() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - scanner._block_event = blocking register_cancel = manager.async_register_scanner(scanner) try: - sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( - loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 - ) - sched._async_tick() - await _drain() - assert len(sched._pending_tasks) == 1 - pending = next(iter(sched._pending_tasks)) + worker = sched._workers[scanner.source] + task = worker._task + assert task is not None sched.stop() await asyncio.sleep(0) - assert pending.cancelled() or pending.done() - assert sched._pending_tasks == set() - assert sched._scanner_windows == {} - assert sched._sweep_in_flight is None + assert task.cancelled() or task.done() + assert sched._workers == {} finally: - blocking.set() register_cancel() @pytest.mark.asyncio async def test_dispatch_drops_tracking_for_unseen_address() -> None: - """A due address with no history entry is pruned, not retried.""" + """An address with no history entry is pruned on the next worker tick.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() cancel = manager.async_register_active_scan("aa:bb:cc:dd:ee:ff", scan_interval=60.0) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) try: request = next(iter(sched._requests_by_address["aa:bb:cc:dd:ee:ff"])) sched._needs["aa:bb:cc:dd:ee:ff"] = {request: loop.time() - 1.0} - sched._async_tick() + await _run_worker_tick(sched, scanner.source) assert "aa:bb:cc:dd:ee:ff" not in sched._needs finally: cancel() + register_cancel() @pytest.mark.asyncio @@ -454,55 +378,38 @@ async def test_first_sweep_is_delayed_after_scanner_registers() -> None: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - last = sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] - first_sweep_at = last + AUTO_REDISCOVERY_INTERVAL + worker = sched._workers[scanner.source] + first_sweep_at = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL now = loop.time() - # First sweep should land roughly AUTO_INITIAL_SWEEP_DELAY from now, - # not AUTO_REDISCOVERY_INTERVAL from now. assert ( AUTO_INITIAL_SWEEP_DELAY - 1.0 <= first_sweep_at - now <= AUTO_INITIAL_SWEEP_DELAY + 1.0 ) - # Force the tick at the scheduled first sweep time and confirm - # the sweep would fire. - sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] = ( - now - AUTO_REDISCOVERY_INTERVAL - 1.0 - ) - sched._async_tick() - await _drain() - assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] finally: register_cancel() @pytest.mark.asyncio -async def test_remove_scanner_clears_sweep_state() -> None: - """Unregistering a scanner drops its sweep / window state.""" +async def test_remove_scanner_stops_its_worker() -> None: + """Unregistering a scanner cancels and drops its worker.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) cancel = manager.async_register_scanner(scanner) - assert "AA:BB:CC:DD:EE:00" in sched._sweep_last_completed + assert scanner.source in sched._workers + worker = sched._workers[scanner.source] + task = worker._task cancel() - assert "AA:BB:CC:DD:EE:00" not in sched._sweep_last_completed - - -@pytest.mark.asyncio -async def test_remove_scanner_clears_sweep_in_flight() -> None: - """Unregistering a scanner mid-sweep resets _sweep_in_flight.""" - manager = get_manager() - sched = manager._auto_scheduler - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - cancel = manager.async_register_scanner(scanner) - sched._sweep_in_flight = scanner.source - cancel() - assert sched._sweep_in_flight is None + await asyncio.sleep(0) + assert scanner.source not in sched._workers + assert task is not None + assert task.cancelled() or task.done() @pytest.mark.asyncio -async def test_add_scanner_before_start_stores_placeholder() -> None: - """A scanner registered before start() leaves a placeholder until start runs.""" +async def test_add_scanner_before_start_defers_worker() -> None: + """A scanner registered before start() gets its worker on start().""" manager = get_manager() sched = manager._auto_scheduler loop = sched._loop @@ -511,13 +418,13 @@ async def test_add_scanner_before_start_stores_placeholder() -> None: try: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) sched.add_scanner(scanner) - assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] == 0.0 + assert scanner.source not in sched._workers manager._sources[scanner.source] = scanner sched.start(loop) - assert sched._sweep_last_completed["AA:BB:CC:DD:EE:00"] > 0.0 + assert scanner.source in sched._workers + sched._workers[scanner.source].stop() finally: manager._sources.pop("AA:BB:CC:DD:EE:00", None) - sched._sweep_last_completed.pop("AA:BB:CC:DD:EE:00", None) @pytest.mark.asyncio @@ -527,10 +434,7 @@ async def test_stop_is_safe_when_already_idle() -> None: sched = manager._auto_scheduler sched.stop() sched.stop() - assert sched._tick_handle is None - assert sched._pending_tasks == set() - assert sched._scanner_windows == {} - assert sched._sweep_in_flight is None + assert sched._workers == {} @pytest.mark.asyncio @@ -564,3 +468,23 @@ async def test_on_advertisement_early_returns_with_no_requests() -> None: assert sched._requests_by_address == {} finally: register_cancel() + + +@pytest.mark.asyncio +async def test_on_advertisement_wakes_owning_worker() -> None: + """Adding a tracking entry wakes the worker so it picks the new event up.""" + manager = get_manager() + sched = manager._auto_scheduler + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + cancel = manager.async_register_active_scan( + "11:22:33:44:55:66", scan_interval=120.0 + ) + try: + worker = sched._workers[scanner.source] + worker._wake.clear() + _inject(scanner, "11:22:33:44:55:66") + assert worker._wake.is_set() + finally: + cancel() + register_cancel() From e055ce53416def370dc7372777b8d5dc09307650 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:24:14 -0500 Subject: [PATCH 12/75] fix(auto): break circular cimport triangle that broke macOS CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto_scheduler.pxd cimported BaseHaScanner from base_scanner.pxd, which together with the existing manager ↔ base_scanner two-way circular cimport formed a triangle: manager cimports auto_scheduler which cimports base_scanner which cimports manager. On macOS this load order caused channels/bluez.so to look up BleakCallback in a partially-initialized manager module and raise AttributeError. Type the scanner parameter on add_scanner / remove_scanner as object instead of BaseHaScanner. The body of those methods only uses .requested_mode and .source which are accessible via the generic object protocol; the cython speedup from a typed parameter is not load-bearing for these cold paths. on_advertisement keeps its tight typing because that's the hot one. --- src/habluetooth/auto_scheduler.pxd | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 45b3b230..36b7af67 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -1,6 +1,5 @@ import cython -from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak @@ -25,9 +24,14 @@ cdef class AutoScanScheduler: cpdef void remove_request(self, ActiveScanRequest request) - cpdef void add_scanner(self, BaseHaScanner scanner) + # scanner is typed as object rather than BaseHaScanner to avoid a + # triangular cimport (manager -> auto_scheduler -> base_scanner -> + # manager) which loads modules in an order on macOS where + # BleakCallback is referenced from channels/bluez before manager + # finishes initializing. + cpdef void add_scanner(self, object scanner) - cpdef void remove_scanner(self, BaseHaScanner scanner) + cpdef void remove_scanner(self, object scanner) @cython.locals( address=str, From e1693597351e0545aa7310e3dfcf6e2954dcf28f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:35:51 -0500 Subject: [PATCH 13/75] fix(manager): defer channels.bluez import to break macOS Cython init cycle channels.bluez transitively cimports BleakCallback from manager (via scanner -> base_scanner -> manager) so its module init looks up BleakCallback on habluetooth.manager. When manager imported channels.bluez at module top level, the order on macOS Cython was: __init__.py -> base_scanner (cimport BluetoothManager) -> manager module starts executing -> manager line 35 imports channels.bluez -> channels.bluez init looks up BleakCallback on manager -> manager is still mid-init, BleakCallback not bound yet -> AttributeError Move the import inside async_setup (where CONNECTION_ERRORS and MGMTBluetoothCtl are actually used) and expose MGMTBluetoothCtl via the TYPE_CHECKING block so the existing string annotations still resolve. Repoints the two tests that patched habluetooth.manager.MGMTBluetoothCtl onto habluetooth.channels.bluez. --- src/habluetooth/manager.py | 11 ++++++++++- tests/test_manager.py | 2 +- tests/test_scanner.py | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 33955abc..bbe97c98 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -32,7 +32,6 @@ AdvertisementTracker, ) from .auto_scheduler import ActiveScanRequest, AutoScanScheduler -from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl from .const import ( ADV_RSSI_SWITCH_THRESHOLD, CALLBACK_TYPE, @@ -56,6 +55,7 @@ from bleak.backends.scanner import AdvertisementData from .base_scanner import BaseHaScanner + from .channels.bluez import MGMTBluetoothCtl from .scanner import HaScanner @@ -358,7 +358,16 @@ async def _async_recover_failed_adapters(self) -> None: async def async_setup(self) -> None: """Set up the bluetooth manager.""" + # Lazy-imported here to break a Cython circular-cimport chain on + # macOS: channels.bluez transitively cimports BleakCallback from + # this module via scanner -> base_scanner -> manager, and pulling + # it in at module top-level made channels.bluez try to look up + # BleakCallback while this module is still being initialized. from .central_manager import CentralBluetoothManager + from .channels.bluez import ( + CONNECTION_ERRORS, + MGMTBluetoothCtl, + ) if CentralBluetoothManager.manager is None: CentralBluetoothManager.manager = self diff --git a/tests/test_manager.py b/tests/test_manager.py index f5f1207a..1094e958 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1396,7 +1396,7 @@ async def test_is_operating_degraded_after_permission_error() -> None: with ( patch("habluetooth.manager.IS_LINUX", True), - patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class, + patch("habluetooth.channels.bluez.MGMTBluetoothCtl") as mock_mgmt_class, ): # Make setup fail with permission error mock_mgmt_instance = Mock() diff --git a/tests/test_scanner.py b/tests/test_scanner.py index a5df3bfa..767f2a3e 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1344,7 +1344,7 @@ def _discover_service_info( # Mock MGMTBluetoothCtl setup to raise PermissionError with ( - patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_cls, + patch("habluetooth.channels.bluez.MGMTBluetoothCtl") as mock_mgmt_cls, patch("habluetooth.manager.IS_LINUX", True), ): mock_mgmt = Mock() From 0eb280c44a901964b866843ecfc04ab5b5ff7c0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:36:36 -0500 Subject: [PATCH 14/75] refactor(auto): restore typed BaseHaScanner cimport in scheduler pxd Now that the channels.bluez import in manager has been deferred, the triangular cimport (manager -> auto_scheduler -> base_scanner -> manager) no longer loads channels.bluez before manager finishes initializing on macOS, so we can put the typed BaseHaScanner back on add_scanner / remove_scanner. --- src/habluetooth/auto_scheduler.pxd | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 36b7af67..45b3b230 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -1,5 +1,6 @@ import cython +from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak @@ -24,14 +25,9 @@ cdef class AutoScanScheduler: cpdef void remove_request(self, ActiveScanRequest request) - # scanner is typed as object rather than BaseHaScanner to avoid a - # triangular cimport (manager -> auto_scheduler -> base_scanner -> - # manager) which loads modules in an order on macOS where - # BleakCallback is referenced from channels/bluez before manager - # finishes initializing. - cpdef void add_scanner(self, object scanner) + cpdef void add_scanner(self, BaseHaScanner scanner) - cpdef void remove_scanner(self, object scanner) + cpdef void remove_scanner(self, BaseHaScanner scanner) @cython.locals( address=str, From 3540555b86653c4e1ad580f00100ce39bfef3e19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 08:48:19 -0500 Subject: [PATCH 15/75] fix(auto): re-drop BaseHaScanner cimport to break 3-way cycle on macOS Restoring the typed cimport in 0eb280c re-introduced a Cython init order failure that the previous channels.bluez fix didn't cover: base_scanner init -> cimport BluetoothManager from manager -> manager init -> cimport AutoScanScheduler from auto_scheduler -> auto_scheduler init -> cimport BaseHaScanner from base_scanner -> base_scanner is still partially initialized -> KeyError: '__pyx_vtable__' Cython handles the existing 2-way manager <-> base_scanner cycle via forward declarations, but the 3-way variant collides on macOS. Type scanner as object on add_scanner / remove_scanner; these are cold paths and the typing loss is not measurable. Both this and the channels.bluez deferred import are needed; either one alone leaves macOS broken in a different spot. --- src/habluetooth/auto_scheduler.pxd | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 45b3b230..26de21e9 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -1,6 +1,5 @@ import cython -from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak @@ -25,9 +24,18 @@ cdef class AutoScanScheduler: cpdef void remove_request(self, ActiveScanRequest request) - cpdef void add_scanner(self, BaseHaScanner scanner) - - cpdef void remove_scanner(self, BaseHaScanner scanner) + # scanner is typed as object rather than BaseHaScanner to avoid a + # three-way cimport cycle: manager.pxd cimports auto_scheduler, + # auto_scheduler would cimport base_scanner, and base_scanner already + # cimports manager. Cython handles a 2-way cycle (manager <-> + # base_scanner) via forward declarations but the 3-way variant + # breaks on macOS at init time with KeyError: '__pyx_vtable__' + # because base_scanner is only partially initialized when + # auto_scheduler tries to resolve BaseHaScanner. Object typing on + # these cold paths costs nothing measurable. + cpdef void add_scanner(self, object scanner) + + cpdef void remove_scanner(self, object scanner) @cython.locals( address=str, From 251518c66dde62f76bf2eacaff2bdef11680e88e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 09:14:00 -0500 Subject: [PATCH 16/75] fix(manager): drop AutoScanScheduler cimport to settle macOS Cython init The auto_scheduler cimport plus the existing two-way manager <-> base_scanner cycle expanded the init graph into a four-way chain (base_scanner -> manager -> auto_scheduler / manager -> base_scanner) that macOS Cython could not resolve. Even after dropping the BaseHaScanner cimport from auto_scheduler.pxd and deferring channels.bluez, base_scanner kept failing with KeyError: '__pyx_vtable__' while looking up BluetoothManager during its own init. Removing the cimport from manager.pxd and typing _auto_scheduler as object breaks that chain entirely. AutoScanScheduler is still imported via regular Python in manager.py, so behavior is unchanged. The hot on_advertisement dispatch loses its direct C call (becomes a Python attribute lookup) which we accept for now in exchange for a CI that actually runs; revisiting the perf path is straightforward once the cycle is gone. --- src/habluetooth/manager.pxd | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/habluetooth/manager.pxd b/src/habluetooth/manager.pxd index b3dcafc3..d127e419 100644 --- a/src/habluetooth/manager.pxd +++ b/src/habluetooth/manager.pxd @@ -1,10 +1,17 @@ import cython from .advertisement_tracker cimport AdvertisementTracker -from .auto_scheduler cimport ActiveScanRequest, AutoScanScheduler from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak +# auto_scheduler is intentionally NOT cimported here. Pulling AutoScanScheduler +# into manager.pxd plus the existing manager <-> base_scanner two-way cycle +# turned the load graph into a four-way chain that macOS Cython could not +# settle at init time, leaving BluetoothManager's __pyx_vtable__ unbound when +# base_scanner came back around to look it up. Importing AutoScanScheduler as +# regular Python in manager.py and typing _auto_scheduler as object keeps the +# class fully usable while removing the cimport that was breaking init. + cdef int NO_RSSI_VALUE cdef int ADV_RSSI_SWITCH_THRESHOLD cdef double TRACKER_BUFFERING_WOBBLE_SECONDS @@ -70,7 +77,7 @@ cdef class BluetoothManager: cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl - cdef public AutoScanScheduler _auto_scheduler + cdef public object _auto_scheduler @cython.locals(stale_seconds=double) cdef bint _prefer_previous_adv_from_different_source( From 8c6f1cfcc0798b2e657c01d1a5f143401c343a29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 09:51:40 -0500 Subject: [PATCH 17/75] fix(auto): restore cimport via mutual cycle to recover hot-path dispatch 251518c dropped the AutoScanScheduler cimport from manager.pxd to get macOS Cython init unstuck, which restored the codspeed regression by demoting self._auto_scheduler.on_advertisement(...) in the manager hot path from a direct vtable call to a Python attribute lookup plus a PyObject_Call. Put the cimport back, but pair it with a matching cimport of BluetoothManager from manager in auto_scheduler.pxd so Cython sees the manager <-> auto_scheduler dependency from both sides. That triggers the deferred-resolution path that handles the existing manager <-> base_scanner two-way cycle; the one-way variant that broke macOS only advertised the dependency from one side and Cython couldn't settle the load order. auto_scheduler._manager stays typed as object: promoting it to BluetoothManager would require base_scanner to also cimport auto_scheduler and that three-way cycle is the one that actually breaks (KeyError: '__pyx_vtable__' on whichever module is partial when the chain comes back around). The generated C confirms _auto_scheduler is now an AutoScanScheduler struct pointer in manager.c and on_advertisement is called via the cdef class vtable. --- src/habluetooth/auto_scheduler.pxd | 24 +++++++++++++++--------- src/habluetooth/manager.pxd | 11 ++--------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 26de21e9..452c8cbe 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -1,7 +1,15 @@ import cython +from .manager cimport BluetoothManager from .models cimport BluetoothServiceInfoBleak +# auto_scheduler intentionally cimports BluetoothManager even though the +# attribute is stored untyped: the mutual cimport (manager <-> auto_scheduler) +# is what lets Cython's deferred-resolution path settle the init order on +# macOS. The one-way variant produced KeyError: '__pyx_vtable__' on the +# partially-initialized peer because the deferral only kicks in when both +# sides advertise the dependency at compile time. + cdef class ActiveScanRequest: @@ -12,6 +20,13 @@ cdef class ActiveScanRequest: cdef class AutoScanScheduler: + # _manager is typed as object rather than BluetoothManager to keep the + # attribute access through Python protocol; promoting to a typed cdef + # would require base_scanner to also cimport auto_scheduler and the + # resulting three-way cycle (manager <-> base_scanner, base_scanner <-> + # auto_scheduler, auto_scheduler <-> manager) breaks macOS Cython init + # with KeyError: '__pyx_vtable__' on whichever module is partial when + # the chain comes back around. cdef public object _manager cdef public dict _requests_by_address cdef public dict _needs @@ -24,15 +39,6 @@ cdef class AutoScanScheduler: cpdef void remove_request(self, ActiveScanRequest request) - # scanner is typed as object rather than BaseHaScanner to avoid a - # three-way cimport cycle: manager.pxd cimports auto_scheduler, - # auto_scheduler would cimport base_scanner, and base_scanner already - # cimports manager. Cython handles a 2-way cycle (manager <-> - # base_scanner) via forward declarations but the 3-way variant - # breaks on macOS at init time with KeyError: '__pyx_vtable__' - # because base_scanner is only partially initialized when - # auto_scheduler tries to resolve BaseHaScanner. Object typing on - # these cold paths costs nothing measurable. cpdef void add_scanner(self, object scanner) cpdef void remove_scanner(self, object scanner) diff --git a/src/habluetooth/manager.pxd b/src/habluetooth/manager.pxd index d127e419..b3dcafc3 100644 --- a/src/habluetooth/manager.pxd +++ b/src/habluetooth/manager.pxd @@ -1,17 +1,10 @@ import cython from .advertisement_tracker cimport AdvertisementTracker +from .auto_scheduler cimport ActiveScanRequest, AutoScanScheduler from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak -# auto_scheduler is intentionally NOT cimported here. Pulling AutoScanScheduler -# into manager.pxd plus the existing manager <-> base_scanner two-way cycle -# turned the load graph into a four-way chain that macOS Cython could not -# settle at init time, leaving BluetoothManager's __pyx_vtable__ unbound when -# base_scanner came back around to look it up. Importing AutoScanScheduler as -# regular Python in manager.py and typing _auto_scheduler as object keeps the -# class fully usable while removing the cimport that was breaking init. - cdef int NO_RSSI_VALUE cdef int ADV_RSSI_SWITCH_THRESHOLD cdef double TRACKER_BUFFERING_WOBBLE_SECONDS @@ -77,7 +70,7 @@ cdef class BluetoothManager: cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl - cdef public object _auto_scheduler + cdef public AutoScanScheduler _auto_scheduler @cython.locals(stale_seconds=double) cdef bint _prefer_previous_adv_from_different_source( From fc21c439ed58c21574336726e861c1dd7adfd559 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 09:57:25 -0500 Subject: [PATCH 18/75] fix(auto): drop the BluetoothManager cimport from auto_scheduler.pxd The mutual cimport (manager cimports auto_scheduler, auto_scheduler cimports manager) was meant to trigger Cython's deferred resolution on macOS but CI still failed at module init. Drop the auto_scheduler side; the cimport from manager.pxd stays so the hot-path call into on_advertisement remains a direct vtable dispatch. The _manager attribute on AutoScanScheduler stays typed object, which costs nothing since the scheduler only touches it from the worker tasks. --- src/habluetooth/auto_scheduler.pxd | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 452c8cbe..9cd4f0f5 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -1,15 +1,7 @@ import cython -from .manager cimport BluetoothManager from .models cimport BluetoothServiceInfoBleak -# auto_scheduler intentionally cimports BluetoothManager even though the -# attribute is stored untyped: the mutual cimport (manager <-> auto_scheduler) -# is what lets Cython's deferred-resolution path settle the init order on -# macOS. The one-way variant produced KeyError: '__pyx_vtable__' on the -# partially-initialized peer because the deferral only kicks in when both -# sides advertise the dependency at compile time. - cdef class ActiveScanRequest: @@ -20,13 +12,6 @@ cdef class ActiveScanRequest: cdef class AutoScanScheduler: - # _manager is typed as object rather than BluetoothManager to keep the - # attribute access through Python protocol; promoting to a typed cdef - # would require base_scanner to also cimport auto_scheduler and the - # resulting three-way cycle (manager <-> base_scanner, base_scanner <-> - # auto_scheduler, auto_scheduler <-> manager) breaks macOS Cython init - # with KeyError: '__pyx_vtable__' on whichever module is partial when - # the chain comes back around. cdef public object _manager cdef public dict _requests_by_address cdef public dict _needs From 1d2c995404c8649723986509bc429ea5d3b826dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 10:02:09 -0500 Subject: [PATCH 19/75] docs(auto): trim verbose comments and docstrings Earlier work-through on the cython init issue left the auto-scheduler, manager, scanner, and const files with paragraph-length comments that re-explained build-time behavior already captured by the commit history. Replace the longest ones with single-line callouts where the why is still useful and drop the rest. --- src/habluetooth/auto_scheduler.py | 87 ++++++++----------------------- src/habluetooth/const.py | 21 +++----- src/habluetooth/manager.py | 11 +--- src/habluetooth/scanner.py | 35 +++---------- 4 files changed, 39 insertions(+), 115 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index b2278633..a9a820ba 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -1,28 +1,12 @@ """ -Auto-mode active-window scheduler for the bluetooth manager. - -Coordinates two distinct kinds of active scanning windows on AUTO-mode -scanners: - -* Per-device windows. Callers (Home Assistant's bluetooth integration is - the primary one) register an ``ActiveScanRequest`` for a specific - address with a ``scan_interval`` and ``scan_duration``. When a matching - advertisement arrives the scheduler asks the scanner currently seeing - the device to flip active for the requested duration, repeating on the - requested cadence. Multiple matching requests for the same address - coalesce into one window using the max of their durations. - -* Global rediscovery sweeps. ``AUTO_INITIAL_SWEEP_DELAY`` after a scanner - joins, and every ``AUTO_REDISCOVERY_INTERVAL`` thereafter, each AUTO-mode - scanner gets a ``AUTO_REDISCOVERY_SWEEP_DURATION`` active window. Sweeps - are staggered across scanners so that at most one scanner is mid-sweep - at a time, keeping the radio coverage gap bounded. - -Each AUTO scanner has one persistent ``_ScannerWorker`` task that sleeps -on an ``asyncio.Event`` with a ``wait_for`` timeout matching the next -scheduled event for that scanner. State mutations (new request, new -advertisement, scanner registration) just set the wake event; no task is -created per window dispatch. +Auto-mode active-window scheduler. + +One ``_ScannerWorker`` task per AUTO scanner sleeps on an +``asyncio.Event`` with a ``wait_for`` timeout until the next due event; +per-address registrations fire scan_interval/scan_duration windows on +the scanner currently seeing the device, and each scanner sweeps once +``AUTO_INITIAL_SWEEP_DELAY`` after joining then every +``AUTO_REDISCOVERY_INTERVAL`` thereafter, serialized across scanners. """ from __future__ import annotations @@ -51,14 +35,7 @@ class ActiveScanRequest: - """ - A registered need for on-demand active scans on a specific address. - - Created by ``BluetoothManager.async_register_active_scan``. The scheduler - indexes requests by ``address`` so the on_advertisement hot path is an - O(1) dict lookup; nothing is iterated when the advertisement's address - has no registered request. - """ + """A registered need for on-demand active scans on a specific address.""" __slots__ = ("address", "scan_duration", "scan_interval") @@ -90,9 +67,7 @@ def __init__(self, scheduler: AutoScanScheduler, scanner: BaseHaScanner) -> None self._scanner = scanner self._wake = asyncio.Event() self._task: asyncio.Task[None] | None = None - # When this scanner's current active window ends; 0.0 = idle. self._window_end: float = 0.0 - # When this scanner last completed (or attempted) a global sweep. self._sweep_last_completed: float = 0.0 def start(self, loop: asyncio.AbstractEventLoop) -> None: @@ -103,7 +78,7 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: self._task = loop.create_task(self._run()) def stop(self) -> None: - """Cancel the worker task; it will exit on its next CancelledError.""" + """Cancel the worker task.""" if self._task is not None and not self._task.done(): self._task.cancel() @@ -113,13 +88,9 @@ def wake(self) -> None: def _next_event_at(self, now: float) -> float: """Return the earliest loop-time at which this worker has work.""" - # If we have an active window in flight, next event is its end. if self._window_end > now: return self._window_end - # Sweep cadence. next_at = self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL - # Earliest per-device need owned by this scanner. A request's - # "owner" is whichever scanner currently sees its address. source = self._scanner.source needs = self._scheduler._needs all_history = self._scheduler._manager._all_history @@ -135,7 +106,7 @@ def _next_event_at(self, now: float) -> float: return next_at async def _run(self) -> None: - """Main loop: sleep until next event or wake, then process due work.""" + """Sleep until next event or wake, then process due work.""" try: while True: loop = self._scheduler._loop @@ -155,19 +126,18 @@ async def _run(self) -> None: raise async def _tick(self) -> None: - """Fire any due per-device windows for this scanner, then the sweep.""" + """Fire due per-device windows, then the sweep.""" loop = self._scheduler._loop if loop is None: return if self._window_end > loop.time(): - # Still inside the previous window; nothing to do yet. return self._window_end = 0.0 await self._dispatch_per_device() await self._dispatch_sweep() async def _dispatch_per_device(self) -> None: - """Process per-(address, request) needs that target this scanner.""" + """Fire per-(address, request) needs that target this scanner.""" loop = self._scheduler._loop if loop is None: return @@ -180,8 +150,6 @@ async def _dispatch_per_device(self) -> None: continue history = all_history.get(address) if history is None: - # Drop tracking for unseen addresses; on_advertisement will - # recreate the entry when the device next advertises. del needs[address] continue if history.source != source: @@ -199,7 +167,7 @@ async def _dispatch_per_device(self) -> None: self._window_end = 0.0 async def _dispatch_sweep(self) -> None: - """Fire the global rediscovery sweep if it's our turn and due.""" + """Fire the global rediscovery sweep if due.""" loop = self._scheduler._loop if loop is None: return @@ -211,8 +179,6 @@ async def _dispatch_sweep(self) -> None: return async with sweep_lock: now = loop.time() - # Re-check after acquiring lock; another worker may have moved - # our sweep clock forward (unlikely but defensive). if now < self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL: return duration = AUTO_REDISCOVERY_SWEEP_DURATION @@ -220,7 +186,7 @@ async def _dispatch_sweep(self) -> None: try: await self._run_window(duration) finally: - # Advance even on failure so we don't busy-loop the worker. + # Advance on failure too so a stuck scanner doesn't busy-loop. self._sweep_last_completed = loop.time() self._window_end = 0.0 @@ -253,20 +219,15 @@ class AutoScanScheduler: def __init__(self, manager: BluetoothManager) -> None: """Initialize the scheduler bound to a manager.""" self._manager = manager - # address -> registered requests for that address self._requests_by_address: dict[str, set[ActiveScanRequest]] = {} - # address -> {request: next_due_loop_time} self._needs: dict[str, dict[ActiveScanRequest, float]] = {} - # source -> persistent worker task self._workers: dict[str, _ScannerWorker] = {} - # Serializes global sweeps so at most one scanner is mid-sweep - # at a time across the whole manager. self._sweep_lock: asyncio.Lock | None = None self._loop: asyncio.AbstractEventLoop | None = None self._running = False def start(self, loop: asyncio.AbstractEventLoop) -> None: - """Bind the scheduler to the event loop and spawn one worker per scanner.""" + """Bind to the event loop and spawn one worker per AUTO scanner.""" self._loop = loop self._running = True self._sweep_lock = asyncio.Lock() @@ -275,14 +236,14 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: self._spawn_worker(scanner) def stop(self) -> None: - """Cancel all worker tasks; the manager is shutting down.""" + """Cancel all worker tasks.""" self._running = False for worker in self._workers.values(): worker.stop() self._workers.clear() def add_scanner(self, scanner: BaseHaScanner) -> None: - """Register an AUTO-mode scanner; spawns its worker if start() has run.""" + """Register an AUTO-mode scanner; spawn its worker if start() has run.""" if scanner.requested_mode is not BluetoothScanningMode.AUTO: return if self._loop is None or scanner.source in self._workers: @@ -290,22 +251,20 @@ def add_scanner(self, scanner: BaseHaScanner) -> None: self._spawn_worker(scanner) def remove_scanner(self, scanner: BaseHaScanner) -> None: - """Stop the worker for a scanner that's leaving the manager.""" + """Stop the worker for a scanner leaving the manager.""" worker = self._workers.pop(scanner.source, None) if worker is not None: worker.stop() def _spawn_worker(self, scanner: BaseHaScanner) -> None: - """Start a fresh worker task for an AUTO scanner.""" - assert self._loop is not None # noqa: S101 # set by start() + assert self._loop is not None # noqa: S101 worker = _ScannerWorker(self, scanner) worker.start(self._loop) self._workers[scanner.source] = worker def add_request(self, request: ActiveScanRequest) -> None: - """Register an active-scan request for its address.""" + """Register an active-scan request and wake the owning worker.""" self._requests_by_address.setdefault(request.address, set()).add(request) - # Wake any worker that currently owns this address. history = self._manager._all_history.get(request.address) if ( history is not None @@ -326,7 +285,6 @@ def remove_request(self, request: ActiveScanRequest) -> None: def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: """Hot path. Track requests for the advertisement's address.""" - # Early return when nothing is registered. Common case, cheap. if not self._requests_by_address or self._loop is None: return address = service_info.address @@ -341,9 +299,6 @@ def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: if request not in existing: existing[request] = self._loop.time() + request.scan_interval added = True - # Wake the worker that owns this scanner so it can pick up the - # new tracking entry; without the wake the worker would sleep - # until its previously computed next-event time. if added and (worker := self._workers.get(service_info.source)) is not None: worker.wake() diff --git a/src/habluetooth/const.py b/src/habluetooth/const.py index 9661338c..e778cc32 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -54,19 +54,14 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 -# AUTO scanning mode: each scanner in AUTO mode receives a periodic -# active "sweep" so new devices are still discovered. The manager -# staggers sweeps across scanners so at most one is active at a time. -# Each scanner's first sweep fires AUTO_INITIAL_SWEEP_DELAY after it -# joins the manager so startup isn't crowded by ACTIVE scans on every -# adapter at once; subsequent sweeps follow AUTO_REDISCOVERY_INTERVAL. -AUTO_INITIAL_SWEEP_DELAY: Final = 60 * 10 # 10 minutes after registration -AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 12 # 12 hours per scanner -AUTO_REDISCOVERY_SWEEP_DURATION: Final = 15.0 # seconds per scanner per sweep - -# AUTO scanning mode: bounds on the per-callback `scan_duration` value. -# Callers requesting an active window for a single device are clamped -# into this range to keep individual windows short and predictable. +# AUTO scanning mode: each scanner gets its first sweep +# AUTO_INITIAL_SWEEP_DELAY after joining, then every +# AUTO_REDISCOVERY_INTERVAL, serialized across scanners. +AUTO_INITIAL_SWEEP_DELAY: Final = 60 * 10 +AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 12 +AUTO_REDISCOVERY_SWEEP_DURATION: Final = 15.0 + +# Per-callback scan_duration is clamped into this range. AUTO_WINDOW_MIN_DURATION: Final = 1.0 AUTO_WINDOW_MAX_DURATION: Final = 30.0 diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index bbe97c98..f9f3a3ff 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -358,16 +358,9 @@ async def _async_recover_failed_adapters(self) -> None: async def async_setup(self) -> None: """Set up the bluetooth manager.""" - # Lazy-imported here to break a Cython circular-cimport chain on - # macOS: channels.bluez transitively cimports BleakCallback from - # this module via scanner -> base_scanner -> manager, and pulling - # it in at module top-level made channels.bluez try to look up - # BleakCallback while this module is still being initialized. + # Lazy-imported to break a Cython init cycle through channels.bluez. from .central_manager import CentralBluetoothManager - from .channels.bluez import ( - CONNECTION_ERRORS, - MGMTBluetoothCtl, - ) + from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl if CentralBluetoothManager.manager is None: CentralBluetoothManager.manager = self diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 4e52649d..6d8167ed 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -639,16 +639,13 @@ async def async_stop(self) -> None: """Stop bluetooth scanner.""" if self._start_future is not None and not self._start_future.done(): self._start_future.set_exception(_AbortStartError()) - # All state mutation runs inside _start_stop_lock so an in-flight - # async_request_active_window or _async_end_active_window can't - # set the handle / override after we've cleared them. async with self._start_stop_lock: self._clear_active_window_state() self._async_stop_scanner_watchdog() await self._async_stop_scanner() def _clear_active_window_state(self) -> None: - """Reset all AUTO active-window bookkeeping (must hold start/stop lock).""" + """Reset AUTO active-window state (caller must hold start/stop lock).""" if self._active_window_handle is not None: self._active_window_handle.cancel() self._active_window_handle = None @@ -656,7 +653,7 @@ def _clear_active_window_state(self) -> None: self._active_window_end = 0.0 def _arm_active_window_timer(self, duration: float, new_end: float) -> None: - """Schedule the end-of-window callback and record the end time.""" + """Schedule the end-of-window callback.""" if TYPE_CHECKING: assert self._loop is not None self._active_window_end = new_end @@ -668,12 +665,8 @@ async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds then restore prior mode. - Only effective for AUTO-mode scanners; ACTIVE/PASSIVE scanners are - already in a fixed mode and the call is a no-op. - - Overlapping requests on the same scanner coalesce: a request whose - end time extends past the currently running window simply extends - the existing window, avoiding a second restart cycle. + No-op on non-AUTO scanners. Overlapping requests extend the + existing window in place instead of triggering a second restart. """ if self.requested_mode is not BluetoothScanningMode.AUTO: return False @@ -681,15 +674,10 @@ async def async_request_active_window(self, duration: float) -> bool: assert self._loop is not None new_end = self._loop.time() + duration if self._active_window_handle is not None: - # A window is already running; extend it if the new request - # reaches further than the existing end. if new_end > self._active_window_end: self._active_window_handle.cancel() self._arm_active_window_timer(duration, new_end) return True - # All state mutation runs inside _start_stop_lock so we don't race - # with _async_end_active_window (which may have been scheduled by - # the timer firing concurrently) or async_stop. async with self._start_stop_lock: self._scan_mode_override = BluetoothScanningMode.ACTIVE try: @@ -699,32 +687,25 @@ async def async_request_active_window(self, duration: float) -> bool: self._scan_mode_override = None return False if self.current_mode is not BluetoothScanningMode.ACTIVE: - # _async_start_attempt silently falls back to PASSIVE on - # Linux when ACTIVE fails on the final retry. Treat that - # as a failed window: the scanner is back up but not in - # ACTIVE, so the scheduler must not believe it engaged. + # Linux's 4th-attempt fallback silently drops to PASSIVE. self._scan_mode_override = None return False self._arm_active_window_timer(duration, new_end) return True def _schedule_end_active_window(self) -> None: - """Schedule the end-of-window restart as a background task.""" + """Spawn the end-of-window restart task.""" self._active_window_handle = None self._create_background_task(self._async_end_active_window()) async def _async_end_active_window(self) -> None: - """Restore the scanner to its underlying mode after an active window.""" + """Restore the scanner to its underlying mode after the window ends.""" async with self._start_stop_lock: if self._active_window_handle is not None: - # A new active window was started while this end-window - # task was queued; defer to it. The new window owns the - # override and the timer; clearing them here would race - # the new window into restarting in passive. + # A new window took over; let it own the override and timer. return self._scan_mode_override = None if not self.scanning: - # Scanner was stopped while the window was active. return try: await self._async_stop_scanner() From da09455f37310b1ae77a9d4a820c1738a0cd2c50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 10:06:15 -0500 Subject: [PATCH 20/75] perf(auto): type const aliases and drop useless cancellederror re-raise Mirror the _CONST = CONST pattern already used in advertisement_tracker: alias the AUTO_* constants to _AUTO_* in auto_scheduler.py and declare the underscored names as cdef double in auto_scheduler.pxd so Cython substitutes the C literal at use sites instead of going through a module-level Python attribute lookup on every tick. Also drop the try/except asyncio.CancelledError: raise wrapper from _run; CancelledError already propagates by default, the explicit re-raise was no-op pattern. --- src/habluetooth/auto_scheduler.pxd | 6 +++ src/habluetooth/auto_scheduler.py | 59 ++++++++++++++++-------------- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 9cd4f0f5..9dfe0407 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -2,6 +2,12 @@ import cython from .models cimport BluetoothServiceInfoBleak +cdef double _AUTO_INITIAL_SWEEP_DELAY +cdef double _AUTO_REDISCOVERY_INTERVAL +cdef double _AUTO_REDISCOVERY_SWEEP_DURATION +cdef double _AUTO_WINDOW_MAX_DURATION +cdef double _AUTO_WINDOW_MIN_DURATION + cdef class ActiveScanRequest: diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index a9a820ba..4eab529d 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -25,6 +25,14 @@ ) from .models import BluetoothScanningMode +# Locally aliased so the Cython .pxd can declare them as C-typed constants; +# the unaliased names stay importable from this module for Python callers. +_AUTO_INITIAL_SWEEP_DELAY = AUTO_INITIAL_SWEEP_DELAY +_AUTO_REDISCOVERY_INTERVAL = AUTO_REDISCOVERY_INTERVAL +_AUTO_REDISCOVERY_SWEEP_DURATION = AUTO_REDISCOVERY_SWEEP_DURATION +_AUTO_WINDOW_MAX_DURATION = AUTO_WINDOW_MAX_DURATION +_AUTO_WINDOW_MIN_DURATION = AUTO_WINDOW_MIN_DURATION + if TYPE_CHECKING: from .base_scanner import BaseHaScanner from .manager import BluetoothManager @@ -73,7 +81,7 @@ def __init__(self, scheduler: AutoScanScheduler, scanner: BaseHaScanner) -> None def start(self, loop: asyncio.AbstractEventLoop) -> None: """Start the worker task; first sweep AUTO_INITIAL_SWEEP_DELAY out.""" self._sweep_last_completed = ( - loop.time() + AUTO_INITIAL_SWEEP_DELAY - AUTO_REDISCOVERY_INTERVAL + loop.time() + _AUTO_INITIAL_SWEEP_DELAY - _AUTO_REDISCOVERY_INTERVAL ) self._task = loop.create_task(self._run()) @@ -90,7 +98,7 @@ def _next_event_at(self, now: float) -> float: """Return the earliest loop-time at which this worker has work.""" if self._window_end > now: return self._window_end - next_at = self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + next_at = self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL source = self._scanner.source needs = self._scheduler._needs all_history = self._scheduler._manager._all_history @@ -107,23 +115,20 @@ def _next_event_at(self, now: float) -> float: async def _run(self) -> None: """Sleep until next event or wake, then process due work.""" - try: - while True: - loop = self._scheduler._loop - if loop is None: - return - now = loop.time() - next_at = self._next_event_at(now) - self._wake.clear() - delay = max(0.0, next_at - now) - if delay > 0: - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(self._wake.wait(), timeout=delay) - if not self._scheduler._running: - return - await self._tick() - except asyncio.CancelledError: - raise + while True: + loop = self._scheduler._loop + if loop is None: + return + now = loop.time() + next_at = self._next_event_at(now) + self._wake.clear() + delay = max(0.0, next_at - now) + if delay > 0: + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._wake.wait(), timeout=delay) + if not self._scheduler._running: + return + await self._tick() async def _tick(self) -> None: """Fire due per-device windows, then the sweep.""" @@ -172,16 +177,16 @@ async def _dispatch_sweep(self) -> None: if loop is None: return now = loop.time() - if now < self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL: + if now < self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL: return sweep_lock = self._scheduler._sweep_lock if sweep_lock is None: return async with sweep_lock: now = loop.time() - if now < self._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL: + if now < self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL: return - duration = AUTO_REDISCOVERY_SWEEP_DURATION + duration = _AUTO_REDISCOVERY_SWEEP_DURATION self._window_end = now + duration try: await self._run_window(duration) @@ -306,10 +311,10 @@ def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """Pick the max requested duration, clamped to the configured range.""" requested = max( (e.scan_duration for e in entries if e.scan_duration is not None), - default=AUTO_WINDOW_MIN_DURATION, + default=_AUTO_WINDOW_MIN_DURATION, ) - if requested < AUTO_WINDOW_MIN_DURATION: - return AUTO_WINDOW_MIN_DURATION - if requested > AUTO_WINDOW_MAX_DURATION: - return AUTO_WINDOW_MAX_DURATION + if requested < _AUTO_WINDOW_MIN_DURATION: + return _AUTO_WINDOW_MIN_DURATION + if requested > _AUTO_WINDOW_MAX_DURATION: + return _AUTO_WINDOW_MAX_DURATION return requested From 25472850c35ea1232f860c0436ff9200d1beb127 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 10:11:20 -0500 Subject: [PATCH 21/75] fix(__init__): import manager before base_scanner to settle macOS Cython init When base_scanner imports first it triggers manager init mid-way, and macOS Cython couldn't resolve the BluetoothManager vtable from a partially-initialized manager module (KeyError: '__pyx_vtable__'). Force manager to load first so base_scanner's cimport lands on a fully-initialized module. The isort: off block keeps ruff from sorting the two lines back into the import order it broke. --- src/habluetooth/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/habluetooth/__init__.py b/src/habluetooth/__init__.py index 7295eac5..08485336 100644 --- a/src/habluetooth/__init__.py +++ b/src/habluetooth/__init__.py @@ -2,11 +2,18 @@ from bleak_retry_connector import Allocations +# isort: off +# Order matters: manager must finish initializing before base_scanner +# imports it, otherwise macOS Cython hits KeyError: '__pyx_vtable__' +# resolving BluetoothManager from a partially-initialized module. from .advertisement_tracker import ( TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) +from .manager import BluetoothManager from .base_scanner import BaseHaRemoteScanner, BaseHaScanner + +# isort: on from .central_manager import get_manager, set_manager from .const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -15,7 +22,6 @@ SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) -from .manager import BluetoothManager from .models import ( BluetoothServiceInfo, BluetoothServiceInfoBleak, From 807c8531ae604360963d32aaf43da85fb908028f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 10:16:01 -0500 Subject: [PATCH 22/75] fix(auto): address review comments and DRY scanner restart pattern - HaScanner.async_request_active_window: if ACTIVE restart fails, clear the override and attempt to bring the scanner back up in the underlying AUTO/passive mode so we don't leave it stopped. - Pull the stop+start pair into _async_stop_then_start_under_lock so async_request_active_window and _async_end_active_window share the same restart helper (without the adapter-reset that the watchdog's _async_restart_scanner does, which is unwanted for mode toggles). - _dispatch_per_device: re-check membership in entries before advancing next-due; remove_request may have dropped the entry while we were awaiting the window. - async_register_active_scan: raise ValueError on non-positive scan_interval or negative scan_duration so a bogus call fails fast instead of spinning the scheduler. - Hoist the wake-worker-for-source pattern into _wake_worker so add_request and on_advertisement share the lookup. --- src/habluetooth/auto_scheduler.py | 20 +++++++++++++------- src/habluetooth/manager.py | 4 ++++ src/habluetooth/scanner.py | 17 +++++++++++++---- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 4eab529d..8c72cdfa 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -167,8 +167,12 @@ async def _dispatch_per_device(self) -> None: self._window_end = now + duration await self._run_window(duration) now = loop.time() + # Re-check membership: remove_request may have dropped any of + # the due entries while we were awaiting the window, and we + # don't want to resurrect a cancelled registration. for request in due: - entries[request] = now + request.scan_interval + if request in entries: + entries[request] = now + request.scan_interval self._window_end = 0.0 async def _dispatch_sweep(self) -> None: @@ -271,11 +275,8 @@ def add_request(self, request: ActiveScanRequest) -> None: """Register an active-scan request and wake the owning worker.""" self._requests_by_address.setdefault(request.address, set()).add(request) history = self._manager._all_history.get(request.address) - if ( - history is not None - and (worker := self._workers.get(history.source)) is not None - ): - worker.wake() + if history is not None: + self._wake_worker(history.source) def remove_request(self, request: ActiveScanRequest) -> None: """Drop the request from the index and from any pending tracking.""" @@ -304,7 +305,12 @@ def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: if request not in existing: existing[request] = self._loop.time() + request.scan_interval added = True - if added and (worker := self._workers.get(service_info.source)) is not None: + if added: + self._wake_worker(service_info.source) + + def _wake_worker(self, source: str) -> None: + """Wake the worker for ``source`` if one is registered.""" + if (worker := self._workers.get(source)) is not None: worker.wake() def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index f9f3a3ff..77b11da3 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1068,6 +1068,10 @@ def async_register_active_scan( ACTIVE and PASSIVE scanners ignore the request. Returns a cancel callable. """ + if scan_interval <= 0: + raise ValueError("scan_interval must be > 0") + if scan_duration is not None and scan_duration < 0: + raise ValueError("scan_duration must be None or >= 0") request = ActiveScanRequest(address, scan_interval, scan_duration) self._auto_scheduler.add_request(request) return partial(self._auto_scheduler.remove_request, request) diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 6d8167ed..7d5b7ec4 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import logging import platform from collections.abc import Coroutine, Iterable @@ -681,10 +682,14 @@ async def async_request_active_window(self, duration: float) -> bool: async with self._start_stop_lock: self._scan_mode_override = BluetoothScanningMode.ACTIVE try: - await self._async_stop_scanner() - await self._async_start() + await self._async_stop_then_start_under_lock() except ScannerStartError: + # ACTIVE start failed; try to bring the scanner back up + # in its underlying AUTO/passive mode so we don't leave + # it stopped. self._scan_mode_override = None + with contextlib.suppress(ScannerStartError): + await self._async_stop_then_start_under_lock() return False if self.current_mode is not BluetoothScanningMode.ACTIVE: # Linux's 4th-attempt fallback silently drops to PASSIVE. @@ -708,8 +713,7 @@ async def _async_end_active_window(self) -> None: if not self.scanning: return try: - await self._async_stop_scanner() - await self._async_start() + await self._async_stop_then_start_under_lock() except ScannerStartError as ex: _LOGGER.warning( "%s: Failed to restart scanner after active window: %s", @@ -717,6 +721,11 @@ async def _async_end_active_window(self) -> None: ex, ) + async def _async_stop_then_start_under_lock(self) -> None: + """Stop and restart the BleakScanner; caller holds _start_stop_lock.""" + await self._async_stop_scanner() + await self._async_start() + async def _async_stop_scanner(self) -> None: """Stop bluetooth discovery under the lock.""" self.scanning = False From da771a043ccb7c2596887f54ebbccf722a2de181 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 10:25:48 -0500 Subject: [PATCH 23/75] perf(manager): keep typed on_advertisement dispatch via cython.locals cast Storing _auto_scheduler as a typed cdef public AutoScanScheduler field triggers Cython's type-import path during manager init, which on macOS collides with the partial init of base_scanner and surfaces as KeyError: '__pyx_vtable__'. Drop the typed field and cast through a cython.locals AutoScanScheduler local in _scanner_adv_received instead; the call is still a direct vtable dispatch, but the typed cross-module reference only fires from inside the hot method, not at module init time. --- src/habluetooth/manager.pxd | 10 ++++++++-- src/habluetooth/manager.py | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/habluetooth/manager.pxd b/src/habluetooth/manager.pxd index b3dcafc3..d7e85cf3 100644 --- a/src/habluetooth/manager.pxd +++ b/src/habluetooth/manager.pxd @@ -70,7 +70,12 @@ cdef class BluetoothManager: cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl - cdef public AutoScanScheduler _auto_scheduler + # _auto_scheduler stays untyped to avoid a typed cdef field that + # triggers Cython's type-import path during manager init; the hot + # path casts to AutoScanScheduler via cython.locals on + # _scanner_adv_received so the call into on_advertisement is still + # a direct vtable dispatch. + cdef public object _auto_scheduler @cython.locals(stale_seconds=double) cdef bint _prefer_previous_adv_from_different_source( @@ -105,7 +110,8 @@ cdef class BluetoothManager: connectable_scanner=BaseHaScanner, apple_cstr="const unsigned char *", bleak_callback=BleakCallback, - cached_name=str + cached_name=str, + auto_scheduler=AutoScanScheduler, ) cdef void _scanner_adv_received(self, BluetoothServiceInfoBleak service_info) diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 77b11da3..20bee680 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -887,7 +887,11 @@ def _scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None bleak_callback, service_info.device, advertisement_data ) - self._auto_scheduler.on_advertisement(service_info) + # Local-typed assignment so cython.locals casts to AutoScanScheduler + # and the call below is a direct vtable dispatch even though + # _auto_scheduler is stored untyped on BluetoothManager. + auto_scheduler = self._auto_scheduler + auto_scheduler.on_advertisement(service_info) self._subclass_discover_info(service_info) def async_clear_advertisement_history(self, address: str) -> None: From 4e467b9eba766e72b0c78d28be1e8838c6b12fa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 10:40:51 -0500 Subject: [PATCH 24/75] test(auto): cover new AUTO scheduler / scanner code paths Add coverage for the new branches landed in this PR: - async_register_active_scan ValueError guards for non-positive scan_interval and negative scan_duration - HaScanner.async_request_active_window recovery after ScannerStartError brings the scanner back up in its underlying AUTO/passive mode - HaScanner.async_request_active_window PASSIVE-fallback detection reports False when current_mode does not reach ACTIVE - HaScanner._async_end_active_window logs a warning when the restore restart raises - AutoScanScheduler._run_window swallows exceptions from async_request_active_window and the sweep clock still advances - _dispatch_per_device does not resurrect a request that was cancelled while the window was awaiting - _dispatch_per_device skips an address owned by a different scanner - _next_event_at returns the current _window_end while a window is in flight --- tests/test_auto_scheduler.py | 116 +++++++++++++++++++++++++++++++ tests/test_scanner.py | 130 +++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 208c779b..895ac166 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -488,3 +488,119 @@ async def test_on_advertisement_wakes_owning_worker() -> None: finally: cancel() register_cancel() + + +@pytest.mark.asyncio +async def test_register_active_scan_validates_inputs() -> None: + """Invalid scan_interval / scan_duration raise ValueError.""" + manager = get_manager() + with pytest.raises(ValueError, match="scan_interval must be > 0"): + manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=0) + with pytest.raises(ValueError, match="scan_interval must be > 0"): + manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=-1) + with pytest.raises(ValueError, match="scan_duration must be None or >= 0"): + manager.async_register_active_scan( + "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=-0.5 + ) + + +@pytest.mark.asyncio +async def test_run_window_swallows_scanner_exception() -> None: + """An exception from async_request_active_window is logged, not re-raised.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + class _FailingScanner(_RecordingAutoScanner): + async def async_request_active_window(self, duration: float) -> bool: + raise RuntimeError("boom") + + scanner = _FailingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + await worker._tick() + # The exception was swallowed; sweep state still advanced. + assert worker._sweep_last_completed > loop.time() - 1.0 + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_does_not_resurrect_cancelled_request() -> None: + """A request cancelled while the window awaits is not re-added to entries.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=3.0 + ) + + gate = asyncio.Event() + + class _CancelDuringWindow(_RecordingAutoScanner): + async def async_request_active_window(self, duration: float) -> bool: + # Mid-window: caller cancels the registration. + cancel() + gate.set() + return True + + scanner = _CancelDuringWindow("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + request = next(iter(entries)) + entries[request] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + await gate.wait() + # remove_request emptied the bucket; the tick must not have + # re-added the cancelled request. + assert address not in sched._needs + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_skips_address_owned_by_other_scanner() -> None: + """An address whose owner is a different scanner is left alone.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + owner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + other = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO) + c1 = manager.async_register_scanner(owner) + c2 = manager.async_register_scanner(other) + try: + _inject(owner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + # The "other" scanner runs its tick. The address is owned by + # owner, so other should not fire its window. + await sched._workers[other.source]._tick() + assert other.active_window_calls == [] + finally: + cancel() + c1() + c2() + + +@pytest.mark.asyncio +async def test_next_event_at_returns_current_window_end() -> None: + """While a window is in flight, next event is its end time.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._window_end = loop.time() + 42.0 + assert worker._next_event_at(loop.time()) == worker._window_end + finally: + register_cancel() diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 767f2a3e..2add1297 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1,6 +1,7 @@ """Tests for the Bluetooth integration scanners.""" import asyncio +import logging import platform import time from datetime import timedelta @@ -1851,3 +1852,132 @@ def register_detection_callback(self, callback): assert scanner._active_window_handle is None assert scanner._scan_mode_override is None # type: ignore[unreachable] assert scanner._active_window_end == 0.0 + + +@pytest.mark.asyncio +async def test_async_request_active_window_recovers_on_start_failure() -> None: + """If the ACTIVE restart raises, recovery brings the scanner back up.""" + call_count = 0 + fail_until = 0 + + class MockBleakScanner: + async def start(self): + nonlocal call_count + call_count += 1 + if call_count <= fail_until: + raise BleakError("simulated start failure") + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + before = call_count + # Fail the next 4 start attempts so the ACTIVE swap raises; + # then succeed so the recovery restart can come back up. + fail_until = call_count + 4 + result = await scanner.async_request_active_window(1.0) + assert result is False + assert scanner._scan_mode_override is None + # Recovery restart happened after the failure path. + assert call_count > before + 4 + await scanner.async_stop() + + +@pytest.mark.asyncio +async def test_async_request_active_window_detects_passive_fallback() -> None: + """If current_mode does not reach ACTIVE the request returns False.""" + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Patch set_current_mode so the swap's restart does NOT reach + # ACTIVE, mimicking the Linux 4th-attempt PASSIVE fallback path. + original_set_current_mode = type(scanner).set_current_mode + + def _stay_passive(self, mode): + original_set_current_mode(self, BluetoothScanningMode.PASSIVE) + + with patch.object(type(scanner), "set_current_mode", _stay_passive): + result = await scanner.async_request_active_window(1.0) + assert result is False + assert scanner._scan_mode_override is None + await scanner.async_stop() + + +@pytest.mark.asyncio +async def test_async_end_active_window_handles_start_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """ScannerStartError during the end-of-window restart logs a warning.""" + starts = 0 + fail_until = 0 + + class MockBleakScanner: + async def start(self): + nonlocal starts + starts += 1 + if starts <= fail_until: + raise BleakError("simulated end-window failure") + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Open a long active window then drive end-of-window with the + # bleak start mocked to fail. + await scanner.async_request_active_window(3600.0) + assert scanner._active_window_handle is not None + fail_until = starts + 4 + scanner._active_window_handle.cancel() + scanner._active_window_handle = None + caplog.clear() + with caplog.at_level(logging.WARNING): + await scanner._async_end_active_window() + assert any( + "Failed to restart scanner after active window" in record.message + for record in caplog.records + ) From ee07cf26be42ddfcf53aaa50a5c822fb9d9d13f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 10:51:31 -0500 Subject: [PATCH 25/75] test(auto): drive auto_scheduler coverage to 100% and expand coalesce cases Cover every remaining branch in auto_scheduler.py: - _next_event_at: in-flight window, per-device earliest lowering next_at, per-device entries later than the sweep cadence not lowering next_at, empty entries and foreign-source entries skipped - _run loop: early-return paths when loop is None and when the scheduler stops between iterations - _tick / _dispatch_per_device / _dispatch_sweep: loop-None, empty-entries, not-yet-due, sweep-not-due, sweep_lock missing, re-check after acquiring the sweep lock when another worker advanced the clock - start() ignores non-AUTO scanners on the manager - add_request paths with and without an address in _all_history - remove_request when the bucket was never populated - on_advertisement no-match, all-tracked, and existing-entry skip branches inside the for-loop - _wake_worker no-op when the source has no worker Add Inkbird-flavored coalesce scenarios: - three Inkbirds with the same address coalesce into one 15s window - three Inkbirds with different addresses each get their own 15s window - removing one of three same-address registrations leaves the remaining two requests still asking for 15s so the window does not shrink - duration clamping above MAX, None scan_duration falling back to MIN, and only-due requests contributing to the coalesced duration Drop the broken Linux PASSIVE-fallback test that tried to patch a cython cdef class method (set_current_mode is immutable on the HaScanner type). --- tests/test_auto_scheduler.py | 717 +++++++++++++++++++++++++++++++++++ tests/test_scanner.py | 39 -- 2 files changed, 717 insertions(+), 39 deletions(-) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 895ac166..109dbaeb 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib from collections.abc import Iterable import pytest @@ -12,6 +13,7 @@ from habluetooth import ( BaseHaScanner, BluetoothScanningMode, + BluetoothServiceInfoBleak, get_manager, ) from habluetooth.auto_scheduler import ActiveScanRequest @@ -604,3 +606,718 @@ async def test_next_event_at_returns_current_window_end() -> None: assert worker._next_event_at(loop.time()) == worker._window_end finally: register_cancel() + + +@pytest.mark.asyncio +async def test_next_event_at_returns_earliest_per_device_need() -> None: + """Per-device entries owned by this scanner influence the next-event time.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan(address, scan_interval=120.0) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + worker = sched._workers[scanner.source] + # Sweep is far in the future (initial delay window). The earliest + # event for the worker is the per-device next-due. + entries = sched._needs[address] + request = next(iter(entries)) + per_device_at = loop.time() + 5.0 + entries[request] = per_device_at + assert worker._next_event_at(loop.time()) == per_device_at + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_next_event_at_ignores_empty_or_foreign_entries() -> None: + """Empty entry dicts and entries owned by other scanners don't lower next-event.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + # Empty entries: hits the "if not entries: continue" branch. + sched._needs["aa:bb:cc:dd:ee:01"] = {} + # No history at all: hits "if history is None or history.source != source". + cancel = manager.async_register_active_scan( + "aa:bb:cc:dd:ee:02", scan_interval=60.0 + ) + request = next(iter(sched._requests_by_address["aa:bb:cc:dd:ee:02"])) + sched._needs["aa:bb:cc:dd:ee:02"] = {request: loop.time() - 1.0} + next_at = worker._next_event_at(loop.time()) + # With no contributing per-device entries the next event reverts + # to the sweep cadence (well into the future via initial delay). + assert next_at == worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + cancel() + del sched._needs["aa:bb:cc:dd:ee:01"] + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_per_device_skips_empty_entries() -> None: + """An address whose entries dict is empty is skipped (no del, no fire).""" + manager = get_manager() + sched = manager._auto_scheduler + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + sched._needs["aa:bb:cc:dd:ee:ff"] = {} + await sched._workers[scanner.source]._tick() + assert scanner.active_window_calls == [] + del sched._needs["aa:bb:cc:dd:ee:ff"] + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_per_device_skips_not_yet_due() -> None: + """Entries with future due times don't fire.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan(address, scan_interval=120.0) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + # Push the due time far in the future. + entries = sched._needs[address] + for request in list(entries): + entries[request] = loop.time() + 1000.0 + await sched._workers[scanner.source]._tick() + assert scanner.active_window_calls == [] + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_sweep_returns_when_not_due() -> None: + """The sweep dispatch returns immediately if the cadence is not reached.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + # Sweep is well in the future; _dispatch_sweep should be a no-op. + worker._sweep_last_completed = loop.time() + await worker._dispatch_sweep() + assert scanner.active_window_calls == [] + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_sweep_re_checks_after_acquiring_lock() -> None: + """If another worker advances the clock while we wait on the lock, we bail.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + sweep_lock = sched._sweep_lock + assert sweep_lock is not None + # Pre-acquire the lock and bump the sweep clock so the re-check + # inside _dispatch_sweep returns early. + await sweep_lock.acquire() + try: + task = asyncio.create_task(worker._dispatch_sweep()) + await asyncio.sleep(0) + # Move the worker's clock forward so the in-lock re-check fails. + worker._sweep_last_completed = loop.time() + finally: + sweep_lock.release() + await task + assert scanner.active_window_calls == [] + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_worker_tick_no_op_when_loop_detached() -> None: + """Workers exit cleanly if the scheduler's loop is None.""" + manager = get_manager() + sched = manager._auto_scheduler + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + original_loop = sched._loop + sched._loop = None + try: + # All three dispatch entry points must short-circuit when + # the loop is gone. + await worker._tick() + await worker._dispatch_per_device() + await worker._dispatch_sweep() + finally: + sched._loop = original_loop + assert scanner.active_window_calls == [] + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_sweep_returns_when_sweep_lock_missing() -> None: + """_dispatch_sweep is a no-op when the scheduler has no sweep_lock.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + original_lock = sched._sweep_lock + sched._sweep_lock = None + try: + await worker._dispatch_sweep() + finally: + sched._sweep_lock = original_lock + assert scanner.active_window_calls == [] + finally: + register_cancel() + + +async def _replace_worker_task(worker: object) -> None: + """Cancel the worker's existing task so a fresh _run() can be tested.""" + task = worker._task # type: ignore[attr-defined] + if task is not None and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_run_exits_when_scheduler_not_running() -> None: + """The worker's _run loop exits cleanly when _running is False after a wake.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + await _replace_worker_task(worker) + # Put the sweep clock far in the past so _next_event_at returns + # a time <= now and the wait_for branch is skipped; the loop + # falls straight through to the "not running" check. + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + sched._running = False + new_task = loop.create_task(worker._run()) + await asyncio.wait_for(new_task, timeout=1.0) + assert new_task.done() and not new_task.cancelled() + finally: + sched._running = True + register_cancel() + + +@pytest.mark.asyncio +async def test_run_exits_when_loop_detached() -> None: + """The worker's _run loop exits when scheduler._loop becomes None.""" + manager = get_manager() + sched = manager._auto_scheduler + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + await _replace_worker_task(worker) + original_loop = sched._loop + sched._loop = None + new_task = asyncio.get_running_loop().create_task(worker._run()) + await asyncio.wait_for(new_task, timeout=1.0) + assert new_task.done() and not new_task.cancelled() + sched._loop = original_loop + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_add_request_without_history_does_not_wake() -> None: + """When the address has never been seen, add_request is a pure registry op.""" + manager = get_manager() + sched = manager._auto_scheduler + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._wake.clear() + cancel = manager.async_register_active_scan( + "AA:AA:AA:AA:AA:AA", scan_interval=60.0 + ) + # No prior advertisement: history is None, so no wake is sent. + assert not worker._wake.is_set() + cancel() + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_remove_request_handles_missing_bucket() -> None: + """remove_request tolerates a request whose bucket is already gone.""" + manager = get_manager() + sched = manager._auto_scheduler + request = ActiveScanRequest("AA:BB:CC:DD:EE:99", 60.0, None) + # Bucket was never added; remove_request must be a no-op. + sched.remove_request(request) + assert "AA:BB:CC:DD:EE:99" not in sched._requests_by_address + assert "AA:BB:CC:DD:EE:99" not in sched._needs + + +@pytest.mark.asyncio +async def test_on_advertisement_no_match_no_wake() -> None: + """An ad whose address has no registered request doesn't add anything.""" + manager = get_manager() + sched = manager._auto_scheduler + cancel = manager.async_register_active_scan("11:22:33:44:55:66", scan_interval=60.0) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._wake.clear() + _inject(scanner, "AA:AA:AA:AA:AA:AA") + assert "AA:AA:AA:AA:AA:AA" not in sched._needs + assert not worker._wake.is_set() + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_on_advertisement_existing_entry_no_extra_wake() -> None: + """A second ad for a tracked address with multiple requests skips all.""" + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:66" + # Two registrations for the same address so the for-loop in + # on_advertisement iterates twice; both requests must be present + # in _needs after the first inject, so the second inject takes + # the request-in-existing branch on every iteration and added + # stays False. + cancel1 = manager.async_register_active_scan(address, scan_interval=60.0) + cancel2 = manager.async_register_active_scan(address, scan_interval=120.0) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + _inject(scanner, address) + worker._wake.clear() + _inject(scanner, address) + assert not worker._wake.is_set() + finally: + cancel1() + cancel2() + register_cancel() + + +@pytest.mark.asyncio +async def test_on_advertisement_with_all_requests_already_tracked() -> None: + """Direct exercise of the existing-entries skip path inside the for-loop.""" + manager = get_manager() + sched = manager._auto_scheduler + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + address = "11:22:33:44:55:66" + # Build two requests in the registry directly so we know exactly + # what's in _requests_by_address; pre-populate _needs with both so + # on_advertisement's for-loop iterates twice and skips both. + req_a = ActiveScanRequest(address, 60.0, None) + req_b = ActiveScanRequest(address, 120.0, None) + sched._requests_by_address[address] = {req_a, req_b} + sched._needs[address] = {req_a: 0.0, req_b: 0.0} + try: + # Drive on_advertisement directly; both requests are present so + # added stays False and the wake path is skipped. + si = BluetoothServiceInfoBleak( + name="x", + address=address, + rssi=-50, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source=scanner.source, + device=generate_ble_device(address, "x"), + advertisement=generate_advertisement_data(local_name="x"), + connectable=True, + time=asyncio.get_running_loop().time(), + tx_power=None, + raw=None, + ) + worker = sched._workers[scanner.source] + worker._wake.clear() + sched.on_advertisement(si) + assert not worker._wake.is_set() + # Sanity: the entries we put in are untouched. + assert sched._needs[address] == {req_a: 0.0, req_b: 0.0} + finally: + sched._requests_by_address.pop(address, None) + sched._needs.pop(address, None) + register_cancel() + + +@pytest.mark.asyncio +async def test_add_request_with_history_wakes_owning_worker() -> None: + """add_request wakes the worker whose scanner currently sees the address.""" + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:66" + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + # Populate manager._all_history WITHOUT first registering an + # active scan, so the inject doesn't go through on_advertisement's + # wake-on-added path. add_request then sees the history entry + # and fires _wake_worker itself. + _inject(scanner, address) + worker = sched._workers[scanner.source] + worker._wake.clear() + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + assert worker._wake.is_set() + cancel() + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_next_event_at_skips_per_device_later_than_sweep() -> None: + """A per-device next-due later than the sweep cadence does not lower next_at.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + worker = sched._workers[scanner.source] + sweep_at = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + # Push per-device need past the sweep cadence so the earliest < + # next_at branch is False inside _next_event_at. + for req in list(sched._needs[address]): + sched._needs[address][req] = sweep_at + 100.0 + assert worker._next_event_at(loop.time()) == sweep_at + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_start_ignores_non_auto_scanner() -> None: + """A non-AUTO scanner already on the manager doesn't get a worker on start.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + auto = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + active = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.ACTIVE) + c_auto = manager.async_register_scanner(auto) + c_active = manager.async_register_scanner(active) + try: + assert active.source not in sched._workers + # Re-run start() so the False branch (non-AUTO scanner) of the + # `if scanner.requested_mode is AUTO` check inside start() is hit. + # First shut down the worker tasks the existing start() already + # spawned so we don't leak. + for worker in list(sched._workers.values()): + await _replace_worker_task(worker) + sched._workers.clear() + sched.start(loop) + assert auto.source in sched._workers + assert active.source not in sched._workers + finally: + c_auto() + c_active() + + +@pytest.mark.asyncio +async def test_wake_worker_without_worker_is_no_op() -> None: + """_wake_worker tolerates being called for an unknown source.""" + manager = get_manager() + sched = manager._auto_scheduler + # No scanner registered for this source; should silently no-op. + sched._wake_worker("AA:AA:AA:AA:AA:AA") + + +@pytest.mark.asyncio +async def test_coalesce_three_due_uses_max_clamped() -> None: + """Three due requests on one address fire one window using max duration.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + c1 = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=2.0 + ) + c2 = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=4.0 + ) + c3 = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=9.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + assert scanner.active_window_calls == [9.0] + finally: + c1() + c2() + c3() + register_cancel() + + +@pytest.mark.asyncio +async def test_coalesce_clamps_oversize_request() -> None: + """A scan_duration above the max is clamped on dispatch.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=AUTO_WINDOW_MAX_DURATION + 50.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + assert scanner.active_window_calls == [AUTO_WINDOW_MAX_DURATION] + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_coalesce_none_duration_uses_min() -> None: + """An unspecified scan_duration falls back to the configured minimum.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + assert scanner.active_window_calls == [AUTO_WINDOW_MIN_DURATION] + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_coalesce_only_due_requests_count() -> None: + """Only the requests that are actually due contribute to coalesced duration.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + c_short = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=2.0 + ) + c_long = manager.async_register_active_scan( + address, scan_interval=300.0, scan_duration=20.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + short_req = next(r for r in entries if r.scan_duration == 2.0) + long_req = next(r for r in entries if r.scan_duration == 20.0) + # Only the short request is due; the long one is well in the + # future and must not pull its bigger duration into the window. + entries[short_req] = loop.time() - 1.0 + entries[long_req] = loop.time() + 200.0 + await sched._workers[scanner.source]._tick() + assert scanner.active_window_calls == [2.0] + finally: + c_short() + c_long() + register_cancel() + + +@pytest.mark.asyncio +async def test_coalesce_distinct_addresses_fire_separately() -> None: + """Two due addresses on the same scanner each get their own window.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + addr_a = "11:22:33:44:55:01" + addr_b = "11:22:33:44:55:02" + c1 = manager.async_register_active_scan( + addr_a, scan_interval=60.0, scan_duration=3.0 + ) + c2 = manager.async_register_active_scan( + addr_b, scan_interval=60.0, scan_duration=7.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, addr_a) + _inject(scanner, addr_b) + for address in (addr_a, addr_b): + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + # Each address gets its own window since coalescing is per-address. + assert sorted(scanner.active_window_calls) == [3.0, 7.0] + finally: + c1() + c2() + register_cancel() + + +@pytest.mark.asyncio +async def test_three_inkbirds_share_one_scan() -> None: + """ + Three Inkbirds at the same 5min / 15s cadence share a single window. + + Each Inkbird has its own address so it gets its own per-address bucket + in _needs, but all three are owned by the same scanner and become due + at the same time. The worker iterates the addresses and fires three + back-to-back 15s windows, never overlapping and each clamped to the + requested duration. The point of this test is to confirm that + independent identical registrations don't accidentally turn into + 9 separate windows (3 per address); the coalesce step inside + _coalesce_duration must pick max(15s) once per address. + """ + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + addresses = ["C0:01:01:11:11:11", "C0:01:01:22:22:22", "C0:01:01:33:33:33"] + cancels = [ + manager.async_register_active_scan( + addr, scan_interval=300.0, scan_duration=15.0 + ) + for addr in addresses + ] + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + for addr in addresses: + _inject(scanner, addr) + entries = sched._needs[addr] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + # One 15s window per device, none missed, none duplicated. + assert scanner.active_window_calls == [15.0, 15.0, 15.0] + # Next-due moved forward by scan_interval for every request. + for addr in addresses: + for due in sched._needs[addr].values(): + assert due > loop.time() + 250.0 + finally: + for cancel in cancels: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_three_inkbirds_same_address_coalesce_to_one_scan() -> None: + """ + Three Inkbird-style registrations on the same address share one window. + + Realistic case: three integrations each register their own callback + for the same Inkbird; the scheduler must NOT fire 3 separate 15s + windows back-to-back. Instead all three requests coalesce into one + single 15s window via _coalesce_duration's max-of-durations. + """ + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "C0:01:01:11:11:11" + cancels = [ + manager.async_register_active_scan( + address, scan_interval=300.0, scan_duration=15.0 + ) + for _ in range(3) + ] + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + assert len(entries) == 3 + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + # All three coalesced into a single 15s window. + assert scanner.active_window_calls == [15.0] + # Each request's next-due advanced by its own scan_interval. + for due in entries.values(): + assert due > loop.time() + 250.0 + finally: + for cancel in cancels: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_three_inkbirds_window_unchanged_after_removal() -> None: + """ + Removing one of three same-address registrations preserves the window. + + All three asked for the same 15s duration so the coalesced window is + 15s. Cancelling one of them leaves two requests still asking for + 15s; the resulting window must still be 15s, not regress to the + MIN_DURATION floor. + """ + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "C0:01:01:11:11:11" + cancels = [ + manager.async_register_active_scan( + address, scan_interval=300.0, scan_duration=15.0 + ) + for _ in range(3) + ] + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + assert len(entries) == 3 + # Cancel one of the three; two should remain in both the registry + # and the _needs tracker. + cancels.pop()() + assert len(sched._requests_by_address[address]) == 2 + entries = sched._needs[address] + assert len(entries) == 2 + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + # Window duration is unchanged because the remaining two still + # ask for 15s; coalesce takes the max. + assert scanner.active_window_calls == [15.0] + finally: + for cancel in cancels: + cancel() + register_cancel() diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 2add1297..00bc32bf 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1896,45 +1896,6 @@ def register_detection_callback(self, callback): await scanner.async_stop() -@pytest.mark.asyncio -async def test_async_request_active_window_detects_passive_fallback() -> None: - """If current_mode does not reach ACTIVE the request returns False.""" - - class MockBleakScanner: - async def start(self): - pass - - async def stop(self): - pass - - @property - def discovered_devices(self): - return [] - - def register_detection_callback(self, callback): - pass - - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - # Patch set_current_mode so the swap's restart does NOT reach - # ACTIVE, mimicking the Linux 4th-attempt PASSIVE fallback path. - original_set_current_mode = type(scanner).set_current_mode - - def _stay_passive(self, mode): - original_set_current_mode(self, BluetoothScanningMode.PASSIVE) - - with patch.object(type(scanner), "set_current_mode", _stay_passive): - result = await scanner.async_request_active_window(1.0) - assert result is False - assert scanner._scan_mode_override is None - await scanner.async_stop() - - @pytest.mark.asyncio async def test_async_end_active_window_handles_start_error( caplog: pytest.LogCaptureFixture, From d419a9bf525b4867cf329a9475318637e978c50f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 11:03:26 -0500 Subject: [PATCH 26/75] test(scanner): cover BaseHaScanner default + HaScanner end-window edge cases - BaseHaScanner.async_request_active_window: default no-op returns False and logs the unsupported-scanner debug line. - HaScanner._async_end_active_window: defer when a new window already re-armed the handle, and skip the restart when the scanner was stopped while the window was open. - HaScanner.async_request_active_window: on Linux's 4th-attempt PASSIVE fallback the request reports False and clears the override. Also revert the __init__.py isort:off block now that the real macOS fix (untyped _auto_scheduler field plus cython.locals cast in _scanner_adv_received) holds the load order on its own. --- src/habluetooth/__init__.py | 8 +- tests/test_scanner.py | 159 ++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 7 deletions(-) diff --git a/src/habluetooth/__init__.py b/src/habluetooth/__init__.py index 08485336..7295eac5 100644 --- a/src/habluetooth/__init__.py +++ b/src/habluetooth/__init__.py @@ -2,18 +2,11 @@ from bleak_retry_connector import Allocations -# isort: off -# Order matters: manager must finish initializing before base_scanner -# imports it, otherwise macOS Cython hits KeyError: '__pyx_vtable__' -# resolving BluetoothManager from a partially-initialized module. from .advertisement_tracker import ( TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) -from .manager import BluetoothManager from .base_scanner import BaseHaRemoteScanner, BaseHaScanner - -# isort: on from .central_manager import get_manager, set_manager from .const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -22,6 +15,7 @@ SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) +from .manager import BluetoothManager from .models import ( BluetoothServiceInfo, BluetoothServiceInfoBleak, diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 00bc32bf..8f5a3a50 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1896,6 +1896,165 @@ def register_detection_callback(self, callback): await scanner.async_stop() +@pytest.mark.asyncio +async def test_base_scanner_default_active_window_is_noop( + caplog: pytest.LogCaptureFixture, +) -> None: + """BaseHaScanner.async_request_active_window default returns False.""" + from collections.abc import Iterable + + from bleak.backends.device import BLEDevice + from bleak.backends.scanner import AdvertisementData + + from habluetooth import BaseHaScanner + + class _PlainScanner(BaseHaScanner): + @property + def discovered_devices(self) -> list[BLEDevice]: + return [] + + @property + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + return {} + + def get_discovered_device_advertisement_data( + self, address: str + ) -> tuple[BLEDevice, AdvertisementData] | None: + return None + + @property + def discovered_addresses(self) -> Iterable[str]: + return () + + scanner = _PlainScanner("AA:BB:CC:DD:EE:FF", "plain") + with caplog.at_level(logging.DEBUG, logger="habluetooth"): + result = await scanner.async_request_active_window(1.0) + assert result is False + assert any( + "does not support on-demand active windows" in record.message + for record in caplog.records + ) + + +@pytest.mark.asyncio +async def test_async_end_active_window_defers_to_new_window() -> None: + """If a new window armed the timer, the end-window task returns early.""" + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + await scanner.async_request_active_window(3600.0) + # Simulate a new window taking over by leaving the handle in place + # and call _async_end_active_window directly; it must short-circuit. + assert scanner._active_window_handle is not None + await scanner._async_end_active_window() + # Override and handle untouched because we deferred to the new window. + assert scanner._scan_mode_override == BluetoothScanningMode.ACTIVE + assert scanner._active_window_handle is not None + await scanner.async_stop() + + +@pytest.mark.asyncio +async def test_async_end_active_window_skips_when_not_scanning() -> None: + """If the scanner was stopped during the window the restart is skipped.""" + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + await scanner.async_request_active_window(3600.0) + # Pretend the end-window timer just fired (handle cleared) and the + # scanner was stopped in the meantime. + scanner._active_window_handle = None + scanner.scanning = False + # Should be a quick no-op: clears override, sees not scanning, returns. + await scanner._async_end_active_window() + assert scanner._scan_mode_override is None + scanner.scanning = True + await scanner.async_stop() + + +@pytest.mark.asyncio +async def test_async_request_active_window_passive_fallback_on_linux() -> None: + """If the swap restart falls back to PASSIVE on Linux, request returns False.""" + starts = 0 + + class MockBleakScanner: + async def start(self): + nonlocal starts + starts += 1 + # Fail the first three attempts so the 4th-attempt PASSIVE + # fallback inside _async_start_attempt kicks in. + if 2 <= starts <= 4: + raise BleakError("simulated active failure") + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with ( + patch("habluetooth.scanner.IS_LINUX", True), + patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ), + patch("habluetooth.scanner.async_reset_adapter", AsyncMock()), + patch("habluetooth.scanner.ADAPTER_INIT_TIME", 0), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + result = await scanner.async_request_active_window(1.0) + # The swap ran through to the 4th attempt and fell back to PASSIVE; + # the request reports False because the scanner is not ACTIVE. + assert result is False + assert scanner._scan_mode_override is None + await scanner.async_stop() + + @pytest.mark.asyncio async def test_async_end_active_window_handles_start_error( caplog: pytest.LogCaptureFixture, From 9069dc976c20dca24815c79296e3070e4c75ecef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 11:13:42 -0500 Subject: [PATCH 27/75] feat(auto): add is_tracking, mark_due, mark_sweep_due, async_tick APIs The cython cdef methods on AutoScanScheduler exposed for cold-path use by tests and external callers, replacing the test-side reach-ins into _needs / _workers / worker._sweep_last_completed: - is_tracking(address) -> bool: read-only check whether an address has any active-scan tracking; intended as the public way to ask if the scheduler is doing anything for a device. - mark_due(address) -> bool: advance every tracked request for an address so the next tick fires its window now; wakes the worker that currently owns the address. Useful for forcing an immediate active scan when a device-specific event arrives. - mark_sweep_due(source) -> bool: pull the source's global rediscovery sweep clock far enough back that the next tick runs it. Hook for manager-side flows that want a sweep on demand without rewriting the worker's internal clock from the outside. - async_tick(source): drive a single processing tick for the scanner. Lets callers force the scheduler to evaluate pending work without waiting for the worker's sleep to expire. Drop the _run_worker_tick test helper and the corresponding _workers[source]._tick(), worker._sweep_last_completed assignments, and `entries = sched._needs[address]; for req in entries: entries[req] = ...` patterns from the suite in favor of the new public APIs. The remaining direct _needs / _workers accesses are read-only inspections the tests still need to assert on next-due times. --- src/habluetooth/auto_scheduler.pxd | 6 ++ src/habluetooth/auto_scheduler.py | 52 +++++++++++ tests/test_auto_scheduler.py | 145 +++++++++++++++++------------ 3 files changed, 144 insertions(+), 59 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 9dfe0407..1497862c 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -46,3 +46,9 @@ cdef class AutoScanScheduler: cpdef void start(self, object loop) cpdef void stop(self) + + cpdef bint is_tracking(self, str address) + + cpdef bint mark_due(self, str address) + + cpdef bint mark_sweep_due(self, str source) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 8c72cdfa..457687c1 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -313,6 +313,58 @@ def _wake_worker(self, source: str) -> None: if (worker := self._workers.get(source)) is not None: worker.wake() + def is_tracking(self, address: str) -> bool: + """Return whether ``address`` currently has any registered tracking.""" + return address in self._needs + + def mark_due(self, address: str) -> bool: + """ + Force any tracked requests for ``address`` to fire on the next tick. + + Returns True if there were entries to advance, False otherwise. Wakes + the worker that currently owns the address so the tick fires now. + """ + entries = self._needs.get(address) + if not entries or self._loop is None: + return False + now = self._loop.time() + for request in entries: + entries[request] = now + history = self._manager._all_history.get(address) + if history is not None: + self._wake_worker(history.source) + return True + + def mark_sweep_due(self, source: str) -> bool: + """ + Force the global rediscovery sweep on ``source`` to be due now. + + Returns True if the source has a worker and the sweep clock was + advanced; False otherwise. Used both as a test helper and as a hook + for ``BluetoothManager.async_rediscover_address`` style flows that + want an immediate sweep on a specific scanner. + """ + worker = self._workers.get(source) + if worker is None or self._loop is None: + return False + worker._sweep_last_completed = ( + self._loop.time() - _AUTO_REDISCOVERY_INTERVAL - 1.0 + ) + worker.wake() + return True + + async def async_tick(self, source: str) -> None: + """ + Drive a single processing tick for ``source`` synchronously. + + Useful when a caller wants the scheduler to evaluate pending work + immediately instead of waiting for the worker's next sleep to + expire (and the primary entry point tests use to advance state + without poking the worker's internals). + """ + if (worker := self._workers.get(source)) is not None: + await worker._tick() + def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """Pick the max requested duration, clamped to the configured range.""" requested = max( diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 109dbaeb..919b9be7 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -94,12 +94,6 @@ async def _drain() -> None: await asyncio.sleep(0) -async def _run_worker_tick(scheduler: object, source: str) -> None: - """Drive one worker through a single tick for deterministic testing.""" - worker = scheduler._workers[source] # type: ignore[attr-defined] - await worker._tick() - - @pytest.mark.asyncio async def test_advertisement_starts_tracking() -> None: """A matching address advertisement creates a per-(address, request) entry.""" @@ -112,7 +106,7 @@ async def test_advertisement_starts_tracking() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, "11:22:33:44:55:66") - assert "11:22:33:44:55:66" in sched._needs + assert sched.is_tracking("11:22:33:44:55:66") finally: cancel() register_cancel() @@ -153,7 +147,7 @@ async def test_worker_tick_fires_active_window() -> None: entries = sched._needs["11:22:33:44:55:66"] request = next(iter(entries)) entries[request] = loop.time() - 1.0 - await _run_worker_tick(sched, scanner.source) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [5.0] assert entries[request] > loop.time() finally: @@ -166,7 +160,6 @@ async def test_worker_tick_coalesces_overlapping_requests() -> None: """Multiple requests for the same address coalesce on max scan_duration.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel1 = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=3.0 @@ -178,10 +171,8 @@ async def test_worker_tick_coalesces_overlapping_requests() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - entries = sched._needs[address] - for req in list(entries): - entries[req] = loop.time() - 1.0 - await _run_worker_tick(sched, scanner.source) + sched.mark_due(address) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [10.0] finally: cancel1() @@ -211,13 +202,13 @@ async def test_multiple_requests_same_address_track_independent_intervals() -> N fast, slow = sorted(entries, key=lambda r: r.scan_interval) entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() + 200.0 - await _run_worker_tick(sched, scanner.source) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [2.0] assert entries[fast] > loop.time() assert entries[slow] > loop.time() + 100 entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() - 1.0 - await _run_worker_tick(sched, scanner.source) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [2.0, 4.0] finally: cancel_fast() @@ -248,8 +239,8 @@ async def test_global_sweep_runs_on_auto_scanner() -> None: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 - await _run_worker_tick(sched, scanner.source) + sched.mark_sweep_due(scanner.source) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] assert worker._sweep_last_completed > loop.time() - 1.0 finally: @@ -303,9 +294,9 @@ async def test_remove_request_clears_tracking() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - assert address in sched._needs + assert sched.is_tracking(address) cancel() - assert address not in sched._needs + assert not sched.is_tracking(address) assert sched._requests_by_address == {} finally: register_cancel() @@ -316,15 +307,14 @@ async def test_failed_sweep_advances_sweep_last_completed() -> None: """A False return on a sweep advances the worker's sweep clock.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) scanner._return_value = False register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + sched.mark_sweep_due(scanner.source) before = worker._sweep_last_completed - await _run_worker_tick(sched, scanner.source) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] # Even on False, the worker's sweep clock advanced so the next # sweep is one full interval out instead of immediate. @@ -364,8 +354,8 @@ async def test_dispatch_drops_tracking_for_unseen_address() -> None: try: request = next(iter(sched._requests_by_address["aa:bb:cc:dd:ee:ff"])) sched._needs["aa:bb:cc:dd:ee:ff"] = {request: loop.time() - 1.0} - await _run_worker_tick(sched, scanner.source) - assert "aa:bb:cc:dd:ee:ff" not in sched._needs + await sched.async_tick(scanner.source) + assert not sched.is_tracking("aa:bb:cc:dd:ee:ff") finally: cancel() register_cancel() @@ -521,7 +511,7 @@ async def async_request_active_window(self, duration: float) -> bool: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + sched.mark_sweep_due(scanner.source) await worker._tick() # The exception was swallowed; sweep state still advanced. assert worker._sweep_last_completed > loop.time() - 1.0 @@ -556,11 +546,11 @@ async def async_request_active_window(self, duration: float) -> bool: entries = sched._needs[address] request = next(iter(entries)) entries[request] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) await gate.wait() # remove_request emptied the bucket; the tick must not have # re-added the cancelled request. - assert address not in sched._needs + assert not sched.is_tracking(address) finally: register_cancel() @@ -570,7 +560,6 @@ async def test_dispatch_skips_address_owned_by_other_scanner() -> None: """An address whose owner is a different scanner is left alone.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) owner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) @@ -579,12 +568,10 @@ async def test_dispatch_skips_address_owned_by_other_scanner() -> None: c2 = manager.async_register_scanner(other) try: _inject(owner, address) - entries = sched._needs[address] - for req in list(entries): - entries[req] = loop.time() - 1.0 + sched.mark_due(address) # The "other" scanner runs its tick. The address is owned by # owner, so other should not fire its window. - await sched._workers[other.source]._tick() + await sched.async_tick(other.source) assert other.active_window_calls == [] finally: cancel() @@ -670,7 +657,7 @@ async def test_dispatch_per_device_skips_empty_entries() -> None: register_cancel = manager.async_register_scanner(scanner) try: sched._needs["aa:bb:cc:dd:ee:ff"] = {} - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [] del sched._needs["aa:bb:cc:dd:ee:ff"] finally: @@ -693,7 +680,7 @@ async def test_dispatch_per_device_skips_not_yet_due() -> None: entries = sched._needs[address] for request in list(entries): entries[request] = loop.time() + 1000.0 - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [] finally: cancel() @@ -728,7 +715,7 @@ async def test_dispatch_sweep_re_checks_after_acquiring_lock() -> None: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + sched.mark_sweep_due(scanner.source) sweep_lock = sched._sweep_lock assert sweep_lock is not None # Pre-acquire the lock and bump the sweep clock so the re-check @@ -776,12 +763,11 @@ async def test_dispatch_sweep_returns_when_sweep_lock_missing() -> None: """_dispatch_sweep is a no-op when the scheduler has no sweep_lock.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + sched.mark_sweep_due(scanner.source) original_lock = sched._sweep_lock sched._sweep_lock = None try: @@ -875,7 +861,7 @@ async def test_remove_request_handles_missing_bucket() -> None: # Bucket was never added; remove_request must be a no-op. sched.remove_request(request) assert "AA:BB:CC:DD:EE:99" not in sched._requests_by_address - assert "AA:BB:CC:DD:EE:99" not in sched._needs + assert not sched.is_tracking("AA:BB:CC:DD:EE:99") @pytest.mark.asyncio @@ -890,7 +876,7 @@ async def test_on_advertisement_no_match_no_wake() -> None: worker = sched._workers[scanner.source] worker._wake.clear() _inject(scanner, "AA:AA:AA:AA:AA:AA") - assert "AA:AA:AA:AA:AA:AA" not in sched._needs + assert not sched.is_tracking("AA:AA:AA:AA:AA:AA") assert not worker._wake.is_set() finally: cancel() @@ -1057,7 +1043,6 @@ async def test_coalesce_three_due_uses_max_clamped() -> None: """Three due requests on one address fire one window using max duration.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" c1 = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=2.0 @@ -1072,10 +1057,8 @@ async def test_coalesce_three_due_uses_max_clamped() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - entries = sched._needs[address] - for req in list(entries): - entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + sched.mark_due(address) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [9.0] finally: c1() @@ -1089,7 +1072,6 @@ async def test_coalesce_clamps_oversize_request() -> None: """A scan_duration above the max is clamped on dispatch.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=AUTO_WINDOW_MAX_DURATION + 50.0 @@ -1098,10 +1080,8 @@ async def test_coalesce_clamps_oversize_request() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - entries = sched._needs[address] - for req in list(entries): - entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + sched.mark_due(address) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [AUTO_WINDOW_MAX_DURATION] finally: cancel() @@ -1113,17 +1093,14 @@ async def test_coalesce_none_duration_uses_min() -> None: """An unspecified scan_duration falls back to the configured minimum.""" manager = get_manager() sched = manager._auto_scheduler - loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - entries = sched._needs[address] - for req in list(entries): - entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + sched.mark_due(address) + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [AUTO_WINDOW_MIN_DURATION] finally: cancel() @@ -1154,7 +1131,7 @@ async def test_coalesce_only_due_requests_count() -> None: # future and must not pull its bigger duration into the window. entries[short_req] = loop.time() - 1.0 entries[long_req] = loop.time() + 200.0 - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) assert scanner.active_window_calls == [2.0] finally: c_short() @@ -1185,7 +1162,7 @@ async def test_coalesce_distinct_addresses_fire_separately() -> None: entries = sched._needs[address] for req in list(entries): entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) # Each address gets its own window since coalescing is per-address. assert sorted(scanner.active_window_calls) == [3.0, 7.0] finally: @@ -1226,7 +1203,7 @@ async def test_three_inkbirds_share_one_scan() -> None: entries = sched._needs[addr] for req in list(entries): entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) # One 15s window per device, none missed, none duplicated. assert scanner.active_window_calls == [15.0, 15.0, 15.0] # Next-due moved forward by scan_interval for every request. @@ -1267,7 +1244,7 @@ async def test_three_inkbirds_same_address_coalesce_to_one_scan() -> None: assert len(entries) == 3 for req in list(entries): entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) # All three coalesced into a single 15s window. assert scanner.active_window_calls == [15.0] # Each request's next-due advanced by its own scan_interval. @@ -1313,7 +1290,7 @@ async def test_three_inkbirds_window_unchanged_after_removal() -> None: assert len(entries) == 2 for req in list(entries): entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() + await sched.async_tick(scanner.source) # Window duration is unchanged because the remaining two still # ask for 15s; coalesce takes the max. assert scanner.active_window_calls == [15.0] @@ -1321,3 +1298,53 @@ async def test_three_inkbirds_window_unchanged_after_removal() -> None: for cancel in cancels: cancel() register_cancel() + + +@pytest.mark.asyncio +async def test_mark_due_no_tracking_returns_false() -> None: + """mark_due returns False when the address has no active-scan tracking.""" + manager = get_manager() + sched = manager._auto_scheduler + assert sched.mark_due("AA:AA:AA:AA:AA:AA") is False + + +@pytest.mark.asyncio +async def test_mark_due_without_history_does_not_wake() -> None: + """mark_due advances entries even when the address has no history yet.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + try: + # Populate _needs directly without going through on_advertisement so + # _all_history has no entry for the address. + request = next(iter(sched._requests_by_address[address])) + sched._needs[address] = {request: loop.time() + 1000.0} + worker = sched._workers[scanner.source] + worker._wake.clear() + assert sched.mark_due(address) is True + assert sched._needs[address][request] <= loop.time() + # No history -> no wake call. + assert not worker._wake.is_set() + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_mark_sweep_due_unknown_source_returns_false() -> None: + """mark_sweep_due returns False when the source has no worker.""" + manager = get_manager() + sched = manager._auto_scheduler + assert sched.mark_sweep_due("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") is False + + +@pytest.mark.asyncio +async def test_async_tick_unknown_source_is_no_op() -> None: + """async_tick silently returns when the source has no worker.""" + manager = get_manager() + sched = manager._auto_scheduler + await sched.async_tick("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") From d95c5993af20f61d208448c89178d5107521d8d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 11:15:29 -0500 Subject: [PATCH 28/75] Revert "feat(auto): add is_tracking, mark_due, mark_sweep_due, async_tick APIs" This reverts commit 9069dc976c20dca24815c79296e3070e4c75ecef. --- src/habluetooth/auto_scheduler.pxd | 6 -- src/habluetooth/auto_scheduler.py | 52 ----------- tests/test_auto_scheduler.py | 145 ++++++++++++----------------- 3 files changed, 59 insertions(+), 144 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 1497862c..9dfe0407 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -46,9 +46,3 @@ cdef class AutoScanScheduler: cpdef void start(self, object loop) cpdef void stop(self) - - cpdef bint is_tracking(self, str address) - - cpdef bint mark_due(self, str address) - - cpdef bint mark_sweep_due(self, str source) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 457687c1..8c72cdfa 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -313,58 +313,6 @@ def _wake_worker(self, source: str) -> None: if (worker := self._workers.get(source)) is not None: worker.wake() - def is_tracking(self, address: str) -> bool: - """Return whether ``address`` currently has any registered tracking.""" - return address in self._needs - - def mark_due(self, address: str) -> bool: - """ - Force any tracked requests for ``address`` to fire on the next tick. - - Returns True if there were entries to advance, False otherwise. Wakes - the worker that currently owns the address so the tick fires now. - """ - entries = self._needs.get(address) - if not entries or self._loop is None: - return False - now = self._loop.time() - for request in entries: - entries[request] = now - history = self._manager._all_history.get(address) - if history is not None: - self._wake_worker(history.source) - return True - - def mark_sweep_due(self, source: str) -> bool: - """ - Force the global rediscovery sweep on ``source`` to be due now. - - Returns True if the source has a worker and the sweep clock was - advanced; False otherwise. Used both as a test helper and as a hook - for ``BluetoothManager.async_rediscover_address`` style flows that - want an immediate sweep on a specific scanner. - """ - worker = self._workers.get(source) - if worker is None or self._loop is None: - return False - worker._sweep_last_completed = ( - self._loop.time() - _AUTO_REDISCOVERY_INTERVAL - 1.0 - ) - worker.wake() - return True - - async def async_tick(self, source: str) -> None: - """ - Drive a single processing tick for ``source`` synchronously. - - Useful when a caller wants the scheduler to evaluate pending work - immediately instead of waiting for the worker's next sleep to - expire (and the primary entry point tests use to advance state - without poking the worker's internals). - """ - if (worker := self._workers.get(source)) is not None: - await worker._tick() - def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """Pick the max requested duration, clamped to the configured range.""" requested = max( diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 919b9be7..109dbaeb 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -94,6 +94,12 @@ async def _drain() -> None: await asyncio.sleep(0) +async def _run_worker_tick(scheduler: object, source: str) -> None: + """Drive one worker through a single tick for deterministic testing.""" + worker = scheduler._workers[source] # type: ignore[attr-defined] + await worker._tick() + + @pytest.mark.asyncio async def test_advertisement_starts_tracking() -> None: """A matching address advertisement creates a per-(address, request) entry.""" @@ -106,7 +112,7 @@ async def test_advertisement_starts_tracking() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, "11:22:33:44:55:66") - assert sched.is_tracking("11:22:33:44:55:66") + assert "11:22:33:44:55:66" in sched._needs finally: cancel() register_cancel() @@ -147,7 +153,7 @@ async def test_worker_tick_fires_active_window() -> None: entries = sched._needs["11:22:33:44:55:66"] request = next(iter(entries)) entries[request] = loop.time() - 1.0 - await sched.async_tick(scanner.source) + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0] assert entries[request] > loop.time() finally: @@ -160,6 +166,7 @@ async def test_worker_tick_coalesces_overlapping_requests() -> None: """Multiple requests for the same address coalesce on max scan_duration.""" manager = get_manager() sched = manager._auto_scheduler + loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel1 = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=3.0 @@ -171,8 +178,10 @@ async def test_worker_tick_coalesces_overlapping_requests() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - sched.mark_due(address) - await sched.async_tick(scanner.source) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [10.0] finally: cancel1() @@ -202,13 +211,13 @@ async def test_multiple_requests_same_address_track_independent_intervals() -> N fast, slow = sorted(entries, key=lambda r: r.scan_interval) entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() + 200.0 - await sched.async_tick(scanner.source) + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [2.0] assert entries[fast] > loop.time() assert entries[slow] > loop.time() + 100 entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() - 1.0 - await sched.async_tick(scanner.source) + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [2.0, 4.0] finally: cancel_fast() @@ -239,8 +248,8 @@ async def test_global_sweep_runs_on_auto_scanner() -> None: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - sched.mark_sweep_due(scanner.source) - await sched.async_tick(scanner.source) + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] assert worker._sweep_last_completed > loop.time() - 1.0 finally: @@ -294,9 +303,9 @@ async def test_remove_request_clears_tracking() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - assert sched.is_tracking(address) + assert address in sched._needs cancel() - assert not sched.is_tracking(address) + assert address not in sched._needs assert sched._requests_by_address == {} finally: register_cancel() @@ -307,14 +316,15 @@ async def test_failed_sweep_advances_sweep_last_completed() -> None: """A False return on a sweep advances the worker's sweep clock.""" manager = get_manager() sched = manager._auto_scheduler + loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) scanner._return_value = False register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - sched.mark_sweep_due(scanner.source) + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 before = worker._sweep_last_completed - await sched.async_tick(scanner.source) + await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] # Even on False, the worker's sweep clock advanced so the next # sweep is one full interval out instead of immediate. @@ -354,8 +364,8 @@ async def test_dispatch_drops_tracking_for_unseen_address() -> None: try: request = next(iter(sched._requests_by_address["aa:bb:cc:dd:ee:ff"])) sched._needs["aa:bb:cc:dd:ee:ff"] = {request: loop.time() - 1.0} - await sched.async_tick(scanner.source) - assert not sched.is_tracking("aa:bb:cc:dd:ee:ff") + await _run_worker_tick(sched, scanner.source) + assert "aa:bb:cc:dd:ee:ff" not in sched._needs finally: cancel() register_cancel() @@ -511,7 +521,7 @@ async def async_request_active_window(self, duration: float) -> bool: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - sched.mark_sweep_due(scanner.source) + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 await worker._tick() # The exception was swallowed; sweep state still advanced. assert worker._sweep_last_completed > loop.time() - 1.0 @@ -546,11 +556,11 @@ async def async_request_active_window(self, duration: float) -> bool: entries = sched._needs[address] request = next(iter(entries)) entries[request] = loop.time() - 1.0 - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() await gate.wait() # remove_request emptied the bucket; the tick must not have # re-added the cancelled request. - assert not sched.is_tracking(address) + assert address not in sched._needs finally: register_cancel() @@ -560,6 +570,7 @@ async def test_dispatch_skips_address_owned_by_other_scanner() -> None: """An address whose owner is a different scanner is left alone.""" manager = get_manager() sched = manager._auto_scheduler + loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) owner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) @@ -568,10 +579,12 @@ async def test_dispatch_skips_address_owned_by_other_scanner() -> None: c2 = manager.async_register_scanner(other) try: _inject(owner, address) - sched.mark_due(address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 # The "other" scanner runs its tick. The address is owned by # owner, so other should not fire its window. - await sched.async_tick(other.source) + await sched._workers[other.source]._tick() assert other.active_window_calls == [] finally: cancel() @@ -657,7 +670,7 @@ async def test_dispatch_per_device_skips_empty_entries() -> None: register_cancel = manager.async_register_scanner(scanner) try: sched._needs["aa:bb:cc:dd:ee:ff"] = {} - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [] del sched._needs["aa:bb:cc:dd:ee:ff"] finally: @@ -680,7 +693,7 @@ async def test_dispatch_per_device_skips_not_yet_due() -> None: entries = sched._needs[address] for request in list(entries): entries[request] = loop.time() + 1000.0 - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [] finally: cancel() @@ -715,7 +728,7 @@ async def test_dispatch_sweep_re_checks_after_acquiring_lock() -> None: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - sched.mark_sweep_due(scanner.source) + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 sweep_lock = sched._sweep_lock assert sweep_lock is not None # Pre-acquire the lock and bump the sweep clock so the re-check @@ -763,11 +776,12 @@ async def test_dispatch_sweep_returns_when_sweep_lock_missing() -> None: """_dispatch_sweep is a no-op when the scheduler has no sweep_lock.""" manager = get_manager() sched = manager._auto_scheduler + loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - sched.mark_sweep_due(scanner.source) + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 original_lock = sched._sweep_lock sched._sweep_lock = None try: @@ -861,7 +875,7 @@ async def test_remove_request_handles_missing_bucket() -> None: # Bucket was never added; remove_request must be a no-op. sched.remove_request(request) assert "AA:BB:CC:DD:EE:99" not in sched._requests_by_address - assert not sched.is_tracking("AA:BB:CC:DD:EE:99") + assert "AA:BB:CC:DD:EE:99" not in sched._needs @pytest.mark.asyncio @@ -876,7 +890,7 @@ async def test_on_advertisement_no_match_no_wake() -> None: worker = sched._workers[scanner.source] worker._wake.clear() _inject(scanner, "AA:AA:AA:AA:AA:AA") - assert not sched.is_tracking("AA:AA:AA:AA:AA:AA") + assert "AA:AA:AA:AA:AA:AA" not in sched._needs assert not worker._wake.is_set() finally: cancel() @@ -1043,6 +1057,7 @@ async def test_coalesce_three_due_uses_max_clamped() -> None: """Three due requests on one address fire one window using max duration.""" manager = get_manager() sched = manager._auto_scheduler + loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" c1 = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=2.0 @@ -1057,8 +1072,10 @@ async def test_coalesce_three_due_uses_max_clamped() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - sched.mark_due(address) - await sched.async_tick(scanner.source) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [9.0] finally: c1() @@ -1072,6 +1089,7 @@ async def test_coalesce_clamps_oversize_request() -> None: """A scan_duration above the max is clamped on dispatch.""" manager = get_manager() sched = manager._auto_scheduler + loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=AUTO_WINDOW_MAX_DURATION + 50.0 @@ -1080,8 +1098,10 @@ async def test_coalesce_clamps_oversize_request() -> None: register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - sched.mark_due(address) - await sched.async_tick(scanner.source) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [AUTO_WINDOW_MAX_DURATION] finally: cancel() @@ -1093,14 +1113,17 @@ async def test_coalesce_none_duration_uses_min() -> None: """An unspecified scan_duration falls back to the configured minimum.""" manager = get_manager() sched = manager._auto_scheduler + loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) - sched.mark_due(address) - await sched.async_tick(scanner.source) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [AUTO_WINDOW_MIN_DURATION] finally: cancel() @@ -1131,7 +1154,7 @@ async def test_coalesce_only_due_requests_count() -> None: # future and must not pull its bigger duration into the window. entries[short_req] = loop.time() - 1.0 entries[long_req] = loop.time() + 200.0 - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [2.0] finally: c_short() @@ -1162,7 +1185,7 @@ async def test_coalesce_distinct_addresses_fire_separately() -> None: entries = sched._needs[address] for req in list(entries): entries[req] = loop.time() - 1.0 - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() # Each address gets its own window since coalescing is per-address. assert sorted(scanner.active_window_calls) == [3.0, 7.0] finally: @@ -1203,7 +1226,7 @@ async def test_three_inkbirds_share_one_scan() -> None: entries = sched._needs[addr] for req in list(entries): entries[req] = loop.time() - 1.0 - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() # One 15s window per device, none missed, none duplicated. assert scanner.active_window_calls == [15.0, 15.0, 15.0] # Next-due moved forward by scan_interval for every request. @@ -1244,7 +1267,7 @@ async def test_three_inkbirds_same_address_coalesce_to_one_scan() -> None: assert len(entries) == 3 for req in list(entries): entries[req] = loop.time() - 1.0 - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() # All three coalesced into a single 15s window. assert scanner.active_window_calls == [15.0] # Each request's next-due advanced by its own scan_interval. @@ -1290,7 +1313,7 @@ async def test_three_inkbirds_window_unchanged_after_removal() -> None: assert len(entries) == 2 for req in list(entries): entries[req] = loop.time() - 1.0 - await sched.async_tick(scanner.source) + await sched._workers[scanner.source]._tick() # Window duration is unchanged because the remaining two still # ask for 15s; coalesce takes the max. assert scanner.active_window_calls == [15.0] @@ -1298,53 +1321,3 @@ async def test_three_inkbirds_window_unchanged_after_removal() -> None: for cancel in cancels: cancel() register_cancel() - - -@pytest.mark.asyncio -async def test_mark_due_no_tracking_returns_false() -> None: - """mark_due returns False when the address has no active-scan tracking.""" - manager = get_manager() - sched = manager._auto_scheduler - assert sched.mark_due("AA:AA:AA:AA:AA:AA") is False - - -@pytest.mark.asyncio -async def test_mark_due_without_history_does_not_wake() -> None: - """mark_due advances entries even when the address has no history yet.""" - manager = get_manager() - sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - address = "11:22:33:44:55:66" - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) - cancel = manager.async_register_active_scan(address, scan_interval=60.0) - try: - # Populate _needs directly without going through on_advertisement so - # _all_history has no entry for the address. - request = next(iter(sched._requests_by_address[address])) - sched._needs[address] = {request: loop.time() + 1000.0} - worker = sched._workers[scanner.source] - worker._wake.clear() - assert sched.mark_due(address) is True - assert sched._needs[address][request] <= loop.time() - # No history -> no wake call. - assert not worker._wake.is_set() - finally: - cancel() - register_cancel() - - -@pytest.mark.asyncio -async def test_mark_sweep_due_unknown_source_returns_false() -> None: - """mark_sweep_due returns False when the source has no worker.""" - manager = get_manager() - sched = manager._auto_scheduler - assert sched.mark_sweep_due("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") is False - - -@pytest.mark.asyncio -async def test_async_tick_unknown_source_is_no_op() -> None: - """async_tick silently returns when the source has no worker.""" - manager = get_manager() - sched = manager._auto_scheduler - await sched.async_tick("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") From 491cc947381cc2b3a9f9bde4338485e21207f5c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 11:22:52 -0500 Subject: [PATCH 29/75] refactor(auto): use manager public accessors instead of _all_history/_sources Cross-module reach-ins via self._manager._all_history and self._manager._sources were the production-code variant of the underscore access the user flagged. Swap them for the existing public methods on BluetoothManager: - async_last_service_info(address, connectable=False) for the per-address history lookup the workers do in _next_event_at and _dispatch_per_device, and the scheduler does in add_request when waking the owning worker for a fresh registration. - async_current_scanners() for the iteration over manager-registered scanners during AutoScanScheduler.start(). These are cold-path calls (per worker tick and per registration), so the extra method-call cost is not measurable and the boundary stops leaking the manager's storage layout. --- src/habluetooth/auto_scheduler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 8c72cdfa..7a0e64e7 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -101,11 +101,11 @@ def _next_event_at(self, now: float) -> float: next_at = self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL source = self._scanner.source needs = self._scheduler._needs - all_history = self._scheduler._manager._all_history + last_service_info = self._scheduler._manager.async_last_service_info for address, entries in needs.items(): if not entries: continue - history = all_history.get(address) + history = last_service_info(address, False) if history is None or history.source != source: continue earliest = min(entries.values()) @@ -148,12 +148,12 @@ async def _dispatch_per_device(self) -> None: return source = self._scanner.source needs = self._scheduler._needs - all_history = self._scheduler._manager._all_history + last_service_info = self._scheduler._manager.async_last_service_info for address in list(needs): entries = needs.get(address) if not entries: continue - history = all_history.get(address) + history = last_service_info(address, False) if history is None: del needs[address] continue @@ -240,7 +240,7 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop self._running = True self._sweep_lock = asyncio.Lock() - for scanner in self._manager._sources.values(): + for scanner in self._manager.async_current_scanners(): if scanner.requested_mode is BluetoothScanningMode.AUTO: self._spawn_worker(scanner) @@ -274,7 +274,7 @@ def _spawn_worker(self, scanner: BaseHaScanner) -> None: def add_request(self, request: ActiveScanRequest) -> None: """Register an active-scan request and wake the owning worker.""" self._requests_by_address.setdefault(request.address, set()).add(request) - history = self._manager._all_history.get(request.address) + history = self._manager.async_last_service_info(request.address, False) if history is not None: self._wake_worker(history.source) From 8be974c8c93e14ef1892b902be61e7b2eb99d67e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 11:55:26 -0500 Subject: [PATCH 30/75] refactor(auto): merge per-device and sweep dispatch into a single _tick _dispatch_per_device and _dispatch_sweep used to await _run_window separately, which meant a worker with both due per-device work and a due sweep would stop/restart the radio twice per tick for the same coverage. Merge them into one _tick that collects every due request on this scanner, optionally takes the global sweep lock, and fires a single window sized to the max of every due per-device duration and (if the sweep is due) the configured sweep duration. The collection step is sync (_collect_due_buckets) and the post-window advance is sync (_advance_due); the only awaits in _tick are the sweep_lock acquire and the single _run_window call. Sweep serialization across scanners is preserved by the lock, which is rare to contend (once per scanner per AUTO_REDISCOVERY_INTERVAL). Update the tests that previously poked _dispatch_per_device / _dispatch_sweep directly to exercise the same paths through _tick, and tighten the coalesce tests so distinct addresses now share one window when due together. --- src/habluetooth/auto_scheduler.py | 141 ++++++++++++++++++------------ tests/test_auto_scheduler.py | 115 +++++++++++++++++------- 2 files changed, 170 insertions(+), 86 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 7a0e64e7..d887bdfd 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -25,6 +25,11 @@ ) from .models import BluetoothScanningMode +if TYPE_CHECKING: + from .base_scanner import BaseHaScanner + from .manager import BluetoothManager + from .models import BluetoothServiceInfoBleak + # Locally aliased so the Cython .pxd can declare them as C-typed constants; # the unaliased names stay importable from this module for Python callers. _AUTO_INITIAL_SWEEP_DELAY = AUTO_INITIAL_SWEEP_DELAY @@ -33,11 +38,6 @@ _AUTO_WINDOW_MAX_DURATION = AUTO_WINDOW_MAX_DURATION _AUTO_WINDOW_MIN_DURATION = AUTO_WINDOW_MIN_DURATION -if TYPE_CHECKING: - from .base_scanner import BaseHaScanner - from .manager import BluetoothManager - from .models import BluetoothServiceInfoBleak - _LOGGER = logging.getLogger(__name__) @@ -61,19 +61,10 @@ def __init__( class _ScannerWorker: """One persistent task per AUTO scanner; sleeps until next due event.""" - __slots__ = ( - "_scanner", - "_scheduler", - "_sweep_last_completed", - "_task", - "_wake", - "_window_end", - ) - def __init__(self, scheduler: AutoScanScheduler, scanner: BaseHaScanner) -> None: self._scheduler = scheduler self._scanner = scanner - self._wake = asyncio.Event() + self._wake: asyncio.Event = asyncio.Event() self._task: asyncio.Task[None] | None = None self._window_end: float = 0.0 self._sweep_last_completed: float = 0.0 @@ -130,25 +121,26 @@ async def _run(self) -> None: return await self._tick() - async def _tick(self) -> None: - """Fire due per-device windows, then the sweep.""" - loop = self._scheduler._loop - if loop is None: - return - if self._window_end > loop.time(): - return - self._window_end = 0.0 - await self._dispatch_per_device() - await self._dispatch_sweep() - - async def _dispatch_per_device(self) -> None: - """Fire per-(address, request) needs that target this scanner.""" - loop = self._scheduler._loop - if loop is None: - return + def _collect_due_buckets(self, now: float) -> tuple[ + list[tuple[dict[ActiveScanRequest, float], list[ActiveScanRequest]]], + list[ActiveScanRequest], + ]: + """ + Return (due_buckets, all_due) for every address this scanner owns. + + ``due_buckets`` is the list of (entries dict, due requests) pairs to + advance after the window fires; ``all_due`` is the flattened list of + every due request, used to coalesce the window duration. + Addresses whose owning scanner is no longer known are pruned from + ``_needs`` in passing. + """ source = self._scanner.source needs = self._scheduler._needs last_service_info = self._scheduler._manager.async_last_service_info + due_buckets: list[ + tuple[dict[ActiveScanRequest, float], list[ActiveScanRequest]] + ] = [] + all_due: list[ActiveScanRequest] = [] for address in list(needs): entries = needs.get(address) if not entries: @@ -159,45 +151,84 @@ async def _dispatch_per_device(self) -> None: continue if history.source != source: continue - now = loop.time() due = [r for r, t in entries.items() if t <= now] if not due: continue - duration = self._scheduler._coalesce_duration(due) - self._window_end = now + duration - await self._run_window(duration) - now = loop.time() - # Re-check membership: remove_request may have dropped any of - # the due entries while we were awaiting the window, and we - # don't want to resurrect a cancelled registration. + due_buckets.append((entries, due)) + all_due.extend(due) + return due_buckets, all_due + + def _advance_due( + self, + due_buckets: list[ + tuple[dict[ActiveScanRequest, float], list[ActiveScanRequest]] + ], + now: float, + ) -> None: + """ + Push every advanced request's next-due to now + scan_interval. + + Re-checks membership: ``remove_request`` may have dropped any of + them while the window was awaiting, and we must not resurrect a + cancelled registration. + """ + for entries, due in due_buckets: for request in due: if request in entries: entries[request] = now + request.scan_interval - self._window_end = 0.0 - async def _dispatch_sweep(self) -> None: - """Fire the global rediscovery sweep if due.""" + async def _tick(self) -> None: + """ + Fire one coalesced window covering due per-device + sweep work. + + Collection is sync; only ``_run_window`` and the optional sweep-lock + acquire are awaits. The window duration is the max of every due + per-device duration and (if the sweep is due) the configured sweep + duration; a single ACTIVE flip on the scanner catches everything + visible during the window so back-to-back windows would only churn + the radio. The global sweep lock serializes sweeps across scanners, + so a contended sweep blocks the per-device portion until the other + scanner finishes; contention is rare (once per scanner per + ``AUTO_REDISCOVERY_INTERVAL``). + """ loop = self._scheduler._loop if loop is None: return - now = loop.time() - if now < self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL: + if self._window_end > loop.time(): return + self._window_end = 0.0 + now = loop.time() + due_buckets, all_due = self._collect_due_buckets(now) sweep_lock = self._scheduler._sweep_lock - if sweep_lock is None: - return - async with sweep_lock: + sweep_acquired = False + if ( + now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL + and sweep_lock is not None + ): + await sweep_lock.acquire() now = loop.time() - if now < self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL: - return + # Re-check after the wait; another worker may have moved our + # clock forward (unlikely but defensive). + if now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL: + sweep_acquired = True + else: + sweep_lock.release() + if not all_due and not sweep_acquired: + return + duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0 + if sweep_acquired and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: duration = _AUTO_REDISCOVERY_SWEEP_DURATION - self._window_end = now + duration - try: - await self._run_window(duration) - finally: - # Advance on failure too so a stuck scanner doesn't busy-loop. + self._window_end = now + duration + try: + await self._run_window(duration) + finally: + if sweep_acquired: + # Advance on failure too so a stuck scanner doesn't + # busy-loop the worker. self._sweep_last_completed = loop.time() - self._window_end = 0.0 + sweep_lock.release() # type: ignore[union-attr] + self._advance_due(due_buckets, loop.time()) + self._window_end = 0.0 async def _run_window(self, duration: float) -> bool: """Ask the scanner for an active window; swallow per-call exceptions.""" diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 109dbaeb..3ebfebed 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -701,8 +701,8 @@ async def test_dispatch_per_device_skips_not_yet_due() -> None: @pytest.mark.asyncio -async def test_dispatch_sweep_returns_when_not_due() -> None: - """The sweep dispatch returns immediately if the cadence is not reached.""" +async def test_tick_skips_when_sweep_not_due_and_no_per_device() -> None: + """No-op tick: no per-device work due, sweep not due either.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -710,16 +710,15 @@ async def test_dispatch_sweep_returns_when_not_due() -> None: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - # Sweep is well in the future; _dispatch_sweep should be a no-op. worker._sweep_last_completed = loop.time() - await worker._dispatch_sweep() + await worker._tick() assert scanner.active_window_calls == [] finally: register_cancel() @pytest.mark.asyncio -async def test_dispatch_sweep_re_checks_after_acquiring_lock() -> None: +async def test_tick_re_checks_sweep_clock_after_acquiring_lock() -> None: """If another worker advances the clock while we wait on the lock, we bail.""" manager = get_manager() sched = manager._auto_scheduler @@ -731,13 +730,13 @@ async def test_dispatch_sweep_re_checks_after_acquiring_lock() -> None: worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 sweep_lock = sched._sweep_lock assert sweep_lock is not None - # Pre-acquire the lock and bump the sweep clock so the re-check - # inside _dispatch_sweep returns early. + # Pre-acquire the lock so _tick blocks on it; while it waits, + # bump the worker's sweep clock so the post-acquire re-check + # decides not to sweep after all. await sweep_lock.acquire() try: - task = asyncio.create_task(worker._dispatch_sweep()) + task = asyncio.create_task(worker._tick()) await asyncio.sleep(0) - # Move the worker's clock forward so the in-lock re-check fails. worker._sweep_last_completed = loop.time() finally: sweep_lock.release() @@ -749,7 +748,7 @@ async def test_dispatch_sweep_re_checks_after_acquiring_lock() -> None: @pytest.mark.asyncio async def test_worker_tick_no_op_when_loop_detached() -> None: - """Workers exit cleanly if the scheduler's loop is None.""" + """Worker tick exits cleanly if the scheduler's loop is None.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) @@ -759,11 +758,7 @@ async def test_worker_tick_no_op_when_loop_detached() -> None: original_loop = sched._loop sched._loop = None try: - # All three dispatch entry points must short-circuit when - # the loop is gone. await worker._tick() - await worker._dispatch_per_device() - await worker._dispatch_sweep() finally: sched._loop = original_loop assert scanner.active_window_calls == [] @@ -772,8 +767,8 @@ async def test_worker_tick_no_op_when_loop_detached() -> None: @pytest.mark.asyncio -async def test_dispatch_sweep_returns_when_sweep_lock_missing() -> None: - """_dispatch_sweep is a no-op when the scheduler has no sweep_lock.""" +async def test_tick_skips_sweep_when_sweep_lock_missing() -> None: + """The sweep portion of _tick is skipped if the scheduler has no lock.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -785,7 +780,7 @@ async def test_dispatch_sweep_returns_when_sweep_lock_missing() -> None: original_lock = sched._sweep_lock sched._sweep_lock = None try: - await worker._dispatch_sweep() + await worker._tick() finally: sched._sweep_lock = original_lock assert scanner.active_window_calls == [] @@ -1163,8 +1158,8 @@ async def test_coalesce_only_due_requests_count() -> None: @pytest.mark.asyncio -async def test_coalesce_distinct_addresses_fire_separately() -> None: - """Two due addresses on the same scanner each get their own window.""" +async def test_coalesce_distinct_addresses_share_one_window() -> None: + """Two due addresses on the same scanner share one max-duration window.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -1186,27 +1181,54 @@ async def test_coalesce_distinct_addresses_fire_separately() -> None: for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() - # Each address gets its own window since coalescing is per-address. - assert sorted(scanner.active_window_calls) == [3.0, 7.0] + # A single ACTIVE flip covers both devices; the window length is + # the max of every due request's duration. + assert scanner.active_window_calls == [7.0] finally: c1() c2() register_cancel() +@pytest.mark.asyncio +async def test_tick_combines_due_sweep_and_per_device_into_one_window() -> None: + """A due sweep + due per-device fold into a single window at max duration.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=3.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + worker = sched._workers[scanner.source] + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + await worker._tick() + # The sweep duration (15s) beats the per-device duration (3s) + # so the merged window is sized to the sweep. + assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] + # Sweep clock advanced. + assert worker._sweep_last_completed > loop.time() - 1.0 + finally: + cancel() + register_cancel() + + @pytest.mark.asyncio async def test_three_inkbirds_share_one_scan() -> None: """ Three Inkbirds at the same 5min / 15s cadence share a single window. - Each Inkbird has its own address so it gets its own per-address bucket - in _needs, but all three are owned by the same scanner and become due - at the same time. The worker iterates the addresses and fires three - back-to-back 15s windows, never overlapping and each clamped to the - requested duration. The point of this test is to confirm that - independent identical registrations don't accidentally turn into - 9 separate windows (3 per address); the coalesce step inside - _coalesce_duration must pick max(15s) once per address. + Each Inkbird has its own address but all three are owned by the same + scanner and become due at the same time. The worker coalesces every + due request across all addresses into a single 15s active window, so + the radio only stops and restarts once per tick. """ manager = get_manager() sched = manager._auto_scheduler @@ -1227,8 +1249,8 @@ async def test_three_inkbirds_share_one_scan() -> None: for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() - # One 15s window per device, none missed, none duplicated. - assert scanner.active_window_calls == [15.0, 15.0, 15.0] + # All three addresses fold into one coalesced 15s window. + assert scanner.active_window_calls == [15.0] # Next-due moved forward by scan_interval for every request. for addr in addresses: for due in sched._needs[addr].values(): @@ -1239,6 +1261,37 @@ async def test_three_inkbirds_share_one_scan() -> None: register_cancel() +@pytest.mark.asyncio +async def test_dispatch_coalesces_different_durations_to_max() -> None: + """Two addresses with different durations fire one window at the max.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + addr_short = "11:22:33:44:55:01" + addr_long = "11:22:33:44:55:02" + c_short = manager.async_register_active_scan( + addr_short, scan_interval=60.0, scan_duration=3.0 + ) + c_long = manager.async_register_active_scan( + addr_long, scan_interval=60.0, scan_duration=12.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + for addr in (addr_short, addr_long): + _inject(scanner, addr) + entries = sched._needs[addr] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[scanner.source]._tick() + # Single window sized to the larger of the two durations. + assert scanner.active_window_calls == [12.0] + finally: + c_short() + c_long() + register_cancel() + + @pytest.mark.asyncio async def test_three_inkbirds_same_address_coalesce_to_one_scan() -> None: """ From f376b8d19e1281a6a356b62a5b9808fb1d77af97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 11:56:34 -0500 Subject: [PATCH 31/75] perf(auto): promote _ScannerWorker to a cdef class with cpdef helpers Declare _ScannerWorker in the .pxd alongside AutoScanScheduler so the collection / advance helpers _collect_due_buckets, _advance_due, and _next_event_at become cpdef methods. The hot work inside _tick now goes through the cdef class vtable (verified in the generated C as __pyx_vtab dispatch on _ScannerWorker), and the cython.locals annotations on _collect_due_buckets / _advance_due type the locals that walk _needs entries and re-check membership after the awaited window. The async _run / _tick / _run_window methods stay plain async methods on the cdef class; only the sync helpers are cpdef. All 339 tests still pass on the cython build. --- src/habluetooth/auto_scheduler.pxd | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 9dfe0407..b5febbfa 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -16,6 +16,42 @@ cdef class ActiveScanRequest: cdef public object scan_duration +cdef class _ScannerWorker: + + cdef public object _scheduler + cdef public object _scanner + cdef public object _wake + cdef public object _task + cdef public double _window_end + cdef public double _sweep_last_completed + + cpdef void start(self, object loop) + + cpdef void stop(self) + + cpdef void wake(self) + + cpdef double _next_event_at(self, double now) + + @cython.locals( + source=str, + needs=dict, + address=str, + entries=dict, + due=list, + due_buckets=list, + all_due=list, + ) + cpdef tuple _collect_due_buckets(self, double now) + + @cython.locals( + entries=dict, + due=list, + request=ActiveScanRequest, + ) + cpdef void _advance_due(self, list due_buckets, double now) + + cdef class AutoScanScheduler: cdef public object _manager From 95a95ec8fa4d9217a23f3517b5c5d60cb4bb93da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:01:46 -0500 Subject: [PATCH 32/75] refactor(auto): pass manager directly into _ScannerWorker The worker reached the manager via self._scheduler._manager every tick (_collect_due_buckets and _next_event_at both go through the async_last_service_info accessor). Pass the manager into the worker on construction and store it as a public cdef field instead, so the hot lookups touch one attribute on the worker rather than chaining through the scheduler's private attribute. --- src/habluetooth/auto_scheduler.pxd | 1 + src/habluetooth/auto_scheduler.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index b5febbfa..a2ab1ca7 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -20,6 +20,7 @@ cdef class _ScannerWorker: cdef public object _scheduler cdef public object _scanner + cdef public object _manager cdef public object _wake cdef public object _task cdef public double _window_end diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index d887bdfd..ee7184c6 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -61,9 +61,15 @@ def __init__( class _ScannerWorker: """One persistent task per AUTO scanner; sleeps until next due event.""" - def __init__(self, scheduler: AutoScanScheduler, scanner: BaseHaScanner) -> None: + def __init__( + self, + scheduler: AutoScanScheduler, + scanner: BaseHaScanner, + manager: BluetoothManager, + ) -> None: self._scheduler = scheduler self._scanner = scanner + self._manager = manager self._wake: asyncio.Event = asyncio.Event() self._task: asyncio.Task[None] | None = None self._window_end: float = 0.0 @@ -92,7 +98,7 @@ def _next_event_at(self, now: float) -> float: next_at = self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL source = self._scanner.source needs = self._scheduler._needs - last_service_info = self._scheduler._manager.async_last_service_info + last_service_info = self._manager.async_last_service_info for address, entries in needs.items(): if not entries: continue @@ -136,7 +142,7 @@ def _collect_due_buckets(self, now: float) -> tuple[ """ source = self._scanner.source needs = self._scheduler._needs - last_service_info = self._scheduler._manager.async_last_service_info + last_service_info = self._manager.async_last_service_info due_buckets: list[ tuple[dict[ActiveScanRequest, float], list[ActiveScanRequest]] ] = [] @@ -298,7 +304,7 @@ def remove_scanner(self, scanner: BaseHaScanner) -> None: def _spawn_worker(self, scanner: BaseHaScanner) -> None: assert self._loop is not None # noqa: S101 - worker = _ScannerWorker(self, scanner) + worker = _ScannerWorker(self, scanner, self._manager) worker.start(self._loop) self._workers[scanner.source] = worker From 5e7cee62354013fb0e44324db32a10286606f65b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:09:52 -0500 Subject: [PATCH 33/75] refactor(auto): drop the global sweep lock and stagger first sweeps The asyncio.Lock that serialized rediscovery sweeps across scanners turned every _tick into a potential blocking await, complicated the per-device path (a contended sweep stalled per-device dispatches), and was over-engineered: BLE radios don't actually interfere when more than one runs in ACTIVE at the same time, and the sweep cadence (once per scanner per 12h) makes accidental contention rare anyway. Replace it with a simple registration-time stagger: each new worker's first sweep is one sweep duration later than the previous one's, so a batch of N scanners registered at startup spreads its first sweeps over N * AUTO_REDISCOVERY_SWEEP_DURATION. Subsequent sweeps stay spread because each worker advances its own clock from when its prior window finished. _tick now has no awaitable concurrency primitives other than _run_window itself; collection is sync, sweep_due is a sync comparison, and the only failure mode is a per-scanner exception inside the window which _run_window already swallows. --- src/habluetooth/auto_scheduler.pxd | 3 +- src/habluetooth/auto_scheduler.py | 68 +++++++++++----------- tests/test_auto_scheduler.py | 92 +++++++++++------------------- 3 files changed, 67 insertions(+), 96 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index a2ab1ca7..11891edc 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -26,7 +26,7 @@ cdef class _ScannerWorker: cdef public double _window_end cdef public double _sweep_last_completed - cpdef void start(self, object loop) + cpdef void start(self, object loop, double initial_offset=*) cpdef void stop(self) @@ -59,7 +59,6 @@ cdef class AutoScanScheduler: cdef public dict _requests_by_address cdef public dict _needs cdef public dict _workers - cdef public object _sweep_lock cdef public object _loop cdef public bint _running diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index ee7184c6..43deb1b7 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -75,10 +75,22 @@ def __init__( self._window_end: float = 0.0 self._sweep_last_completed: float = 0.0 - def start(self, loop: asyncio.AbstractEventLoop) -> None: - """Start the worker task; first sweep AUTO_INITIAL_SWEEP_DELAY out.""" + def start( + self, loop: asyncio.AbstractEventLoop, initial_offset: float = 0.0 + ) -> None: + """ + Start the worker task; first sweep AUTO_INITIAL_SWEEP_DELAY out. + + ``initial_offset`` lets the caller stagger first sweeps across + concurrently-registered scanners so they don't all flip ACTIVE in + the same second; subsequent sweeps stay staggered because each + worker advances its own clock from when its prior window finished. + """ self._sweep_last_completed = ( - loop.time() + _AUTO_INITIAL_SWEEP_DELAY - _AUTO_REDISCOVERY_INTERVAL + loop.time() + + _AUTO_INITIAL_SWEEP_DELAY + + initial_offset + - _AUTO_REDISCOVERY_INTERVAL ) self._task = loop.create_task(self._run()) @@ -187,15 +199,14 @@ async def _tick(self) -> None: """ Fire one coalesced window covering due per-device + sweep work. - Collection is sync; only ``_run_window`` and the optional sweep-lock - acquire are awaits. The window duration is the max of every due - per-device duration and (if the sweep is due) the configured sweep - duration; a single ACTIVE flip on the scanner catches everything - visible during the window so back-to-back windows would only churn - the radio. The global sweep lock serializes sweeps across scanners, - so a contended sweep blocks the per-device portion until the other - scanner finishes; contention is rare (once per scanner per - ``AUTO_REDISCOVERY_INTERVAL``). + Collection is sync; only ``_run_window`` is awaited. The window + duration is the max of every due per-device duration and (if the + sweep is due) the configured sweep duration; a single ACTIVE flip + catches every device the scanner sees during the window so + back-to-back windows would only churn the radio. Scanners stagger + their first sweep at registration time so concurrent sweeps are + unlikely; BLE radios don't actually interfere when more than one + is active so the prior design's global sweep lock was over-engineered. """ loop = self._scheduler._loop if loop is None: @@ -205,34 +216,20 @@ async def _tick(self) -> None: self._window_end = 0.0 now = loop.time() due_buckets, all_due = self._collect_due_buckets(now) - sweep_lock = self._scheduler._sweep_lock - sweep_acquired = False - if ( - now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL - and sweep_lock is not None - ): - await sweep_lock.acquire() - now = loop.time() - # Re-check after the wait; another worker may have moved our - # clock forward (unlikely but defensive). - if now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL: - sweep_acquired = True - else: - sweep_lock.release() - if not all_due and not sweep_acquired: + sweep_due = now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL + if not all_due and not sweep_due: return duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0 - if sweep_acquired and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: + if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: duration = _AUTO_REDISCOVERY_SWEEP_DURATION self._window_end = now + duration try: await self._run_window(duration) finally: - if sweep_acquired: + if sweep_due: # Advance on failure too so a stuck scanner doesn't # busy-loop the worker. self._sweep_last_completed = loop.time() - sweep_lock.release() # type: ignore[union-attr] self._advance_due(due_buckets, loop.time()) self._window_end = 0.0 @@ -258,7 +255,6 @@ class AutoScanScheduler: "_needs", "_requests_by_address", "_running", - "_sweep_lock", "_workers", ) @@ -268,7 +264,6 @@ def __init__(self, manager: BluetoothManager) -> None: self._requests_by_address: dict[str, set[ActiveScanRequest]] = {} self._needs: dict[str, dict[ActiveScanRequest, float]] = {} self._workers: dict[str, _ScannerWorker] = {} - self._sweep_lock: asyncio.Lock | None = None self._loop: asyncio.AbstractEventLoop | None = None self._running = False @@ -276,7 +271,6 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: """Bind to the event loop and spawn one worker per AUTO scanner.""" self._loop = loop self._running = True - self._sweep_lock = asyncio.Lock() for scanner in self._manager.async_current_scanners(): if scanner.requested_mode is BluetoothScanningMode.AUTO: self._spawn_worker(scanner) @@ -305,7 +299,13 @@ def remove_scanner(self, scanner: BaseHaScanner) -> None: def _spawn_worker(self, scanner: BaseHaScanner) -> None: assert self._loop is not None # noqa: S101 worker = _ScannerWorker(self, scanner, self._manager) - worker.start(self._loop) + # Stagger first sweeps so concurrently-registered scanners don't + # all flip ACTIVE in the same second. Each new worker's first + # sweep is one sweep duration later than the previous one's; the + # offset compounds so a tenth scanner registered in the same + # batch fires its first sweep ~150s after the first one's. + offset = len(self._workers) * _AUTO_REDISCOVERY_SWEEP_DURATION + worker.start(self._loop, offset) self._workers[scanner.source] = worker def add_request(self, request: ActiveScanRequest) -> None: diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 3ebfebed..efe9d3e4 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -257,39 +257,43 @@ async def test_global_sweep_runs_on_auto_scanner() -> None: @pytest.mark.asyncio -async def test_global_sweep_one_scanner_at_a_time() -> None: - """Two scanners both due for sweep do not sweep concurrently.""" +async def test_first_sweeps_stagger_across_scanners() -> None: + """Concurrently-registered scanners get offset first-sweep times.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - blocking = asyncio.Event() s1 = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - s1._block_event = blocking s2 = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO) + s3 = _RecordingAutoScanner("AA:BB:CC:DD:EE:22", BluetoothScanningMode.AUTO) c1 = manager.async_register_scanner(s1) c2 = manager.async_register_scanner(s2) + c3 = manager.async_register_scanner(s3) try: - w1 = sched._workers[s1.source] - w2 = sched._workers[s2.source] - w1._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 10 - w2._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 5 - # Kick worker 1 into its sweep (it'll block on the asyncio.Event). - t1 = asyncio.create_task(w1._tick()) - await _drain() - # While w1's sweep is blocked, w2 attempts its sweep too. It must - # wait on the shared _sweep_lock and not fire concurrently. - t2 = asyncio.create_task(w2._tick()) - await _drain() - assert s1.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] - assert s2.active_window_calls == [] - blocking.set() - await t1 - await t2 - assert s2.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] + now = loop.time() + sweep_1 = ( + sched._workers[s1.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + ) + sweep_2 = ( + sched._workers[s2.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + ) + sweep_3 = ( + sched._workers[s3.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + ) + # Each subsequent worker's first sweep is one sweep-duration + # later than the previous one's (slack for loop.time() advancing + # between spawn calls). + assert sweep_2 - sweep_1 == pytest.approx( + AUTO_REDISCOVERY_SWEEP_DURATION, abs=0.01 + ) + assert sweep_3 - sweep_2 == pytest.approx( + AUTO_REDISCOVERY_SWEEP_DURATION, abs=0.01 + ) + # Roughly the configured initial delay from now. + assert sweep_1 - now == pytest.approx(AUTO_INITIAL_SWEEP_DELAY, abs=1.0) finally: - blocking.set() c1() c2() + c3() @pytest.mark.asyncio @@ -717,35 +721,6 @@ async def test_tick_skips_when_sweep_not_due_and_no_per_device() -> None: register_cancel() -@pytest.mark.asyncio -async def test_tick_re_checks_sweep_clock_after_acquiring_lock() -> None: - """If another worker advances the clock while we wait on the lock, we bail.""" - manager = get_manager() - sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) - try: - worker = sched._workers[scanner.source] - worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 - sweep_lock = sched._sweep_lock - assert sweep_lock is not None - # Pre-acquire the lock so _tick blocks on it; while it waits, - # bump the worker's sweep clock so the post-acquire re-check - # decides not to sweep after all. - await sweep_lock.acquire() - try: - task = asyncio.create_task(worker._tick()) - await asyncio.sleep(0) - worker._sweep_last_completed = loop.time() - finally: - sweep_lock.release() - await task - assert scanner.active_window_calls == [] - finally: - register_cancel() - - @pytest.mark.asyncio async def test_worker_tick_no_op_when_loop_detached() -> None: """Worker tick exits cleanly if the scheduler's loop is None.""" @@ -767,8 +742,8 @@ async def test_worker_tick_no_op_when_loop_detached() -> None: @pytest.mark.asyncio -async def test_tick_skips_sweep_when_sweep_lock_missing() -> None: - """The sweep portion of _tick is skipped if the scheduler has no lock.""" +async def test_tick_no_op_when_already_inside_window() -> None: + """A tick that arrives while a window is in flight returns early.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() @@ -776,13 +751,10 @@ async def test_tick_skips_sweep_when_sweep_lock_missing() -> None: register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] - worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 - original_lock = sched._sweep_lock - sched._sweep_lock = None - try: - await worker._tick() - finally: - sched._sweep_lock = original_lock + # Pretend a window is mid-flight; _tick must defer to that + # window and not start a new one. + worker._window_end = loop.time() + 60.0 + await worker._tick() assert scanner.active_window_calls == [] finally: register_cancel() From 490902499a9f3ba4afa80d50b0fc285d9f7ec4b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:14:42 -0500 Subject: [PATCH 34/75] refactor(auto): inline the _run_window wrapper into _tick _run_window was a one-await wrapper that caught exceptions from the scanner's async_request_active_window. With _dispatch_per_device and _dispatch_sweep merged into a single _tick there's exactly one call site, so the wrapper is just an extra indirection; fold the try/except into _tick's existing try/finally cleanup block. --- src/habluetooth/auto_scheduler.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 43deb1b7..5b228120 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -199,7 +199,8 @@ async def _tick(self) -> None: """ Fire one coalesced window covering due per-device + sweep work. - Collection is sync; only ``_run_window`` is awaited. The window + Collection is sync; only the scanner's active-window call is + awaited. The window duration is the max of every due per-device duration and (if the sweep is due) the configured sweep duration; a single ACTIVE flip catches every device the scanner sees during the window so @@ -224,7 +225,13 @@ async def _tick(self) -> None: duration = _AUTO_REDISCOVERY_SWEEP_DURATION self._window_end = now + duration try: - await self._run_window(duration) + await self._scanner.async_request_active_window(duration) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "%s: error running active window of %.1fs", + self._scanner.name, + duration, + ) finally: if sweep_due: # Advance on failure too so a stuck scanner doesn't @@ -233,18 +240,6 @@ async def _tick(self) -> None: self._advance_due(due_buckets, loop.time()) self._window_end = 0.0 - async def _run_window(self, duration: float) -> bool: - """Ask the scanner for an active window; swallow per-call exceptions.""" - try: - return await self._scanner.async_request_active_window(duration) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "%s: error running active window of %.1fs", - self._scanner.name, - duration, - ) - return False - class AutoScanScheduler: """Coordinates on-demand active windows across AUTO-mode scanners.""" From a30e0ef70b9885825a3584a6ff40fc7590b5eb05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:21:25 -0500 Subject: [PATCH 35/75] feat(auto): kick-start tracking at registration time Previously add_request just updated _requests_by_address; the tracking entry in _needs only got created when an advertisement arrived. A registration whose device was already in history would sit idle until the next advertisement instead of starting its scan_interval countdown from registration. Insert into _needs from add_request too, with next_due = now + scan_interval. The history check at tick time still gates against firing on a scanner that hasn't seen the device, and on_advertisement remains the fallback that re-creates the entry if the worker pruned it for missing history. --- src/habluetooth/auto_scheduler.py | 14 +++++++++++++- tests/test_auto_scheduler.py | 18 +++++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 5b228120..82ce058e 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -304,8 +304,20 @@ def _spawn_worker(self, scanner: BaseHaScanner) -> None: self._workers[scanner.source] = worker def add_request(self, request: ActiveScanRequest) -> None: - """Register an active-scan request and wake the owning worker.""" + """ + Register an active-scan request and start tracking immediately. + + The first window fires ``scan_interval`` seconds after registration + (gated by the per-scanner history check at tick time, so it doesn't + fire on a scanner that hasn't seen the device yet). If the entry + gets pruned later because the device's history disappears, + on_advertisement re-creates it the next time the device is seen. + """ self._requests_by_address.setdefault(request.address, set()).add(request) + if self._loop is not None: + existing = self._needs.setdefault(request.address, {}) + if request not in existing: + existing[request] = self._loop.time() + request.scan_interval history = self._manager.async_last_service_info(request.address, False) if history is not None: self._wake_worker(history.source) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index efe9d3e4..06d647f5 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -130,8 +130,10 @@ async def test_advertisement_for_unrelated_address_is_ignored() -> None: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: + # The registered address has tracking from add_request; the + # unrelated advertisement must not create its own entry. _inject(scanner, "AA:AA:AA:AA:AA:AA") - assert sched._needs == {} + assert "AA:AA:AA:AA:AA:AA" not in sched._needs finally: cancel() register_cancel() @@ -477,19 +479,21 @@ async def test_on_advertisement_early_returns_with_no_requests() -> None: @pytest.mark.asyncio -async def test_on_advertisement_wakes_owning_worker() -> None: - """Adding a tracking entry wakes the worker so it picks the new event up.""" +async def test_on_advertisement_re_bootstraps_pruned_tracking() -> None: + """If a tracking entry was pruned, the next ad re-creates it and wakes.""" manager = get_manager() sched = manager._auto_scheduler + address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) - cancel = manager.async_register_active_scan( - "11:22:33:44:55:66", scan_interval=120.0 - ) + cancel = manager.async_register_active_scan(address, scan_interval=120.0) try: worker = sched._workers[scanner.source] + # Simulate the prune-on-no-history step having removed the entry. + del sched._needs[address] worker._wake.clear() - _inject(scanner, "11:22:33:44:55:66") + _inject(scanner, address) + assert address in sched._needs assert worker._wake.is_set() finally: cancel() From f01f9deaebd93bc5550784bfca861bda1515ef82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:24:23 -0500 Subject: [PATCH 36/75] docs(auto): expand module docstring with a flow diagram and invariants Lays out where state lives, how add_request / on_advertisement feed _needs, and how _ScannerWorker._tick walks from collection through the single ACTIVE window dispatch to the post-window advance. Closes by stating the invariants (one window per scanner, per-device windows only on the owning scanner, sweeps on every scanner, registration kick-starts tracking). --- src/habluetooth/auto_scheduler.py | 69 ++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 82ce058e..075404f8 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -1,12 +1,69 @@ """ Auto-mode active-window scheduler. -One ``_ScannerWorker`` task per AUTO scanner sleeps on an -``asyncio.Event`` with a ``wait_for`` timeout until the next due event; -per-address registrations fire scan_interval/scan_duration windows on -the scanner currently seeing the device, and each scanner sweeps once -``AUTO_INITIAL_SWEEP_DELAY`` after joining then every -``AUTO_REDISCOVERY_INTERVAL`` thereafter, serialized across scanners. +Coordinates on-demand ACTIVE scans for AUTO-mode scanners. A scanner +defaults to PASSIVE; the manager flips it to ACTIVE for ``duration`` +seconds on demand when an integration has asked for active scans on a +specific device address. + + +Flow +==== + + add_request(req) on_advertisement(adv) + | | + | seed _needs[addr][req] | re-seed if pruned + | = now + scan_interval | = now + scan_interval + | wake address's owner | wake adv.source + v v + +------------------------------------------+ + | AutoScanScheduler | + | _requests_by_address | + | addr -> set of ActiveScanRequest | + | _needs | + | addr -> {request: next_due_time} | + | _workers | + | source -> _ScannerWorker | + +------------------------------------------+ + | + | one task per AUTO scanner + v + +------------------------------------------+ + | _ScannerWorker._run loop | + | | + | sleep on _wake with timeout = | + | _next_event_at(now) - now | + | await _tick() | + | | + | _tick (sync collect, one await): | + | 1. _collect_due_buckets | + | skip addresses whose owner | + | (last_service_info.source) is | + | not this scanner | + | 2. sweep_due = sweep cadence elapsed | + | 3. duration = max(due durations, | + | SWEEP_DURATION if sweep_due) | + | 4. ONE await: | + | scanner.async_request_active_window| + | 5. _advance_due / advance sweep clock | + +------------------------------------------+ + + +Invariants +========== + +* At most one outstanding window per scanner (``_window_end`` guards + re-entry into ``_tick``). +* Per-device windows fire only on the scanner whose ``source`` matches + the device's most recent advertisement source; other scanners that + see the same device skip it. +* Global rediscovery sweeps fire on every AUTO scanner at their own + cadence (first sweep at ``AUTO_INITIAL_SWEEP_DELAY`` + a staggered + offset assigned at registration, every + ``AUTO_REDISCOVERY_INTERVAL`` afterwards). +* A registration kick-starts tracking immediately; ``on_advertisement`` + is the fallback that re-creates the entry if the worker pruned it + because the device's history was missing at tick time. """ from __future__ import annotations From 91be515dc4b926c65d9210d628f18ac8fb77cdbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:31:02 -0500 Subject: [PATCH 37/75] test(auto): cover 4-scanner ownership, add_request edges, _run loop Add four tests pulling auto_scheduler.py to 100% coverage: - test_only_owning_scanner_fires_among_four: explicit 4-scanner setup confirming only the owning scanner flips ACTIVE and the other three stay PASSIVE. - test_add_request_before_start_does_not_seed_needs: covers the pre-loop branch of add_request that skips the _needs seed. - test_add_request_idempotent_keeps_existing_due: covers the "request already in existing" branch that does not overwrite the due time. - test_run_loop_waits_then_ticks: drives _ScannerWorker._run through wait_for + _tick so those lines are no longer left to the long-running persistent worker (which tests don't normally exercise). Also pull the one-scanner-fires invariant up into the module docstring prose so it's visible at the top rather than only in the invariants list at the bottom. --- src/habluetooth/auto_scheduler.py | 9 +++ tests/test_auto_scheduler.py | 94 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 075404f8..0ec09aa8 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -6,6 +6,15 @@ seconds on demand when an integration has asked for active scans on a specific device address. +Per-device active windows fire on **exactly one** scanner at a time: +whichever scanner the manager currently considers the device's owner +(``manager.async_last_service_info(address).source``). If three other +AUTO scanners can also see the device, they stay PASSIVE for that +window. Ownership can flip across scanners over time as RSSI changes; +the next-due window then fires on the new owner. Sweeps are different +and run on every AUTO scanner independently, since their job is to +find devices not yet in history. + Flow ==== diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 06d647f5..221f3f35 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -1350,3 +1350,97 @@ async def test_three_inkbirds_window_unchanged_after_removal() -> None: for cancel in cancels: cancel() register_cancel() + + +@pytest.mark.asyncio +async def test_only_owning_scanner_fires_among_four() -> None: + """ + Of four AUTO scanners, only the one owning the device's history fires. + + The device is injected from one specific scanner so the manager's + _all_history points at that source. Every worker's _tick runs; + only the owner produces an active window. The other three scanners + stay PASSIVE. + """ + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=5.0 + ) + scanners = [ + _RecordingAutoScanner(f"AA:00:00:00:00:0{n}", BluetoothScanningMode.AUTO) + for n in range(4) + ] + register_cancels = [manager.async_register_scanner(s) for s in scanners] + try: + owner = scanners[2] + _inject(owner, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + for scanner in scanners: + await sched._workers[scanner.source]._tick() + # Only the owning scanner flipped to ACTIVE for the requested 5s. + assert [s.active_window_calls for s in scanners] == [[], [], [5.0], []] + finally: + for c in register_cancels: + c() + cancel() + + +@pytest.mark.asyncio +async def test_add_request_before_start_does_not_seed_needs() -> None: + """If add_request runs before start() the entry is deferred to advertisement.""" + manager = get_manager() + sched = manager._auto_scheduler + address = "BB:00:00:00:00:00" + original_loop = sched._loop + sched._loop = None + try: + sched.add_request(ActiveScanRequest(address, 60.0, None)) + assert address in sched._requests_by_address + assert address not in sched._needs + finally: + sched._loop = original_loop + sched._requests_by_address.pop(address, None) + + +@pytest.mark.asyncio +async def test_add_request_idempotent_keeps_existing_due() -> None: + """Re-adding the same request preserves its existing next-due time.""" + manager = get_manager() + sched = manager._auto_scheduler + address = "BC:00:00:00:00:00" + request = ActiveScanRequest(address, 60.0, None) + sched.add_request(request) + sched._needs[address][request] = 1234.5 + sched.add_request(request) + assert sched._needs[address][request] == 1234.5 + sched.remove_request(request) + + +@pytest.mark.asyncio +async def test_run_loop_waits_then_ticks() -> None: + """The _run loop's wait_for + _tick path is exercised end-to-end.""" + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + await _replace_worker_task(worker) + # Sweep ~1ms in the future so _run's wait_for times out quickly + # and _tick runs once before we shut it down. + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL + 0.001 + task = loop.create_task(worker._run()) + await asyncio.sleep(0.05) + assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] + sched._running = False + worker._wake.set() + await asyncio.wait_for(task, timeout=1.0) + finally: + sched._running = True + register_cancel() From 4531049852cea96f6ab4bd6cb5a1a0baad411101 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:43:16 -0500 Subject: [PATCH 38/75] fix(auto): advance next-due before the window await to close owner-flip race Race: scanner A is the device's current owner; A's worker collects the due request and starts await scanner.async_request_active_window. During the window a stronger advertisement reaches scanner B and flips _all_history[addr].source to B. on_advertisement wakes B's worker. B's _collect_due_buckets reads the same _needs entry that A hasn't advanced yet (advance was in the finally) and fires its own duplicate window for the same address. Move _advance_due (and the sweep clock advance) to before the await so a second worker that becomes the new owner mid-window sees the entry as not yet due and skips it. With the advance moved, the post-await membership re-check in _advance_due is unreachable (nothing has yielded between _collect_due_buckets and _advance_due), so drop the dead `if request in entries` guard. Add test_owner_flip_during_window_does_not_double_fire that pins A's window open on an asyncio.Event, injects an advertisement on B mid- window, ticks B's worker, and asserts B fires no window. --- src/habluetooth/auto_scheduler.py | 29 ++++++++++------ tests/test_auto_scheduler.py | 57 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 0ec09aa8..769329cb 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -252,14 +252,14 @@ def _advance_due( """ Push every advanced request's next-due to now + scan_interval. - Re-checks membership: ``remove_request`` may have dropped any of - them while the window was awaiting, and we must not resurrect a - cancelled registration. + Called pre-await from ``_tick`` so the window's owner has already + claimed the slot before any other worker can wake; no membership + check is needed because nothing has yielded since + ``_collect_due_buckets`` populated due_buckets. """ for entries, due in due_buckets: for request in due: - if request in entries: - entries[request] = now + request.scan_interval + entries[request] = now + request.scan_interval async def _tick(self) -> None: """ @@ -289,7 +289,19 @@ async def _tick(self) -> None: duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0 if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: duration = _AUTO_REDISCOVERY_SWEEP_DURATION - self._window_end = now + duration + window_end = now + duration + self._window_end = window_end + # Advance per-device next-due times and the sweep clock BEFORE the + # await so a concurrent worker that becomes the new owner of any + # of these addresses mid-window (e.g. an RSSI flip on a fresh + # advertisement) doesn't fire a duplicate window for the same + # request. Failure of the scanner call is handled the same way as + # success: we still don't retry until scan_interval out (or + # AUTO_REDISCOVERY_INTERVAL out for the sweep), which prevents + # busy-looping the worker on a stuck scanner. + self._advance_due(due_buckets, window_end) + if sweep_due: + self._sweep_last_completed = window_end try: await self._scanner.async_request_active_window(duration) except Exception: # pylint: disable=broad-except @@ -299,11 +311,6 @@ async def _tick(self) -> None: duration, ) finally: - if sweep_due: - # Advance on failure too so a stuck scanner doesn't - # busy-loop the worker. - self._sweep_last_completed = loop.time() - self._advance_due(due_buckets, loop.time()) self._window_end = 0.0 diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 221f3f35..c941e465 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -1444,3 +1444,60 @@ async def test_run_loop_waits_then_ticks() -> None: finally: sched._running = True register_cancel() + + +@pytest.mark.asyncio +async def test_owner_flip_during_window_does_not_double_fire() -> None: + """ + If ownership flips to a second scanner mid-window, no duplicate fire. + + Worker A starts its window for address X. While A awaits the radio, + a new advertisement makes B the owner (B's _all_history.source). + B's worker wakes and ticks. Because A advanced X's next-due BEFORE + starting the await, B's _collect_due_buckets sees the entry as not + yet due and skips it. A finishes alone with one window; B fires + none. + """ + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:66" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=5.0 + ) + gate = asyncio.Event() + s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) + s_a._block_event = gate + s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) + c_a = manager.async_register_scanner(s_a) + c_b = manager.async_register_scanner(s_b) + try: + _inject(s_a, address) + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + + # Worker A starts its tick and blocks inside the scanner call. + t_a = asyncio.create_task(sched._workers[s_a.source]._tick()) + for _ in range(4): + await asyncio.sleep(0) + assert s_a.active_window_calls == [5.0] + # A advanced entries BEFORE the await; verify that. + for due in entries.values(): + assert due > loop.time() + 50.0 + + # Ownership flips to B (a fresh advertisement on B). + _inject(s_b, address) + + # B's worker ticks. Because the entry is already in the future + # it must NOT fire a second window. + await sched._workers[s_b.source]._tick() + assert s_b.active_window_calls == [] + + gate.set() + await t_a + finally: + gate.set() + c_a() + c_b() + cancel() From 2c2a58e24d35aee1e941a77dcf579f1ebe221b5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 12:54:27 -0500 Subject: [PATCH 39/75] fix(scanner): skip restart in async_request_active_window if still ACTIVE The end-of-window timer clears _active_window_handle = None synchronously and spawns a background _async_end_active_window task. A new async_request_active_window arriving in the same iteration sees the handle as None too and queues on _start_stop_lock; if _async_end_active_window wins the race it clears the override, restarts in PASSIVE, releases the lock, and then our request acquires and restarts again in ACTIVE - one wasted stop/start pair. Inside the lock, check current_mode: if it's still ACTIVE, the end-of-window task hasn't run yet (it would have flipped current_mode to PASSIVE during its restart). Re-arm the timer and return; when _async_end_active_window eventually acquires the lock it sees the new handle and bails. Closes the remaining bluetoothbot review note about the lock-acquire race. --- src/habluetooth/scanner.py | 12 +++++++- tests/test_scanner.py | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 7d5b7ec4..2d22bcd3 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -681,6 +681,15 @@ async def async_request_active_window(self, duration: float) -> bool: return True async with self._start_stop_lock: self._scan_mode_override = BluetoothScanningMode.ACTIVE + # If the scanner is still ACTIVE here, the end-of-window task + # for the previous timer is queued but hasn't run yet (it + # would have cleared current_mode to PASSIVE). Skip the + # restart, arm a new timer; _async_end_active_window will + # see the new handle and bail when it acquires the lock. + mode_before_restart = self.current_mode + if mode_before_restart is BluetoothScanningMode.ACTIVE: + self._arm_active_window_timer(duration, new_end) + return True try: await self._async_stop_then_start_under_lock() except ScannerStartError: @@ -691,7 +700,8 @@ async def async_request_active_window(self, duration: float) -> bool: with contextlib.suppress(ScannerStartError): await self._async_stop_then_start_under_lock() return False - if self.current_mode is not BluetoothScanningMode.ACTIVE: + mode_after_restart = self.current_mode + if mode_after_restart is not BluetoothScanningMode.ACTIVE: # Linux's 4th-attempt fallback silently drops to PASSIVE. self._scan_mode_override = None return False diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 8f5a3a50..4d65eb87 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1821,6 +1821,63 @@ def _factory(*_args, **kwargs): await scanner.async_stop() +@pytest.mark.asyncio +async def test_async_request_active_window_skips_restart_if_still_active() -> None: + """ + Re-arm the timer instead of restarting if the scanner is still ACTIVE. + + A new request arriving after the end-of-window timer fires but + before the bg task runs reuses the in-flight ACTIVE mode and just + arms a new timer. + """ + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + starts: list[str] = [] + + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() + + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + assert starts == ["passive"] + + assert await scanner.async_request_active_window(100.0) is True + assert starts == ["passive", "active"] + # Simulate the timer firing but the end-window task not having + # run yet: clear the handle (like _schedule_end_active_window + # does) but leave _scan_mode_override / current_mode == ACTIVE. + handle = scanner._active_window_handle + assert handle is not None + handle.cancel() + scanner._active_window_handle = None + + # Scanner is still ACTIVE; the new request must just re-arm + # the timer, not do an active->passive->active pair. + before_len = len(starts) + assert await scanner.async_request_active_window(50.0) is True + assert scanner._active_window_handle is not None + # No new starts; the restart was skipped. + assert len(starts) == before_len # type: ignore[unreachable] + + await scanner.async_stop() + + @pytest.mark.asyncio async def test_async_stop_clears_active_window_state() -> None: """Stopping mid-window cancels the timer and clears the override.""" From afeecef132d084922094c37c28c4f037b5243c39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 13:16:13 -0500 Subject: [PATCH 40/75] feat(manager): default scan_interval/scan_duration on async_register_active_scan 99% of integrations don't need to specify a cadence; pick one for them so the API is opt-in but does the right thing out of the box. Add DEFAULT_ACTIVE_SCAN_INTERVAL (180s, every 3 minutes) and DEFAULT_ACTIVE_SCAN_DURATION (10s) in const.py and make both parameters of async_register_active_scan optional. A None value falls through to the defaults; explicit values still work and still get validated for the obvious bogus cases. Three minutes is enough for the typical temperature/humidity/battery sensor case without burning the proxy's radio or the sensor's battery on more frequent flips than the data actually changes. --- src/habluetooth/const.py | 8 ++++++++ src/habluetooth/manager.py | 23 ++++++++++++++++++----- tests/test_auto_scheduler.py | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/habluetooth/const.py b/src/habluetooth/const.py index e778cc32..39a16892 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -65,6 +65,14 @@ AUTO_WINDOW_MIN_DURATION: Final = 1.0 AUTO_WINDOW_MAX_DURATION: Final = 30.0 +# Defaults used by async_register_active_scan when the caller does +# not specify a cadence. One 10s active window every 3 minutes per +# device is enough for the typical temperature/humidity/battery +# sensor case without burning the proxy's radio or the sensor's +# battery on more frequent flips than its data actually changes. +DEFAULT_ACTIVE_SCAN_INTERVAL: Final = 180.0 +DEFAULT_ACTIVE_SCAN_DURATION: Final = 10.0 + FAILED_ADAPTER_MAC = "00:00:00:00:00:00" diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 20bee680..ca60a1fd 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -35,6 +35,8 @@ from .const import ( ADV_RSSI_SWITCH_THRESHOLD, CALLBACK_TYPE, + DEFAULT_ACTIVE_SCAN_DURATION, + DEFAULT_ACTIVE_SCAN_INTERVAL, FAILED_ADAPTER_MAC, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, UNAVAILABLE_TRACK_SECONDS, @@ -1060,22 +1062,33 @@ def async_register_bleak_callback( def async_register_active_scan( self, address: str, - scan_interval: float, + scan_interval: float | None = None, scan_duration: float | None = None, ) -> CALLBACK_TYPE: """ Declare an on-demand active-scan need for a specific address. - The scheduler asks the AUTO-mode scanner currently in range of - ``address`` to flip active for ``scan_duration`` seconds every + ``scan_interval`` and ``scan_duration`` default to + DEFAULT_ACTIVE_SCAN_INTERVAL (180s, 3 minutes) and + DEFAULT_ACTIVE_SCAN_DURATION (10s) when not provided; those + defaults work for the typical sensor case where a callback just + wants the device's scan response on a steady cadence without + burning radio time on more frequent flips than the sensor data + actually changes. The scheduler + asks the AUTO-mode scanner currently in range of ``address`` to + flip active for ``scan_duration`` seconds every ``scan_interval`` seconds while the device is being seen. ACTIVE and PASSIVE scanners ignore the request. Returns a cancel callable. """ + if scan_interval is None: + scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL + if scan_duration is None: + scan_duration = DEFAULT_ACTIVE_SCAN_DURATION if scan_interval <= 0: raise ValueError("scan_interval must be > 0") - if scan_duration is not None and scan_duration < 0: - raise ValueError("scan_duration must be None or >= 0") + if scan_duration < 0: + raise ValueError("scan_duration must be >= 0") request = ActiveScanRequest(address, scan_interval, scan_duration) self._auto_scheduler.add_request(request) return partial(self._auto_scheduler.remove_request, request) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index c941e465..31eb424e 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -508,12 +508,32 @@ async def test_register_active_scan_validates_inputs() -> None: manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=0) with pytest.raises(ValueError, match="scan_interval must be > 0"): manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=-1) - with pytest.raises(ValueError, match="scan_duration must be None or >= 0"): + with pytest.raises(ValueError, match="scan_duration must be >= 0"): manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=-0.5 ) +@pytest.mark.asyncio +async def test_register_active_scan_applies_defaults() -> None: + """Omitting scan_interval/scan_duration uses the configured defaults.""" + from habluetooth.const import ( + DEFAULT_ACTIVE_SCAN_DURATION, + DEFAULT_ACTIVE_SCAN_INTERVAL, + ) + + manager = get_manager() + sched = manager._auto_scheduler + address = "AA:BB:CC:DD:EE:42" + cancel = manager.async_register_active_scan(address) + try: + request = next(iter(sched._requests_by_address[address])) + assert request.scan_interval == DEFAULT_ACTIVE_SCAN_INTERVAL + assert request.scan_duration == DEFAULT_ACTIVE_SCAN_DURATION + finally: + cancel() + + @pytest.mark.asyncio async def test_run_window_swallows_scanner_exception() -> None: """An exception from async_request_active_window is logged, not re-raised.""" @@ -1081,12 +1101,19 @@ async def test_coalesce_clamps_oversize_request() -> None: @pytest.mark.asyncio async def test_coalesce_none_duration_uses_min() -> None: - """An unspecified scan_duration falls back to the configured minimum.""" + """ + An explicit None scan_duration on a request falls back to the minimum. + + Goes around async_register_active_scan (which defaults scan_duration + to DEFAULT_ACTIVE_SCAN_DURATION) to exercise the None branch of + _coalesce_duration directly with a hand-built ActiveScanRequest. + """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" - cancel = manager.async_register_active_scan(address, scan_interval=60.0) + request = ActiveScanRequest(address, 60.0, None) + sched._requests_by_address.setdefault(address, set()).add(request) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: @@ -1097,7 +1124,7 @@ async def test_coalesce_none_duration_uses_min() -> None: await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [AUTO_WINDOW_MIN_DURATION] finally: - cancel() + sched.remove_request(request) register_cancel() From 06ef79a5132e9c99370b3842499e17269cf8745a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 13:18:03 -0500 Subject: [PATCH 41/75] feat(manager): bump DEFAULT_ACTIVE_SCAN_INTERVAL to 5 minutes Three minutes was a guess; five is friendlier to proxy radios and sensor batteries for the typical temperature/humidity/battery case. Integrations that genuinely need a tighter cadence can pass a smaller scan_interval explicitly to async_register_active_scan. --- src/habluetooth/const.py | 11 ++++++----- src/habluetooth/manager.py | 9 ++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/habluetooth/const.py b/src/habluetooth/const.py index 39a16892..185a5170 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -66,11 +66,12 @@ AUTO_WINDOW_MAX_DURATION: Final = 30.0 # Defaults used by async_register_active_scan when the caller does -# not specify a cadence. One 10s active window every 3 minutes per -# device is enough for the typical temperature/humidity/battery -# sensor case without burning the proxy's radio or the sensor's -# battery on more frequent flips than its data actually changes. -DEFAULT_ACTIVE_SCAN_INTERVAL: Final = 180.0 +# not specify a cadence. One 10s active window every 5 minutes per +# device covers the typical temperature/humidity/battery sensor case +# without burning the proxy's radio or the sensor's battery; an +# integration that genuinely needs faster updates can pass a smaller +# scan_interval explicitly. +DEFAULT_ACTIVE_SCAN_INTERVAL: Final = 300.0 DEFAULT_ACTIVE_SCAN_DURATION: Final = 10.0 diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index ca60a1fd..80db6a48 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1069,12 +1069,11 @@ def async_register_active_scan( Declare an on-demand active-scan need for a specific address. ``scan_interval`` and ``scan_duration`` default to - DEFAULT_ACTIVE_SCAN_INTERVAL (180s, 3 minutes) and + DEFAULT_ACTIVE_SCAN_INTERVAL (300s, 5 minutes) and DEFAULT_ACTIVE_SCAN_DURATION (10s) when not provided; those - defaults work for the typical sensor case where a callback just - wants the device's scan response on a steady cadence without - burning radio time on more frequent flips than the sensor data - actually changes. The scheduler + defaults work for the typical sensor case. Integrations that + genuinely need faster updates can pass a smaller + ``scan_interval`` explicitly. The scheduler asks the AUTO-mode scanner currently in range of ``address`` to flip active for ``scan_duration`` seconds every ``scan_interval`` seconds while the device is being seen. From 0c6be071b6abaeebf5de551b9928a60655a9125c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 13:25:56 -0500 Subject: [PATCH 42/75] fix(auto): address remaining bluetoothbot review items Three suggestions from the latest bluetoothbot review: 1. _advance_due's parameter name was 'now' but the only call site passes window_end (now + duration). Rename to from_time and update the .pxd + docstring so the convention is explicit. 2. Tighten async_register_active_scan input validation: the previous scan_interval > 0 / scan_duration > 0 floors were too permissive; anything below 60s interval or 5s duration just churns the radio without giving the device time to respond on its scan response. New constants MIN_ACTIVE_SCAN_INTERVAL (60s) and MIN_ACTIVE_SCAN_DURATION (5s) drive the validation and the AUTO_WINDOW_MIN_DURATION floor in _coalesce_duration matches. 3. Document the synchronous cancel contract on AutoScanScheduler.stop: the workers' task.cancel() is fire-and-forget so the event loop reaps the cancellations on its next iteration. Harmless during HA teardown; callers outside teardown that need the workers gone should ensure the loop runs at least one more iteration. Bump the test fixture scan_duration values from 2/3/4 to 5/6/7 so they pass the new minimum. --- src/habluetooth/auto_scheduler.pxd | 2 +- src/habluetooth/auto_scheduler.py | 33 +++++++++++++++------ src/habluetooth/const.py | 12 ++++++-- src/habluetooth/manager.py | 10 ++++--- tests/test_auto_scheduler.py | 46 +++++++++++++++++------------- 5 files changed, 68 insertions(+), 35 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 11891edc..6de4741a 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -50,7 +50,7 @@ cdef class _ScannerWorker: due=list, request=ActiveScanRequest, ) - cpdef void _advance_due(self, list due_buckets, double now) + cpdef void _advance_due(self, list due_buckets, double from_time) cdef class AutoScanScheduler: diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 769329cb..ff4b2f1c 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -247,19 +247,22 @@ def _advance_due( due_buckets: list[ tuple[dict[ActiveScanRequest, float], list[ActiveScanRequest]] ], - now: float, + from_time: float, ) -> None: """ - Push every advanced request's next-due to now + scan_interval. - - Called pre-await from ``_tick`` so the window's owner has already - claimed the slot before any other worker can wake; no membership - check is needed because nothing has yielded since + Set every advanced request's next-due to from_time + scan_interval. + + ``from_time`` is whatever the caller wants the next due time + measured against (``_tick`` passes ``window_end`` so the next + window fires ``scan_interval`` after this one is expected to + finish). Called pre-await from ``_tick`` so the window's owner + has already claimed the slot before any other worker can wake; + no membership check is needed because nothing has yielded since ``_collect_due_buckets`` populated due_buckets. """ for entries, due in due_buckets: for request in due: - entries[request] = now + request.scan_interval + entries[request] = from_time + request.scan_interval async def _tick(self) -> None: """ @@ -344,7 +347,21 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: self._spawn_worker(scanner) def stop(self) -> None: - """Cancel all worker tasks.""" + """ + Cancel all worker tasks (fire-and-forget). + + Sync to match ``BluetoothManager.async_stop``. ``worker.stop()`` + calls ``task.cancel()`` but doesn't await: the cancellation + propagates on the next event-loop iteration and the task is + reaped by asyncio. If a worker is mid-``_tick`` (mid-await on + ``scanner.async_request_active_window``) when stop runs, the + scanner call may complete its current await before + ``CancelledError`` is delivered; for HA shutdown that's harmless + because the scanners themselves are being torn down. Callers + outside teardown that need to know the workers have actually + stopped should ensure the event loop runs at least one more + iteration after this returns. + """ self._running = False for worker in self._workers.values(): worker.stop() diff --git a/src/habluetooth/const.py b/src/habluetooth/const.py index 185a5170..31763342 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -61,10 +61,18 @@ AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 12 AUTO_REDISCOVERY_SWEEP_DURATION: Final = 15.0 -# Per-callback scan_duration is clamped into this range. -AUTO_WINDOW_MIN_DURATION: Final = 1.0 +# Per-callback scan_duration is clamped into this range. The floor +# matches the validation in async_register_active_scan; the ceiling is +# the longest single ACTIVE flip we'll ever do for one device tick. +AUTO_WINDOW_MIN_DURATION: Final = 5.0 AUTO_WINDOW_MAX_DURATION: Final = 30.0 +# Minimum values accepted by async_register_active_scan. Anything +# shorter would just churn the radio without giving the device time to +# respond on its scan response. +MIN_ACTIVE_SCAN_INTERVAL: Final = 60.0 +MIN_ACTIVE_SCAN_DURATION: Final = 5.0 + # Defaults used by async_register_active_scan when the caller does # not specify a cadence. One 10s active window every 5 minutes per # device covers the typical temperature/humidity/battery sensor case diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 80db6a48..f188379c 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -39,6 +39,8 @@ DEFAULT_ACTIVE_SCAN_INTERVAL, FAILED_ADAPTER_MAC, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + MIN_ACTIVE_SCAN_DURATION, + MIN_ACTIVE_SCAN_INTERVAL, UNAVAILABLE_TRACK_SECONDS, ) from .models import ( @@ -1084,10 +1086,10 @@ def async_register_active_scan( scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL if scan_duration is None: scan_duration = DEFAULT_ACTIVE_SCAN_DURATION - if scan_interval <= 0: - raise ValueError("scan_interval must be > 0") - if scan_duration < 0: - raise ValueError("scan_duration must be >= 0") + if scan_interval < MIN_ACTIVE_SCAN_INTERVAL: + raise ValueError(f"scan_interval must be >= {MIN_ACTIVE_SCAN_INTERVAL}s") + if scan_duration < MIN_ACTIVE_SCAN_DURATION: + raise ValueError(f"scan_duration must be >= {MIN_ACTIVE_SCAN_DURATION}s") request = ActiveScanRequest(address, scan_interval, scan_duration) self._auto_scheduler.add_request(request) return partial(self._auto_scheduler.remove_request, request) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 31eb424e..f96ff823 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -106,7 +106,7 @@ async def test_advertisement_starts_tracking() -> None: manager = get_manager() sched = manager._auto_scheduler cancel = manager.async_register_active_scan( - "11:22:33:44:55:66", scan_interval=120.0, scan_duration=3.0 + "11:22:33:44:55:66", scan_interval=120.0, scan_duration=6.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) @@ -171,7 +171,7 @@ async def test_worker_tick_coalesces_overlapping_requests() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel1 = manager.async_register_active_scan( - address, scan_interval=120.0, scan_duration=3.0 + address, scan_interval=120.0, scan_duration=6.0 ) cancel2 = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=10.0 @@ -199,10 +199,10 @@ async def test_multiple_requests_same_address_track_independent_intervals() -> N loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel_fast = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=2.0 + address, scan_interval=60.0, scan_duration=5.0 ) cancel_slow = manager.async_register_active_scan( - address, scan_interval=300.0, scan_duration=4.0 + address, scan_interval=300.0, scan_duration=7.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) @@ -214,13 +214,13 @@ async def test_multiple_requests_same_address_track_independent_intervals() -> N entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() + 200.0 await _run_worker_tick(sched, scanner.source) - assert scanner.active_window_calls == [2.0] + assert scanner.active_window_calls == [5.0] assert entries[fast] > loop.time() assert entries[slow] > loop.time() + 100 entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() - 1.0 await _run_worker_tick(sched, scanner.source) - assert scanner.active_window_calls == [2.0, 4.0] + assert scanner.active_window_calls == [5.0, 7.0] finally: cancel_fast() cancel_slow() @@ -502,16 +502,22 @@ async def test_on_advertisement_re_bootstraps_pruned_tracking() -> None: @pytest.mark.asyncio async def test_register_active_scan_validates_inputs() -> None: - """Invalid scan_interval / scan_duration raise ValueError.""" + """scan_interval / scan_duration below the configured minimums raise.""" manager = get_manager() - with pytest.raises(ValueError, match="scan_interval must be > 0"): + # scan_interval below 60s. + with pytest.raises(ValueError, match="scan_interval must be >="): manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=0) - with pytest.raises(ValueError, match="scan_interval must be > 0"): - manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=-1) - with pytest.raises(ValueError, match="scan_duration must be >= 0"): + with pytest.raises(ValueError, match="scan_interval must be >="): + manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=30.0) + # scan_duration below 5s. + with pytest.raises(ValueError, match="scan_duration must be >="): manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=-0.5 ) + with pytest.raises(ValueError, match="scan_duration must be >="): + manager.async_register_active_scan( + "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=4.5 + ) @pytest.mark.asyncio @@ -565,7 +571,7 @@ async def test_dispatch_does_not_resurrect_cancelled_request() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=3.0 + address, scan_interval=60.0, scan_duration=6.0 ) gate = asyncio.Event() @@ -1051,10 +1057,10 @@ async def test_coalesce_three_due_uses_max_clamped() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" c1 = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=2.0 + address, scan_interval=60.0, scan_duration=5.0 ) c2 = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=4.0 + address, scan_interval=60.0, scan_duration=7.0 ) c3 = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=9.0 @@ -1136,7 +1142,7 @@ async def test_coalesce_only_due_requests_count() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" c_short = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=2.0 + address, scan_interval=60.0, scan_duration=5.0 ) c_long = manager.async_register_active_scan( address, scan_interval=300.0, scan_duration=20.0 @@ -1146,14 +1152,14 @@ async def test_coalesce_only_due_requests_count() -> None: try: _inject(scanner, address) entries = sched._needs[address] - short_req = next(r for r in entries if r.scan_duration == 2.0) + short_req = next(r for r in entries if r.scan_duration == 5.0) long_req = next(r for r in entries if r.scan_duration == 20.0) # Only the short request is due; the long one is well in the # future and must not pull its bigger duration into the window. entries[short_req] = loop.time() - 1.0 entries[long_req] = loop.time() + 200.0 await sched._workers[scanner.source]._tick() - assert scanner.active_window_calls == [2.0] + assert scanner.active_window_calls == [5.0] finally: c_short() c_long() @@ -1169,7 +1175,7 @@ async def test_coalesce_distinct_addresses_share_one_window() -> None: addr_a = "11:22:33:44:55:01" addr_b = "11:22:33:44:55:02" c1 = manager.async_register_active_scan( - addr_a, scan_interval=60.0, scan_duration=3.0 + addr_a, scan_interval=60.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=60.0, scan_duration=7.0 @@ -1201,7 +1207,7 @@ async def test_tick_combines_due_sweep_and_per_device_into_one_window() -> None: loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=3.0 + address, scan_interval=60.0, scan_duration=6.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) @@ -1273,7 +1279,7 @@ async def test_dispatch_coalesces_different_durations_to_max() -> None: addr_short = "11:22:33:44:55:01" addr_long = "11:22:33:44:55:02" c_short = manager.async_register_active_scan( - addr_short, scan_interval=60.0, scan_duration=3.0 + addr_short, scan_interval=60.0, scan_duration=6.0 ) c_long = manager.async_register_active_scan( addr_long, scan_interval=60.0, scan_duration=12.0 From dd1520eccc24263c3c186d6a98afa1b8e41f3c0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 13:47:39 -0500 Subject: [PATCH 43/75] docs(auto): note _next_event_at O(M) cost is intentional at HA scale The bot flagged _next_event_at iterating every tracked address on every wake; at HA's typical scale (a few dozen devices) the cost is negligible and not worth a per-worker invariant. Document the trade-off so the next person reading the code knows to invert it only if M becomes large. --- src/habluetooth/auto_scheduler.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index ff4b2f1c..84af8074 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -170,7 +170,15 @@ def wake(self) -> None: self._wake.set() def _next_event_at(self, now: float) -> float: - """Return the earliest loop-time at which this worker has work.""" + """ + Return the earliest loop-time at which this worker has work. + + O(M) over every tracked address per wake. Acceptable at HA's + typical scale (a few dozen registered devices per manager); if + the API gets adopted by deployments with hundreds of registered + devices, replace with a per-worker invariant maintained at + add_request/on_advertisement/_advance_due time so this is O(1). + """ if self._window_end > now: return self._window_end next_at = self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL From 2c57faf403e5faa7b1c756123ad181734ae69783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:22:58 -0500 Subject: [PATCH 44/75] fix(auto): wake new owner's worker when a device migrates between scanners A device whose advertisement moves from scanner A to scanner B (with strong-enough RSSI that the manager flips _all_history[addr].source to B) was not waking B's worker if the address's _needs entry already existed and B's last advertisement was the same payload as A's. Two bugs combined: 1. on_advertisement only wakes the worker when it ADDED a new entry, so steady-state ads on already-tracked addresses never woke the new owner mid-sleep. 2. The manager skips dispatching to the scheduler when the advertisement payload is identical to the previous one (same mfg_data / service_data / etc), which on its own is harmless for discovery but suppresses the ownership-flip signal for static beacons. Always wake the source's worker from on_advertisement when the address has registered active-scan requests, and move the scheduler hook in BluetoothManager._scanner_adv_received up to right after _all_history is updated so the same-payload short-circuit no longer hides flips. Tests prove the migration path: - test_device_migration_between_scanners_fires_on_new_owner: A fires the first window, RSSI flip moves the device to B, the next window fires on B (not A). - test_device_migration_wakes_new_owner_worker: the wake event on B's worker is set when B becomes the new owner via an ad with stronger RSSI. Existing wake-suppression tests inverted to assert the new contract. --- src/habluetooth/auto_scheduler.pxd | 1 - src/habluetooth/auto_scheduler.py | 19 +++- src/habluetooth/manager.py | 17 +++- tests/test_auto_scheduler.py | 146 ++++++++++++++++++++++++++--- 4 files changed, 157 insertions(+), 26 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 6de4741a..91720b02 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -75,7 +75,6 @@ cdef class AutoScanScheduler: existing=dict, requests=set, request=ActiveScanRequest, - added=bint, ) cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 84af8074..c635ff08 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -432,7 +432,19 @@ def remove_request(self, request: ActiveScanRequest) -> None: del self._needs[request.address] def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: - """Hot path. Track requests for the advertisement's address.""" + """ + Hot path. Track requests for the advertisement's address. + + Always wakes the worker for ``service_info.source`` when the + address has registered active-scan requests. The wake covers + two cases: (1) bootstrap, when an entry is created in _needs + because the previous owner was pruned; (2) ownership flip, + when this scanner becomes the device's new owner and its + worker needs to re-evaluate _next_event_at to include the + (already-tracked) entry. A single wake() is one Event.set + call; cheap enough to do per accepted advertisement on a + tracked address. + """ if not self._requests_by_address or self._loop is None: return address = service_info.address @@ -440,15 +452,12 @@ def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: if requests is None: return existing = self._needs.get(address) - added = False for request in requests: if existing is None: existing = self._needs[address] = {} if request not in existing: existing[request] = self._loop.time() + request.scan_interval - added = True - if added: - self._wake_worker(service_info.source) + self._wake_worker(service_info.source) def _wake_worker(self, source: str) -> None: """Wake the worker for ``source`` if one is registered.""" diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index f188379c..84f5d357 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -821,6 +821,18 @@ def _scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None self._all_history[service_info.address] = service_info + # Hand the advertisement to the auto-scan scheduler right after + # _all_history is updated. Ownership-flip detection (a different + # scanner taking over a device's source) needs to fire even when + # the advertisement payload is identical to the previous one; + # the data-comparison short-circuit below would otherwise hide + # that flip from the scheduler. Local-typed assignment so + # cython.locals casts to AutoScanScheduler and the call is a + # direct vtable dispatch even though _auto_scheduler is stored + # untyped on BluetoothManager. + auto_scheduler = self._auto_scheduler + auto_scheduler.on_advertisement(service_info) + # Track advertisement intervals to determine when we need to # switch adapters or mark a device as unavailable if ( @@ -891,11 +903,6 @@ def _scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None bleak_callback, service_info.device, advertisement_data ) - # Local-typed assignment so cython.locals casts to AutoScanScheduler - # and the call below is a direct vtable dispatch even though - # _auto_scheduler is stored untyped on BluetoothManager. - auto_scheduler = self._auto_scheduler - auto_scheduler.on_advertisement(service_info) self._subclass_discover_info(service_info) def async_clear_advertisement_history(self, address: str) -> None: diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index f96ff823..1d2ae09d 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -895,16 +895,18 @@ async def test_on_advertisement_no_match_no_wake() -> None: @pytest.mark.asyncio -async def test_on_advertisement_existing_entry_no_extra_wake() -> None: - """A second ad for a tracked address with multiple requests skips all.""" +async def test_on_advertisement_wakes_on_every_ad_for_tracked_address() -> None: + """ + Every ad on a tracked address wakes the source's worker. + + The wake is what makes ownership-flip detection work: when this + scanner becomes the new owner mid-sleep, the wake forces the + worker to re-evaluate _next_event_at and pick up the entry that + is now owned by it. + """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" - # Two registrations for the same address so the for-loop in - # on_advertisement iterates twice; both requests must be present - # in _needs after the first inject, so the second inject takes - # the request-in-existing branch on every iteration and added - # stays False. cancel1 = manager.async_register_active_scan(address, scan_interval=60.0) cancel2 = manager.async_register_active_scan(address, scan_interval=120.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) @@ -914,7 +916,10 @@ async def test_on_advertisement_existing_entry_no_extra_wake() -> None: _inject(scanner, address) worker._wake.clear() _inject(scanner, address) - assert not worker._wake.is_set() + # Second inject still wakes; the wake is unconditional now so + # ownership flips on an existing entry are seen by the new + # owner. + assert worker._wake.is_set() finally: cancel1() cancel2() @@ -923,22 +928,17 @@ async def test_on_advertisement_existing_entry_no_extra_wake() -> None: @pytest.mark.asyncio async def test_on_advertisement_with_all_requests_already_tracked() -> None: - """Direct exercise of the existing-entries skip path inside the for-loop.""" + """on_advertisement still wakes when every request is already in _needs.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) address = "11:22:33:44:55:66" - # Build two requests in the registry directly so we know exactly - # what's in _requests_by_address; pre-populate _needs with both so - # on_advertisement's for-loop iterates twice and skips both. req_a = ActiveScanRequest(address, 60.0, None) req_b = ActiveScanRequest(address, 120.0, None) sched._requests_by_address[address] = {req_a, req_b} sched._needs[address] = {req_a: 0.0, req_b: 0.0} try: - # Drive on_advertisement directly; both requests are present so - # added stays False and the wake path is skipped. si = BluetoothServiceInfoBleak( name="x", address=address, @@ -957,7 +957,9 @@ async def test_on_advertisement_with_all_requests_already_tracked() -> None: worker = sched._workers[scanner.source] worker._wake.clear() sched.on_advertisement(si) - assert not worker._wake.is_set() + # Wake fires unconditionally so ownership-flip detection still + # triggers when every request was already in _needs. + assert worker._wake.is_set() # Sanity: the entries we put in are untouched. assert sched._needs[address] == {req_a: 0.0, req_b: 0.0} finally: @@ -1534,3 +1536,117 @@ async def test_owner_flip_during_window_does_not_double_fire() -> None: c_a() c_b() cancel() + + +def _inject_with_rssi(scanner: _RecordingAutoScanner, address: str, rssi: int) -> None: + """Drive an advertisement through the scanner with a specific RSSI.""" + adv = generate_advertisement_data(local_name="x", rssi=rssi) + device = generate_ble_device(address, "x") + scanner._async_on_advertisement( + device.address, + adv.rssi, + device.name or "", + adv.service_uuids, + adv.service_data, + adv.manufacturer_data, + adv.tx_power, + {}, + asyncio.get_running_loop().time(), + ) + + +@pytest.mark.asyncio +async def test_device_migration_between_scanners_fires_on_new_owner() -> None: + """ + Migrating from scanner A to B fires the next window on B, not on A. + + Sequence: register active_scan. A sees the device first and becomes + owner. A's worker fires the first window. The device then comes + through B with a much stronger RSSI so the manager's + ADV_RSSI_SWITCH_THRESHOLD flips ownership. Make the entry due + again and tick both workers: B fires the new window, A skips. + """ + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:99" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=5.0 + ) + s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) + s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) + c_a = manager.async_register_scanner(s_a) + c_b = manager.async_register_scanner(s_b) + try: + # A sees the device first; A becomes owner. + _inject_with_rssi(s_a, address, rssi=-80) + info_a = manager.async_last_service_info(address, False) + assert info_a is not None + assert info_a.source == s_a.source + + # Make the existing tracking entry due and fire the first + # window on A. + entries = sched._needs[address] + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[s_a.source]._tick() + assert s_a.active_window_calls == [5.0] + assert s_b.active_window_calls == [] + + # Device migrates to B with much stronger signal (delta beats + # ADV_RSSI_SWITCH_THRESHOLD). The manager flips + # _all_history.source to B. + _inject_with_rssi(s_b, address, rssi=-30) + info_b = manager.async_last_service_info(address, False) + assert info_b is not None + assert info_b.source == s_b.source + + # Force the entry due again and run both workers. B (the new + # owner) fires; A skips because history.source is no longer + # A's source. + for req in list(entries): + entries[req] = loop.time() - 1.0 + await sched._workers[s_a.source]._tick() + await sched._workers[s_b.source]._tick() + assert s_a.active_window_calls == [5.0] + assert s_b.active_window_calls == [5.0] + finally: + c_a() + c_b() + cancel() + + +@pytest.mark.asyncio +async def test_device_migration_wakes_new_owner_worker() -> None: + """ + A fresh advertisement on the new owner wakes its worker. + + Without this wake, a worker that became the owner mid-sleep would + sit until its previously-computed _next_event_at (sweep cadence) + even though there's a tracked address whose due time is much + sooner. The wake is on_advertisement's job and must fire even when + the _needs entry already exists (i.e. the ad doesn't add a new + request, it just notifies us this scanner now sees the device). + """ + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:AA" + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) + s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) + c_a = manager.async_register_scanner(s_a) + c_b = manager.async_register_scanner(s_b) + try: + # A sees the device first. + _inject_with_rssi(s_a, address, rssi=-80) + worker_b = sched._workers[s_b.source] + worker_b._wake.clear() + # B sees the device with stronger RSSI and becomes the new + # owner. B's worker must be woken so it re-evaluates + # _next_event_at and picks up the existing entry. + _inject_with_rssi(s_b, address, rssi=-30) + assert worker_b._wake.is_set() + finally: + c_a() + c_b() + cancel() From 7428a8817d98f3c56bb8221f5dced8f4e400154c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:24:18 -0500 Subject: [PATCH 45/75] docs(auto): update scheduler diagram and add a Migration section Reflects the recent changes around device-migration handling: - on_advertisement always wakes the source's worker (not just on a fresh entry), so an ownership flip wakes the new owner. - _tick advances next-due pre-await so an in-flight window cannot be duplicated by the new owner. - Added a 'Migration' section walking through what happens when _all_history[addr].source flips from one scanner to another. - Added a fifth invariant covering the wake-on-every-tracked-ad contract. - Diagram updated: the 'always wake adv.source's worker' branch on on_advertisement is now explicit. --- src/habluetooth/auto_scheduler.py | 42 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index c635ff08..e3203e35 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -11,9 +11,9 @@ (``manager.async_last_service_info(address).source``). If three other AUTO scanners can also see the device, they stay PASSIVE for that window. Ownership can flip across scanners over time as RSSI changes; -the next-due window then fires on the new owner. Sweeps are different -and run on every AUTO scanner independently, since their job is to -find devices not yet in history. +the next-due window then fires on the new owner (see "Migration" +below). Sweeps are different and run on every AUTO scanner +independently, since their job is to find devices not yet in history. Flow @@ -21,9 +21,9 @@ add_request(req) on_advertisement(adv) | | - | seed _needs[addr][req] | re-seed if pruned - | = now + scan_interval | = now + scan_interval - | wake address's owner | wake adv.source + | seed _needs[addr][req] | seed if pruned; + | = now + scan_interval | always wake + | wake address's owner | adv.source's worker v v +------------------------------------------+ | AutoScanScheduler | @@ -52,12 +52,35 @@ | 2. sweep_due = sweep cadence elapsed | | 3. duration = max(due durations, | | SWEEP_DURATION if sweep_due) | - | 4. ONE await: | + | 4. _advance_due (pre-await) so the | + | new owner of any of these | + | addresses can't double-fire | + | 5. ONE await: | | scanner.async_request_active_window| - | 5. _advance_due / advance sweep clock | +------------------------------------------+ +Migration +========= + +When a device moves from scanner A to scanner B (RSSI flip; manager +swaps ``_all_history[addr].source`` from A to B), the scheduler picks +up the new owner without any address-level rescheduling: + +1. The manager's ``_scanner_adv_received`` updates ``_all_history`` + and then calls ``auto_scheduler.on_advertisement(service_info)`` + *before* the same-payload short-circuit, so the flip is visible to + the scheduler even for static-payload beacons. +2. ``on_advertisement`` always calls ``_wake_worker(adv.source)`` when + the address has registered requests. B's worker wakes up. +3. On B's next ``_tick``, ``_collect_due_buckets`` reads + ``last_service_info(addr).source`` and sees B; the entry is + collected and dispatched. A's worker on its own next tick sees + ``last_service_info(addr).source != A`` and skips. +4. The pre-await ``_advance_due`` in step 4 of ``_tick`` prevents A + from double-firing if the flip lands mid-window. + + Invariants ========== @@ -73,6 +96,9 @@ * A registration kick-starts tracking immediately; ``on_advertisement`` is the fallback that re-creates the entry if the worker pruned it because the device's history was missing at tick time. +* Every accepted advertisement on a tracked address wakes the source's + worker so an ownership flip on the same scanner triggers a + re-evaluation of ``_next_event_at``. """ from __future__ import annotations From 472d953ea9ccd47f67700aa89578ccb861757d24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:35:53 -0500 Subject: [PATCH 46/75] docs(auto): clean up stale comments and tighten edge cases Whole-PR review found a few leftovers from earlier refactors: - _tick docstring still mentioned 'the prior design's global sweep lock was over-engineered'; that lock is gone, the historical comment is just noise. Rewrite to focus on what _tick does now and call out that the return value of async_request_active_window is intentionally ignored. - BaseHaScanner.async_request_active_window docstring said the scheduler 'relies on a True return value to know the window actually ran'; that hasn't been true since _tick stopped branching on the return value. Document the actual contract: True means the radio flipped, False means the request was ignored, and the scheduler advances entries by scan_interval regardless. - AutoScanScheduler.start() didn't guard against being called twice; harmless in production (manager.async_setup only calls it once) but a second start would spawn duplicate workers. Add an 'scanner.source not in self._workers' check so it's idempotent. - Drop the dead _drain() helper from tests/test_auto_scheduler.py (was used by the global-sweep test that got removed when the lock came out). --- src/habluetooth/auto_scheduler.py | 28 ++++++++++++++++++---------- src/habluetooth/base_scanner.py | 12 ++++++++---- tests/test_auto_scheduler.py | 6 ------ 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index e3203e35..f4d3ff1e 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -303,14 +303,13 @@ async def _tick(self) -> None: Fire one coalesced window covering due per-device + sweep work. Collection is sync; only the scanner's active-window call is - awaited. The window - duration is the max of every due per-device duration and (if the - sweep is due) the configured sweep duration; a single ACTIVE flip - catches every device the scanner sees during the window so - back-to-back windows would only churn the radio. Scanners stagger - their first sweep at registration time so concurrent sweeps are - unlikely; BLE radios don't actually interfere when more than one - is active so the prior design's global sweep lock was over-engineered. + awaited. The window duration is the max of every due per-device + duration and (if the sweep is due) the configured sweep duration + so a single ACTIVE flip on the scanner catches every device it + sees during the window. The return value of + ``async_request_active_window`` is intentionally ignored: even on + failure we still advance the entry by ``scan_interval`` so a + stuck scanner can't busy-loop the worker. """ loop = self._scheduler._loop if loop is None: @@ -373,11 +372,20 @@ def __init__(self, manager: BluetoothManager) -> None: self._running = False def start(self, loop: asyncio.AbstractEventLoop) -> None: - """Bind to the event loop and spawn one worker per AUTO scanner.""" + """ + Bind to the event loop and spawn one worker per AUTO scanner. + + Idempotent on the worker-spawn side: ``_workers`` is only added + to for sources that don't already have a worker, so a second + ``start()`` call won't create duplicate tasks. + """ self._loop = loop self._running = True for scanner in self._manager.async_current_scanners(): - if scanner.requested_mode is BluetoothScanningMode.AUTO: + if ( + scanner.requested_mode is BluetoothScanningMode.AUTO + and scanner.source not in self._workers + ): self._spawn_worker(scanner) def stop(self) -> None: diff --git a/src/habluetooth/base_scanner.py b/src/habluetooth/base_scanner.py index 3884ac7b..a42bc9fa 100644 --- a/src/habluetooth/base_scanner.py +++ b/src/habluetooth/base_scanner.py @@ -716,10 +716,14 @@ async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds, then restore prior mode. - Default no-op implementation. Subclasses that can flip the underlying - adapter / proxy into active scanning on demand should override and - return True on success. The manager's auto-mode scheduler relies on - a True return value to know the window actually ran. + Default no-op returning False. Subclasses that can flip the + underlying adapter / proxy into active scanning on demand should + override; ``True`` indicates the override actually flipped the + radio, ``False`` that the request was ignored. The current + scheduler does not branch on the return value (entries advance + by ``scan_interval`` regardless to avoid busy-looping a stuck + scanner), but the contract leaves room for callers that want + to surface a failed window. """ _LOGGER.debug( "%s: scanner does not support on-demand active windows", self.name diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 1d2ae09d..8b0157a5 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -88,12 +88,6 @@ def _inject(scanner: _RecordingAutoScanner, address: str) -> None: ) -async def _drain() -> None: - """Yield several times so worker tasks can process.""" - for _ in range(4): - await asyncio.sleep(0) - - async def _run_worker_tick(scheduler: object, source: str) -> None: """Drive one worker through a single tick for deterministic testing.""" worker = scheduler._workers[source] # type: ignore[attr-defined] From 4636739c1b635f3e1f08f3c4d1c8d5e1a1d467a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:38:22 -0500 Subject: [PATCH 47/75] fix(auto): only wake the owner's worker when add_request actually inserts add_request was waking the worker even when re-registering an identical request (HA config-entry reload path) where _needs was unchanged. Gate the wake on a fresh insertion the same way on_advertisement does so wake means 'there is actually new work', not 'someone touched the request graph'. Extend test_add_request_idempotent_keeps_existing_due to assert the wake event stays cleared across a re-register and add an 'added=bint' local to the pxd so cython types the new flag. The other two items from the latest bluetoothbot review were already in place before this commit: - The MIN_ACTIVE_SCAN_* and DEFAULT_ACTIVE_SCAN_* constants are wired into async_register_active_scan (validation and default fallback) as of 0c6be07 / afeecef. - start() does not spawn duplicates as of 472d953 (the 'scanner.source not in self._workers' check). --- src/habluetooth/auto_scheduler.pxd | 4 ++++ src/habluetooth/auto_scheduler.py | 9 +++++++++ tests/test_auto_scheduler.py | 31 +++++++++++++++++++++++------- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 91720b02..e71d206a 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -62,6 +62,10 @@ cdef class AutoScanScheduler: cdef public object _loop cdef public bint _running + @cython.locals( + existing=dict, + added=bint, + ) cpdef void add_request(self, ActiveScanRequest request) cpdef void remove_request(self, ActiveScanRequest request) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index f4d3ff1e..f1b5ba80 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -444,12 +444,21 @@ def add_request(self, request: ActiveScanRequest) -> None: fire on a scanner that hasn't seen the device yet). If the entry gets pruned later because the device's history disappears, on_advertisement re-creates it the next time the device is seen. + + Only wakes the owner's worker when this call actually inserted + a fresh entry into ``_needs``; re-registering an identical + request (e.g., from an HA config-entry reload) is a no-op on + the schedule so the wake would just churn ``_next_event_at``. """ self._requests_by_address.setdefault(request.address, set()).add(request) + added = False if self._loop is not None: existing = self._needs.setdefault(request.address, {}) if request not in existing: existing[request] = self._loop.time() + request.scan_interval + added = True + if not added: + return history = self._manager.async_last_service_info(request.address, False) if history is not None: self._wake_worker(history.source) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 8b0157a5..09460421 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -1438,16 +1438,33 @@ async def test_add_request_before_start_does_not_seed_needs() -> None: @pytest.mark.asyncio async def test_add_request_idempotent_keeps_existing_due() -> None: - """Re-adding the same request preserves its existing next-due time.""" + """ + Re-adding the same request preserves its existing next-due time. + + Also verifies the wake is gated on "actually inserted a new entry": + a re-register (e.g. an HA config-entry reload) is a no-op on the + schedule, so the worker should not be woken. + """ manager = get_manager() sched = manager._auto_scheduler address = "BC:00:00:00:00:00" - request = ActiveScanRequest(address, 60.0, None) - sched.add_request(request) - sched._needs[address][request] = 1234.5 - sched.add_request(request) - assert sched._needs[address][request] == 1234.5 - sched.remove_request(request) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:42", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + request = ActiveScanRequest(address, 60.0, None) + sched.add_request(request) + # Inject so add_request can see history on the second call. + _inject(scanner, address) + sched._needs[address][request] = 1234.5 + worker = sched._workers[scanner.source] + worker._wake.clear() + sched.add_request(request) + assert sched._needs[address][request] == 1234.5 + # No new entry → no wake. + assert not worker._wake.is_set() + sched.remove_request(request) + finally: + register_cancel() @pytest.mark.asyncio From cf00c174774167567ce2c60527a37548c0b24c6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:49:28 -0500 Subject: [PATCH 48/75] fix(auto): address bot suggestions on slots, stagger, and timer drift Three review items in one commit: - Add __slots__ to _ScannerWorker so SKIP_CYTHON=1 builds don't allocate a per-worker __dict__; matches ActiveScanRequest and AutoScanScheduler which already had slots. The cdef class declaration in the .pxd already covers the cython build. - Bound the first-sweep stagger offset with a modulo into the AUTO_INITIAL_SWEEP_DELAY window. The previous offset compounded linearly with worker count (the 20th scanner's first sweep was ~10min + 5min out), now it wraps after roughly AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION scanners. Collisions past that point are harmless since BLE radios don't interfere when multiple are active. - Compute _active_window_end from loop.time() at the moment the timer is armed instead of pre-computing it before the stop/restart cycle. Copilot caught that the precomputed new_end drifted out of sync with the real call_later fire time during the restart, which let a *shorter* follow-up request masquerade as an extension and silently shorten the active window. Drop the new_end parameter from _arm_active_window_timer entirely. --- src/habluetooth/auto_scheduler.py | 25 +++++++++++++++++++++---- src/habluetooth/scanner.py | 25 +++++++++++++++++-------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index f1b5ba80..d16856a6 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -153,6 +153,16 @@ def __init__( class _ScannerWorker: """One persistent task per AUTO scanner; sleeps until next due event.""" + __slots__ = ( + "_manager", + "_scanner", + "_scheduler", + "_sweep_last_completed", + "_task", + "_wake", + "_window_end", + ) + def __init__( self, scheduler: AutoScanScheduler, @@ -428,10 +438,17 @@ def _spawn_worker(self, scanner: BaseHaScanner) -> None: worker = _ScannerWorker(self, scanner, self._manager) # Stagger first sweeps so concurrently-registered scanners don't # all flip ACTIVE in the same second. Each new worker's first - # sweep is one sweep duration later than the previous one's; the - # offset compounds so a tenth scanner registered in the same - # batch fires its first sweep ~150s after the first one's. - offset = len(self._workers) * _AUTO_REDISCOVERY_SWEEP_DURATION + # sweep is one sweep duration later than the previous one's, + # wrapped into the initial-sweep window so the Nth scanner's + # first sweep is bounded to AUTO_INITIAL_SWEEP_DELAY + delay + # rather than growing linearly with worker count. Past + # AUTO_INITIAL_SWEEP_DELAY / SWEEP_DURATION scanners the offsets + # start to repeat, which is fine: BLE radios don't interfere + # when multiple are active so collisions are harmless and the + # natural advertisement jitter spreads them out over time. + offset = ( + len(self._workers) * _AUTO_REDISCOVERY_SWEEP_DURATION + ) % _AUTO_INITIAL_SWEEP_DELAY worker.start(self._loop, offset) self._workers[scanner.source] = worker diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 2d22bcd3..ed9646f7 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -653,11 +653,21 @@ def _clear_active_window_state(self) -> None: self._scan_mode_override = None self._active_window_end = 0.0 - def _arm_active_window_timer(self, duration: float, new_end: float) -> None: - """Schedule the end-of-window callback.""" + def _arm_active_window_timer(self, duration: float) -> None: + """ + Schedule the end-of-window callback. + + Computes ``_active_window_end`` from ``loop.time()`` at the + moment of arming so it matches the actual fire time of the + underlying ``call_later``. Earlier versions accepted an + externally-computed ``new_end`` snapshot, which drifted out of + sync with the real timer fire time across the stop/restart + cycle and let a *shorter* follow-up request masquerade as an + extension. + """ if TYPE_CHECKING: assert self._loop is not None - self._active_window_end = new_end + self._active_window_end = self._loop.time() + duration self._active_window_handle = self._loop.call_later( duration, self._schedule_end_active_window ) @@ -673,11 +683,10 @@ async def async_request_active_window(self, duration: float) -> bool: return False if TYPE_CHECKING: assert self._loop is not None - new_end = self._loop.time() + duration if self._active_window_handle is not None: - if new_end > self._active_window_end: + if self._loop.time() + duration > self._active_window_end: self._active_window_handle.cancel() - self._arm_active_window_timer(duration, new_end) + self._arm_active_window_timer(duration) return True async with self._start_stop_lock: self._scan_mode_override = BluetoothScanningMode.ACTIVE @@ -688,7 +697,7 @@ async def async_request_active_window(self, duration: float) -> bool: # see the new handle and bail when it acquires the lock. mode_before_restart = self.current_mode if mode_before_restart is BluetoothScanningMode.ACTIVE: - self._arm_active_window_timer(duration, new_end) + self._arm_active_window_timer(duration) return True try: await self._async_stop_then_start_under_lock() @@ -705,7 +714,7 @@ async def async_request_active_window(self, duration: float) -> bool: # Linux's 4th-attempt fallback silently drops to PASSIVE. self._scan_mode_override = None return False - self._arm_active_window_timer(duration, new_end) + self._arm_active_window_timer(duration) return True def _schedule_end_active_window(self) -> None: From 06c91a4b88cd8383e38c8d25352e0e7bb5515233 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 14:51:53 -0500 Subject: [PATCH 49/75] test(scanner): lock in active-window timer drift fix Regression test for Copilot's PR review note: pre-fix, async_request_active_window stored _active_window_end as loop.time()+duration captured BEFORE the stop/restart cycle, so the stored end-time lagged the real call_later fire time by the restart duration. A subsequent follow-up whose new_end landed in that gap would cancel the live timer and arm a shorter one. Test uses a mock BleakScanner whose start() sleeps long enough (100ms) to push the gap well above any approx tolerance, asserts _active_window_end matches loop.time()+duration measured AFTER the restart, and asserts a request whose new_end lands in the pre-fix-gap (above stale stored end, below real fire) does NOT replace the live timer handle. --- tests/test_scanner.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_scanner.py b/tests/test_scanner.py index a27a73af..ca1e54bb 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1821,6 +1821,77 @@ def _factory(*_args, **kwargs): await scanner.async_stop() +@pytest.mark.asyncio +async def test_async_request_active_window_end_time_matches_real_timer() -> None: + """ + _active_window_end reflects the post-restart loop.time() + duration. + + Regression: a slow stop/restart cycle previously left + ``_active_window_end`` set to ``loop.time() + duration`` captured + *before* the restart, so it lagged the real ``call_later`` fire + time by the restart duration. The fix moved the + ``_active_window_end`` computation inside + ``_arm_active_window_timer`` so it always matches when the timer + will actually fire. A subsequent request with a duration just + slightly above the lagged stored end-time but below the real + fire time would otherwise have cancelled the live timer and + armed a shorter one, silently shortening the active window. + """ + # Big enough that the pre-fix lag (~0.1s) is well above any + # approx-tolerance noise, but small enough to keep the test + # quick. + restart_sleep = 0.1 + duration = 10.0 + + class SlowMockBleakScanner: + async def start(self): + await asyncio.sleep(restart_sleep) + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *a, **k: SlowMockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + + loop = asyncio.get_running_loop() + before = loop.time() + assert await scanner.async_request_active_window(duration) is True + after = loop.time() + # Sanity: the restart actually took the slow path. + assert after - before >= restart_sleep + # The stored end-time must match the post-restart fire time + # (loop.time() + duration), not before + duration. Pre-fix + # this assertion fails by ~restart_sleep. + assert scanner._active_window_end == pytest.approx(after + duration, abs=0.01) + first_handle = scanner._active_window_handle + + # A follow-up whose new_end lands between the pre-fix stored + # end and the real fire time must NOT be treated as an + # extension. With the fix this is rejected; without it the + # live timer would be cancelled and armed shorter. + # Pick a duration that puts new_end at before + duration + + # restart_sleep/2 - i.e. above the stale stored end but below + # the real fire time. + target_new_end = before + duration + restart_sleep / 2 + shorter_duration = target_new_end - loop.time() + assert await scanner.async_request_active_window(shorter_duration) is True + assert scanner._active_window_handle is first_handle + + await scanner.async_stop() + + @pytest.mark.asyncio async def test_async_request_active_window_skips_restart_if_still_active() -> None: """ From 23ab2df093d88280e9c31172cfe1fd16c0c40a6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:05:56 -0500 Subject: [PATCH 50/75] fix(auto): measure scan_interval between window starts, not after window end Copilot caught a docstring/behavior mismatch: the public docstring on async_register_active_scan said the scanner flips active "every scan_interval seconds", but _tick was advancing next-due from window_end so the real cadence was scan_interval + scan_duration. The same applied to _sweep_last_completed. Switch both advances to use the tick's start time (`now`) so scan_interval is a true period between window starts. Cadence is now exact and doesn't drift with the actual stop/start cost. Update the relevant docstrings. Drive-by perf: fold the duplicate loop.time() calls at the top of _tick (window-end guard + now) into a single call. Tests: - test_worker_tick_advances_by_scan_interval_from_window_start asserts the advanced next-due is now + scan_interval (not window_end + scan_interval) for a window where duration is large enough to make the difference visible. - test_first_sweep_stagger_wraps_past_window_size builds N+1 scanners (N = AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION) and asserts the wrap-around scanner's first-sweep time matches the first scanner's, locking in the modulo-cap contract. - test_active_scan_registered_before_auto_scanner_wakes_on_register covers the deployment shape where async_register_active_scan is called before any AUTO scanner exists; later add_scanner(AUTO) + first advertisement must wake the new worker. --- src/habluetooth/auto_scheduler.py | 49 ++++++++------ src/habluetooth/manager.py | 12 ++-- tests/test_auto_scheduler.py | 106 ++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 27 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index d16856a6..755bc7a1 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -296,13 +296,13 @@ def _advance_due( """ Set every advanced request's next-due to from_time + scan_interval. - ``from_time`` is whatever the caller wants the next due time - measured against (``_tick`` passes ``window_end`` so the next - window fires ``scan_interval`` after this one is expected to - finish). Called pre-await from ``_tick`` so the window's owner - has already claimed the slot before any other worker can wake; - no membership check is needed because nothing has yielded since - ``_collect_due_buckets`` populated due_buckets. + ``from_time`` is the timestamp the next-due is measured against; + ``_tick`` passes the tick's start ``now`` so ``scan_interval`` + is the period between window starts. Called pre-await from + ``_tick`` so the window's owner has already claimed the slot + before any other worker can wake; no membership check is needed + because nothing has yielded since ``_collect_due_buckets`` + populated due_buckets. """ for entries, due in due_buckets: for request in due: @@ -316,18 +316,21 @@ async def _tick(self) -> None: awaited. The window duration is the max of every due per-device duration and (if the sweep is due) the configured sweep duration so a single ACTIVE flip on the scanner catches every device it - sees during the window. The return value of - ``async_request_active_window`` is intentionally ignored: even on - failure we still advance the entry by ``scan_interval`` so a - stuck scanner can't busy-loop the worker. + sees during the window. ``scan_interval`` is measured between + window *starts* (not after each window ends), so the next due + time advances from ``now`` (this tick's start) rather than from + ``window_end``; the same applies to the sweep clock. The return + value of ``async_request_active_window`` is intentionally + ignored: even on failure we still advance by ``scan_interval`` + so a stuck scanner can't busy-loop the worker. """ loop = self._scheduler._loop if loop is None: return - if self._window_end > loop.time(): + now = loop.time() + if self._window_end > now: return self._window_end = 0.0 - now = loop.time() due_buckets, all_due = self._collect_due_buckets(now) sweep_due = now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL if not all_due and not sweep_due: @@ -335,19 +338,23 @@ async def _tick(self) -> None: duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0 if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: duration = _AUTO_REDISCOVERY_SWEEP_DURATION - window_end = now + duration - self._window_end = window_end + self._window_end = now + duration # Advance per-device next-due times and the sweep clock BEFORE the # await so a concurrent worker that becomes the new owner of any # of these addresses mid-window (e.g. an RSSI flip on a fresh # advertisement) doesn't fire a duplicate window for the same - # request. Failure of the scanner call is handled the same way as - # success: we still don't retry until scan_interval out (or - # AUTO_REDISCOVERY_INTERVAL out for the sweep), which prevents - # busy-looping the worker on a stuck scanner. - self._advance_due(due_buckets, window_end) + # request. Advancing from ``now`` (not ``window_end``) makes + # ``scan_interval`` a true period between window starts; the + # alternative ("interval after window ends") would make the + # effective cadence ``scan_interval + duration`` and drift with + # the actual stop/start cost. Failure of the scanner call is + # handled the same way as success: we still don't retry until + # scan_interval out (or AUTO_REDISCOVERY_INTERVAL out for the + # sweep), which prevents busy-looping the worker on a stuck + # scanner. + self._advance_due(due_buckets, now) if sweep_due: - self._sweep_last_completed = window_end + self._sweep_last_completed = now try: await self._scanner.async_request_active_window(duration) except Exception: # pylint: disable=broad-except diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 84f5d357..206a0244 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1082,12 +1082,12 @@ def async_register_active_scan( DEFAULT_ACTIVE_SCAN_DURATION (10s) when not provided; those defaults work for the typical sensor case. Integrations that genuinely need faster updates can pass a smaller - ``scan_interval`` explicitly. The scheduler - asks the AUTO-mode scanner currently in range of ``address`` to - flip active for ``scan_duration`` seconds every - ``scan_interval`` seconds while the device is being seen. - ACTIVE and PASSIVE scanners ignore the request. Returns a - cancel callable. + ``scan_interval`` explicitly. The scheduler asks the AUTO-mode + scanner currently in range of ``address`` to flip active for + ``scan_duration`` seconds every ``scan_interval`` seconds + (measured between window starts, not between successive + windows) while the device is being seen. ACTIVE and PASSIVE + scanners ignore the request. Returns a cancel callable. """ if scan_interval is None: scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 09460421..3904428f 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -157,6 +157,43 @@ async def test_worker_tick_fires_active_window() -> None: register_cancel() +@pytest.mark.asyncio +async def test_worker_tick_advances_by_scan_interval_from_window_start() -> None: + """ + Next-due is window_start + scan_interval, not window_end + scan_interval. + + scan_interval is documented as the cadence between window *starts*. + The scheduler advances entries from the tick's ``now`` (when the + window starts) so the effective period is exactly ``scan_interval``; + advancing from ``window_end`` instead would make the effective + period ``scan_interval + scan_duration``. + """ + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + address = "11:22:33:44:55:77" + cancel = manager.async_register_active_scan( + address, scan_interval=120.0, scan_duration=15.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + entries = sched._needs[address] + request = next(iter(entries)) + entries[request] = loop.time() - 1.0 + before_tick = loop.time() + await _run_worker_tick(sched, scanner.source) + # entries[request] should be the tick's now + scan_interval == + # roughly before_tick + 120. Definitely NOT before_tick + 135 + # (which is what "scan_interval after window ends" would give). + assert entries[request] == pytest.approx(before_tick + 120.0, abs=0.1) + assert entries[request] < before_tick + 130.0 + finally: + cancel() + register_cancel() + + @pytest.mark.asyncio async def test_worker_tick_coalesces_overlapping_requests() -> None: """Multiple requests for the same address coalesce on max scan_duration.""" @@ -292,6 +329,75 @@ async def test_first_sweeps_stagger_across_scanners() -> None: c3() +@pytest.mark.asyncio +async def test_first_sweep_stagger_wraps_past_window_size() -> None: + """ + Past AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION scanners, offsets wrap. + + With the modulo cap on the spawn offset, the Nth scanner where + N == AUTO_INITIAL_SWEEP_DELAY/AUTO_REDISCOVERY_SWEEP_DURATION + wraps back to offset 0. This locks in the contract that the + stagger does not grow unboundedly with worker count. + """ + manager = get_manager() + sched = manager._auto_scheduler + wrap_at = int(AUTO_INITIAL_SWEEP_DELAY // AUTO_REDISCOVERY_SWEEP_DURATION) + n = wrap_at + 1 # one past the wrap + cancels = [] + try: + for i in range(n): + s = _RecordingAutoScanner( + f"AA:BB:CC:00:00:{i:02x}", BluetoothScanningMode.AUTO + ) + cancels.append(manager.async_register_scanner(s)) + # The Nth scanner's first sweep is wrap_at scanners' worth of + # offset modulo AUTO_INITIAL_SWEEP_DELAY -> back to 0; the + # first scanner was also at offset 0, so their next-sweep times + # match within a small slack for loop.time() advancing. + workers = list(sched._workers.values()) + first_sweep_a = workers[0]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + first_sweep_wrap = ( + workers[wrap_at]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + ) + assert abs(first_sweep_wrap - first_sweep_a) < 1.0 + finally: + for c in cancels: + c() + + +@pytest.mark.asyncio +async def test_active_scan_registered_before_auto_scanner_wakes_on_register() -> None: + """ + A request registered before any AUTO scanner exists wakes the right one. + + Sequence: async_register_active_scan (request enters + _requests_by_address; no worker exists yet for the device). + Later, an AUTO scanner is registered and starts seeing the device. + The first advertisement on that scanner must wake its worker so + the entry in _needs is acted upon. + """ + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:88" + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + # Sanity: request is recorded; no worker yet for any source. + assert address in sched._requests_by_address + try: + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:99", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._wake.clear() + _inject(scanner, address) + assert worker._wake.is_set() + # The address now has a tracked entry on this scanner. + assert address in sched._needs + finally: + register_cancel() + finally: + cancel() + + @pytest.mark.asyncio async def test_remove_request_clears_tracking() -> None: """Cancelling a registration removes its per-(address, request) entries.""" From 64e1e6c87df923bfca27af054742e78217513319 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:12:59 -0500 Subject: [PATCH 51/75] fix(scanner): cancel pending timer in _arm_active_window_timer, deflake test Two changes in one commit: - Address bluetoothbot's race note: two concurrent async_request_active_window callers could both reach _arm_active_window_timer (one through the restart path, the second through the "still ACTIVE" fast path inside the lock) without the second cancelling the first's TimerHandle, leaking a pending timer that would later fire an extra _async_end_active_window. Today the scheduler is the only caller and _tick serializes per worker so the race isn't reachable, but the public method name reads as if external callers may use it; defensive cancel-before-arm closes the gap. Add test_arm_active_window_timer_cancels_existing_handle to lock in the contract. - Deflake test_async_request_active_window_end_time_matches_real_timer. The previous version relied on asyncio.sleep(0.1) returning at or after 0.1s, which CI ran ~6ms early. Replace the sleep-based restart-stall with an asyncio.Event gated mock so the test controls when the restart completes, then measure elapsed loop.time() directly and use it as the assertion's tolerance reference. Stable across 20 local repeats. Also clarify the add_request docstring per bluetoothbot's "pre-start delays first window" note: pre-start() registrations don't seed _needs and wait for the next advertisement; HA's flow always sets up the manager first so this only matters for outside-HA callers. --- src/habluetooth/auto_scheduler.py | 20 ++++-- src/habluetooth/scanner.py | 10 +++ tests/test_scanner.py | 110 +++++++++++++++++++++++------- 3 files changed, 112 insertions(+), 28 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 755bc7a1..ff2da904 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -463,16 +463,26 @@ def add_request(self, request: ActiveScanRequest) -> None: """ Register an active-scan request and start tracking immediately. - The first window fires ``scan_interval`` seconds after registration - (gated by the per-scanner history check at tick time, so it doesn't - fire on a scanner that hasn't seen the device yet). If the entry - gets pruned later because the device's history disappears, - on_advertisement re-creates it the next time the device is seen. + If ``start()`` has already run, the first window fires + ``scan_interval`` seconds after registration (gated by the + per-scanner history check at tick time, so it doesn't fire on + a scanner that hasn't seen the device yet). If the entry gets + pruned later because the device's history disappears, + on_advertisement re-creates it the next time the device is + seen. Only wakes the owner's worker when this call actually inserted a fresh entry into ``_needs``; re-registering an identical request (e.g., from an HA config-entry reload) is a no-op on the schedule so the wake would just churn ``_next_event_at``. + + Pre-``start()`` registrations (no event loop yet) record the + request only — no ``_needs`` entry is seeded. The first window + for such a request fires ``scan_interval`` after the next + advertisement for ``address`` arrives, not after + ``start()``. This only matters if an integration registers + before ``BluetoothManager.async_setup``; HA's flow always sets + up the manager first. """ self._requests_by_address.setdefault(request.address, set()).add(request) added = False diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index ed9646f7..3c9a066c 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -664,9 +664,19 @@ def _arm_active_window_timer(self, duration: float) -> None: sync with the real timer fire time across the stop/restart cycle and let a *shorter* follow-up request masquerade as an extension. + + Cancels any existing handle before arming the new one so two + concurrent ``async_request_active_window`` calls cannot leak a + pending timer; today only the per-scanner scheduler worker + drives this and ``_tick`` serializes per worker, so the + contention is hypothetical, but the public method name reads + as if external callers may use it and nothing else in the + lock-side path defends against the race. """ if TYPE_CHECKING: assert self._loop is not None + if self._active_window_handle is not None: + self._active_window_handle.cancel() self._active_window_end = self._loop.time() + duration self._active_window_handle = self._loop.call_later( duration, self._schedule_end_active_window diff --git a/tests/test_scanner.py b/tests/test_scanner.py index ca1e54bb..be06441d 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1775,6 +1775,56 @@ def _factory(*_args, **kwargs): await scanner.async_stop() +@pytest.mark.asyncio +async def test_arm_active_window_timer_cancels_existing_handle() -> None: + """ + _arm_active_window_timer cancels any prior handle before arming. + + Regression for the concurrent-callers race noted in PR review: + two concurrent ``async_request_active_window`` calls could both + reach _arm_active_window_timer without the second cancelling the + first's TimerHandle, leaking a pending timer that would later fire + an extra _async_end_active_window. Today only the scheduler drives + the public method (and _tick serializes per worker) so the race + isn't reachable through normal callers, but the contract on + _arm_active_window_timer must defend against it. + """ + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_a, **_kw: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + + # Arm a window so there's a handle to potentially leak. + assert await scanner.async_request_active_window(100.0) is True + first_handle = scanner._active_window_handle + assert first_handle is not None + # Directly call _arm again (simulating the race-path second + # caller). The first handle must be cancelled, not leaked. + scanner._arm_active_window_timer(50.0) + assert first_handle.cancelled() + assert scanner._active_window_handle is not first_handle + + await scanner.async_stop() + + @pytest.mark.asyncio async def test_async_request_active_window_extends_existing_window() -> None: """A second request inside an active window extends the timer in place.""" @@ -1832,20 +1882,25 @@ async def test_async_request_active_window_end_time_matches_real_timer() -> None time by the restart duration. The fix moved the ``_active_window_end`` computation inside ``_arm_active_window_timer`` so it always matches when the timer - will actually fire. A subsequent request with a duration just - slightly above the lagged stored end-time but below the real - fire time would otherwise have cancelled the live timer and - armed a shorter one, silently shortening the active window. + will actually fire. + + Uses an asyncio.Event to gate the restart-in-progress + deterministically rather than relying on asyncio.sleep precision, + which can fire slightly early on busy CI runners. """ - # Big enough that the pre-fix lag (~0.1s) is well above any - # approx-tolerance noise, but small enough to keep the test - # quick. - restart_sleep = 0.1 duration = 10.0 + restart_started = asyncio.Event() + gate = asyncio.Event() + + class GatedMockBleakScanner: + _first_start_done = False - class SlowMockBleakScanner: async def start(self): - await asyncio.sleep(restart_sleep) + if not type(self)._first_start_done: + type(self)._first_start_done = True + return + restart_started.set() + await gate.wait() async def stop(self): pass @@ -1859,7 +1914,7 @@ def register_detection_callback(self, callback): with patch( "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *a, **k: SlowMockBleakScanner(), + side_effect=lambda *a, **k: GatedMockBleakScanner(), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() @@ -1867,24 +1922,33 @@ def register_detection_callback(self, callback): loop = asyncio.get_running_loop() before = loop.time() - assert await scanner.async_request_active_window(duration) is True - after = loop.time() - # Sanity: the restart actually took the slow path. - assert after - before >= restart_sleep - # The stored end-time must match the post-restart fire time - # (loop.time() + duration), not before + duration. Pre-fix - # this assertion fails by ~restart_sleep. - assert scanner._active_window_end == pytest.approx(after + duration, abs=0.01) + task = asyncio.create_task(scanner.async_request_active_window(duration)) + await restart_started.wait() + # Provably advance loop.time() past `before` before the restart + # completes; the exact amount doesn't matter for the assertion + # below as long as loop.time() has visibly moved. + await asyncio.sleep(0.05) + elapsed = loop.time() - before + gate.set() + assert await task is True + + # Contract: _active_window_end matches loop.time() + duration + # measured AFTER the restart, not before. Pre-fix it would be + # before + duration. Allow generous tolerance for the small + # gap between arming and reading. + now = loop.time() + assert scanner._active_window_end == pytest.approx(now + duration, abs=0.1) + # Reject pre-fix value (before + duration) explicitly with a + # margin well above asyncio scheduling jitter: the stored end + # is at least ``elapsed`` ahead of before + duration. + assert scanner._active_window_end - before - duration >= elapsed / 2 first_handle = scanner._active_window_handle # A follow-up whose new_end lands between the pre-fix stored # end and the real fire time must NOT be treated as an # extension. With the fix this is rejected; without it the # live timer would be cancelled and armed shorter. - # Pick a duration that puts new_end at before + duration + - # restart_sleep/2 - i.e. above the stale stored end but below - # the real fire time. - target_new_end = before + duration + restart_sleep / 2 + target_new_end = before + duration + elapsed / 2 shorter_duration = target_new_end - loop.time() assert await scanner.async_request_active_window(shorter_duration) is True assert scanner._active_window_handle is first_handle From ffddf59c6e00aa689841ca91b00bc9b6d5717c62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:19:26 -0500 Subject: [PATCH 52/75] fix(scanner): AUTO mode passive args + spurious fallback warning + doc fix Three new Copilot review items: - On Linux, AUTO scanners passed through SCANNING_MODE_TO_BLEAK as "passive" but create_bleak_scanner's `scanning_mode == PASSIVE` check excluded AUTO, so AUTO scanners never got the PASSIVE_SCANNER_ARGS or_patterns. bleak's passive scanner needs at least one or_pattern matcher, so AUTO would not behave like real passive scanning. Treat AUTO the same as PASSIVE for the Linux bluez args setup. - _log_start_success warned "fell back to passive" whenever current_mode != requested_mode. For an AUTO scanner mid-active- window that's current_mode=ACTIVE, requested_mode=AUTO -> spurious warning on every successful active-window restart. Pass effective_mode (the mode we tried to start in) and compare current_mode against that instead. Add a regression test that asserts no warning is logged on a successful active-window restart. - Clarify add_request docstring: ActiveScanRequest is identity-based, so each call to async_register_active_scan creates a new request and contributes its own cadence to the same address. The previous "re-registering an identical request is a no-op" wording was misleading because the public API does not deduplicate. The fourth Copilot note (race in async_request_active_window where a second caller could overwrite the timer handle) is already addressed by the cancel-on-arm fix in _arm_active_window_timer (64e1e6c). --- src/habluetooth/auto_scheduler.py | 13 ++++++--- src/habluetooth/scanner.py | 25 +++++++++++++--- tests/test_scanner.py | 47 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index ff2da904..47c10031 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -471,10 +471,15 @@ def add_request(self, request: ActiveScanRequest) -> None: on_advertisement re-creates it the next time the device is seen. - Only wakes the owner's worker when this call actually inserted - a fresh entry into ``_needs``; re-registering an identical - request (e.g., from an HA config-entry reload) is a no-op on - the schedule so the wake would just churn ``_next_event_at``. + ``ActiveScanRequest`` is compared by identity, so each public + call to ``BluetoothManager.async_register_active_scan`` creates + a new request that contributes its own cadence to the same + address (two callers asking for windows every 60s on the same + device get two independent 60s cadences, not one). Adding the + *same* request object twice is idempotent and no-ops the + wake. Cancellation is per-registration — the callable returned + from ``async_register_active_scan`` only removes that specific + request, not other registrations against the same address. Pre-``start()`` registrations (no event loop yet) record the request only — no ``_needs`` entry is seeded. The first window diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 3c9a066c..f51481f5 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -140,7 +140,17 @@ def create_bleak_scanner( if IS_LINUX: # Only Linux supports multiple adapters bluez_args: BlueZScannerArgs = {} - if scanning_mode == BluetoothScanningMode.PASSIVE: + # PASSIVE and AUTO both start the scanner in passive mode on + # Linux; bleak's passive scanner needs at least one or_pattern + # matcher or it won't start, so AUTO has to set PASSIVE_SCANNER_ARGS + # too. (AUTO gets flipped to active on demand by the scheduler + # via async_request_active_window, which restarts with + # scan_mode_override=ACTIVE so this branch is skipped for those + # restarts.) + if scanning_mode in ( + BluetoothScanningMode.PASSIVE, + BluetoothScanningMode.AUTO, + ): bluez_args = dict(PASSIVE_SCANNER_ARGS) if adapter: # bleak 3.0 deprecated the top-level ``adapter`` kwarg in favor of @@ -474,7 +484,7 @@ async def _async_start_attempt(self, attempt: int) -> bool: finally: self._start_future = None - self._log_start_success(attempt) + self._log_start_success(attempt, effective_mode) self._on_start_success() return True @@ -486,8 +496,15 @@ def _log_adapter_init_wait(self, attempt: int) -> None: START_ATTEMPTS, ) - def _log_start_success(self, attempt: int) -> None: - if self.current_mode is not self.requested_mode: + def _log_start_success( + self, attempt: int, effective_mode: BluetoothScanningMode | None + ) -> None: + # Compare against the mode we *tried* to start in (effective_mode) + # rather than requested_mode: an AUTO scanner mid-active-window + # has requested_mode=AUTO but effective_mode=ACTIVE, and we + # don't want to warn "fell back to passive" when the active + # restart actually succeeded. + if self.current_mode is not effective_mode: _LOGGER.warning( "%s: Successful fall-back to passive scanning mode " "after active scanning failed (%s/%s)", diff --git a/tests/test_scanner.py b/tests/test_scanner.py index be06441d..171779ec 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1775,6 +1775,53 @@ def _factory(*_args, **kwargs): await scanner.async_stop() +@pytest.mark.asyncio +async def test_active_window_restart_does_not_log_fallback_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """ + A successful active-window restart on an AUTO scanner must not warn. + + Regression: the start-success log compared current_mode against + requested_mode. For an AUTO scanner mid-active-window, + requested_mode is AUTO but current_mode is ACTIVE (because the + restart was triggered by the scheduler with + _scan_mode_override=ACTIVE), so the previous code logged a + spurious "fell back to passive" warning on every active-window + restart. The check now uses effective_mode (the mode we tried to + start in) so it only triggers on a real fallback. + """ + + class MockBleakScanner: + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_a, **_kw: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + caplog.clear() + with caplog.at_level(logging.WARNING): + assert await scanner.async_request_active_window(10.0) is True + assert not any( + "fall-back to passive" in record.message for record in caplog.records + ) + await scanner.async_stop() + + @pytest.mark.asyncio async def test_arm_active_window_timer_cancels_existing_handle() -> None: """ From 21b69eba200520f5f15310681f11f6bcfc8f667a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:25:22 -0500 Subject: [PATCH 53/75] fix(auto): normalize address case and skip seed-then-prune kick-start Two real review items, plus a docstring tweak for the third. - Address case sensitivity: _requests_by_address keys were raw caller-supplied strings, but BlueZ / bleak normalize advertisement source addresses to upper-case. A caller passing "aa:bb:cc:dd:ee:ff" against a device whose ads come up "AA:BB:CC:DD:EE:FF" silently registered a no-op since on_advertisement looked up the upper-case key. Normalize in async_register_active_scan (address.upper()) so case never matters at the boundary. - Kick-start without history was a misleading no-op: add_request unconditionally seeded _needs[address] but _collect_due_buckets prunes addresses with no last_service_info on the next tick. The "first window scan_interval after registration" promise silently degraded to "scan_interval after first advertisement". Skip the seed when history is None and let on_advertisement bootstrap tracking; same observable behavior, no wasted insert/delete. Tests: - test_register_active_scan_normalizes_address_case asserts a lowercase registration lands in _requests_by_address under the upper-case key. - test_add_request_without_history_skips_seed verifies _needs stays empty until the first advertisement and on_advertisement still wakes the owner's worker. - Update test_dispatch_drops_tracking_for_unseen_address and the pruned-bootstrap test to use the upper-case address and pop-not-del (the entry no longer pre-exists). The other two review notes were already in place after ffddf59: the spurious 'fell back to passive' warning is fixed by passing effective_mode to _log_start_success, and the add_request docstring already explains the identity-based comparison semantics. --- src/habluetooth/auto_scheduler.py | 46 +++++++++-------- src/habluetooth/manager.py | 22 ++++---- tests/test_auto_scheduler.py | 85 ++++++++++++++++++++++++++----- 3 files changed, 108 insertions(+), 45 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 47c10031..1973ade6 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -463,13 +463,16 @@ def add_request(self, request: ActiveScanRequest) -> None: """ Register an active-scan request and start tracking immediately. - If ``start()`` has already run, the first window fires - ``scan_interval`` seconds after registration (gated by the - per-scanner history check at tick time, so it doesn't fire on - a scanner that hasn't seen the device yet). If the entry gets - pruned later because the device's history disappears, - on_advertisement re-creates it the next time the device is - seen. + If a previous advertisement for ``request.address`` is in + ``_all_history`` when this runs, the first window fires + ``scan_interval`` seconds after registration on the current + owner. If the device hasn't been seen yet, no ``_needs`` entry + is seeded (a speculative seed would just be pruned on the + next tick because ``_collect_due_buckets`` drops addresses + with no ``last_service_info``); ``on_advertisement`` creates + the entry and wakes the owner's worker the first time the + device is seen, so the first window fires ``scan_interval`` + after that advertisement instead. ``ActiveScanRequest`` is compared by identity, so each public call to ``BluetoothManager.async_register_active_scan`` creates @@ -482,25 +485,24 @@ def add_request(self, request: ActiveScanRequest) -> None: request, not other registrations against the same address. Pre-``start()`` registrations (no event loop yet) record the - request only — no ``_needs`` entry is seeded. The first window - for such a request fires ``scan_interval`` after the next - advertisement for ``address`` arrives, not after - ``start()``. This only matters if an integration registers - before ``BluetoothManager.async_setup``; HA's flow always sets - up the manager first. + request only — no ``_needs`` entry is seeded, no wake fires. """ self._requests_by_address.setdefault(request.address, set()).add(request) - added = False - if self._loop is not None: - existing = self._needs.setdefault(request.address, {}) - if request not in existing: - existing[request] = self._loop.time() + request.scan_interval - added = True - if not added: + if self._loop is None: return history = self._manager.async_last_service_info(request.address, False) - if history is not None: - self._wake_worker(history.source) + if history is None: + # No history yet; seeding _needs would just be pruned on + # the next tick because _collect_due_buckets drops + # addresses with no last_service_info. on_advertisement + # will create the entry the first time the device is seen + # and wake the owner's worker. + return + existing = self._needs.setdefault(request.address, {}) + if request in existing: + return + existing[request] = self._loop.time() + request.scan_interval + self._wake_worker(history.source) def remove_request(self, request: ActiveScanRequest) -> None: """Drop the request from the index and from any pending tracking.""" diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 206a0244..64a618bc 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1077,15 +1077,17 @@ def async_register_active_scan( """ Declare an on-demand active-scan need for a specific address. - ``scan_interval`` and ``scan_duration`` default to - DEFAULT_ACTIVE_SCAN_INTERVAL (300s, 5 minutes) and - DEFAULT_ACTIVE_SCAN_DURATION (10s) when not provided; those - defaults work for the typical sensor case. Integrations that - genuinely need faster updates can pass a smaller - ``scan_interval`` explicitly. The scheduler asks the AUTO-mode - scanner currently in range of ``address`` to flip active for - ``scan_duration`` seconds every ``scan_interval`` seconds - (measured between window starts, not between successive + ``address`` is normalized to upper-case so it matches the + case BlueZ / bleak use for advertisement source addresses; + callers don't have to think about case. ``scan_interval`` and + ``scan_duration`` default to DEFAULT_ACTIVE_SCAN_INTERVAL + (300s, 5 minutes) and DEFAULT_ACTIVE_SCAN_DURATION (10s) when + not provided; those defaults work for the typical sensor + case. Integrations that genuinely need faster updates can pass + a smaller ``scan_interval`` explicitly. The scheduler asks the + AUTO-mode scanner currently in range of ``address`` to flip + active for ``scan_duration`` seconds every ``scan_interval`` + seconds (measured between window starts, not between successive windows) while the device is being seen. ACTIVE and PASSIVE scanners ignore the request. Returns a cancel callable. """ @@ -1097,7 +1099,7 @@ def async_register_active_scan( raise ValueError(f"scan_interval must be >= {MIN_ACTIVE_SCAN_INTERVAL}s") if scan_duration < MIN_ACTIVE_SCAN_DURATION: raise ValueError(f"scan_duration must be >= {MIN_ACTIVE_SCAN_DURATION}s") - request = ActiveScanRequest(address, scan_interval, scan_duration) + request = ActiveScanRequest(address.upper(), scan_interval, scan_duration) self._auto_scheduler.add_request(request) return partial(self._auto_scheduler.remove_request, request) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 3904428f..a34a2790 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -464,14 +464,14 @@ async def test_dispatch_drops_tracking_for_unseen_address() -> None: manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() - cancel = manager.async_register_active_scan("aa:bb:cc:dd:ee:ff", scan_interval=60.0) + cancel = manager.async_register_active_scan("AA:BB:CC:DD:EE:FF", scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - request = next(iter(sched._requests_by_address["aa:bb:cc:dd:ee:ff"])) - sched._needs["aa:bb:cc:dd:ee:ff"] = {request: loop.time() - 1.0} + request = next(iter(sched._requests_by_address["AA:BB:CC:DD:EE:FF"])) + sched._needs["AA:BB:CC:DD:EE:FF"] = {request: loop.time() - 1.0} await _run_worker_tick(sched, scanner.source) - assert "aa:bb:cc:dd:ee:ff" not in sched._needs + assert "AA:BB:CC:DD:EE:FF" not in sched._needs finally: cancel() register_cancel() @@ -589,8 +589,10 @@ async def test_on_advertisement_re_bootstraps_pruned_tracking() -> None: cancel = manager.async_register_active_scan(address, scan_interval=120.0) try: worker = sched._workers[scanner.source] - # Simulate the prune-on-no-history step having removed the entry. - del sched._needs[address] + # No advertisement has been seen yet, so add_request skipped + # the _needs seed (the prune-on-no-history path). Simulate the + # "pruned" state by ensuring it's not there. + sched._needs.pop(address, None) worker._wake.clear() _inject(scanner, address) assert address in sched._needs @@ -640,6 +642,63 @@ async def test_register_active_scan_applies_defaults() -> None: cancel() +@pytest.mark.asyncio +async def test_register_active_scan_normalizes_address_case() -> None: + """ + Lowercase addresses get normalized to the upper-case form. + + Matches the upper-case form BlueZ / bleak use for advertisement + source addresses so on_advertisement's dict lookup finds the + request regardless of caller case. + """ + manager = get_manager() + sched = manager._auto_scheduler + upper = "AA:BB:CC:DD:EE:55" + cancel = manager.async_register_active_scan(upper.lower(), scan_interval=60.0) + try: + # Stored under the upper-case form, regardless of caller's case. + assert upper in sched._requests_by_address + assert upper.lower() not in sched._requests_by_address + request = next(iter(sched._requests_by_address[upper])) + assert request.address == upper + finally: + cancel() + + +@pytest.mark.asyncio +async def test_add_request_without_history_skips_seed() -> None: + """ + add_request skips _needs when no last_service_info exists yet. + + on_advertisement bootstraps tracking instead. The previous + behavior seeded unconditionally and let the next worker tick + prune the orphan entry; skipping the seed avoids that churn. + """ + manager = get_manager() + sched = manager._auto_scheduler + address = "AA:BB:CC:DD:EE:56" + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + try: + # Sanity: history doesn't exist for this address yet. + assert manager.async_last_service_info(address, False) is None + # _needs was not seeded -> no entry to prune later. + assert address not in sched._needs + # But the request IS recorded for on_advertisement to pick up. + assert address in sched._requests_by_address + # First advertisement bootstraps tracking and wakes the + # owner's worker. + worker = sched._workers[scanner.source] + worker._wake.clear() + _inject(scanner, address) + assert address in sched._needs + assert worker._wake.is_set() + finally: + cancel() + register_cancel() + + @pytest.mark.asyncio async def test_run_window_swallows_scanner_exception() -> None: """An exception from async_request_active_window is logged, not re-raised.""" @@ -778,19 +837,19 @@ async def test_next_event_at_ignores_empty_or_foreign_entries() -> None: try: worker = sched._workers[scanner.source] # Empty entries: hits the "if not entries: continue" branch. - sched._needs["aa:bb:cc:dd:ee:01"] = {} + sched._needs["AA:BB:CC:DD:EE:01"] = {} # No history at all: hits "if history is None or history.source != source". cancel = manager.async_register_active_scan( - "aa:bb:cc:dd:ee:02", scan_interval=60.0 + "AA:BB:CC:DD:EE:02", scan_interval=60.0 ) - request = next(iter(sched._requests_by_address["aa:bb:cc:dd:ee:02"])) - sched._needs["aa:bb:cc:dd:ee:02"] = {request: loop.time() - 1.0} + request = next(iter(sched._requests_by_address["AA:BB:CC:DD:EE:02"])) + sched._needs["AA:BB:CC:DD:EE:02"] = {request: loop.time() - 1.0} next_at = worker._next_event_at(loop.time()) # With no contributing per-device entries the next event reverts # to the sweep cadence (well into the future via initial delay). assert next_at == worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL cancel() - del sched._needs["aa:bb:cc:dd:ee:01"] + del sched._needs["AA:BB:CC:DD:EE:01"] finally: register_cancel() @@ -803,10 +862,10 @@ async def test_dispatch_per_device_skips_empty_entries() -> None: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - sched._needs["aa:bb:cc:dd:ee:ff"] = {} + sched._needs["AA:BB:CC:DD:EE:FF"] = {} await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [] - del sched._needs["aa:bb:cc:dd:ee:ff"] + del sched._needs["AA:BB:CC:DD:EE:FF"] finally: register_cancel() From 7d71e8304e8b0e0066b8893905723df1200d798e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:30:33 -0500 Subject: [PATCH 54/75] fix(auto): post-merge review polish Six review items in one commit: - scanner.py: drop the redundant _active_window_handle.cancel() in the lockless extension path. _arm_active_window_timer already cancels any existing handle internally; having both cancel sites was a future-reader trap. - auto_scheduler.py: add a defense-in-depth comment to the _tick re-entry guard. _tick is only invoked from _run on a single per-worker task and the finally clears _window_end, so the guard can't trip on the current call path - the comment makes clear it's intentional protection against future refactors that might call _tick from a second site, not load-bearing logic. - auto_scheduler.py: replay pre-start() registrations in start(). add_request only seeds _needs when self._loop is set, so an embedder that registered active scans before async_setup ran would have had to wait for the next advertisement to bootstrap tracking. start() now sweeps _requests_by_address once and seeds _needs for any address that already has last_service_info, so the kick-start contract ("first window scan_interval after registration") holds for those callers too. - auto_scheduler.py: rate-limit the per-failed-window exception log. A persistently broken scanner could emit a full traceback every scan_interval (>= 60s by validation); now the first failure per worker logs the full stack and subsequent failures collapse to a one-line warning. The _failed_window flag is on _ScannerWorker (added to slots and the .pxd). - auto_scheduler.pxd: add cython.locals to _next_event_at so it matches _collect_due_buckets and gets the same C-typed local treatment under cython. Tests: - test_repeated_window_failures_log_only_first_traceback asserts the first log record carries exc_info and the second does not. - test_start_replays_pre_start_requests_when_history_exists drops _loop to simulate pre-start state, registers, restores _loop, calls start() and asserts _needs picked up the entry. (Item 2 from the review - the "serialized" wording on the sweep - was addressed in the PR description text, not the code.) --- src/habluetooth/auto_scheduler.pxd | 9 ++++ src/habluetooth/auto_scheduler.py | 55 ++++++++++++++++++--- src/habluetooth/scanner.py | 3 +- tests/test_auto_scheduler.py | 79 ++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 7 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index e71d206a..76c824a9 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -25,6 +25,7 @@ cdef class _ScannerWorker: cdef public object _task cdef public double _window_end cdef public double _sweep_last_completed + cdef public bint _failed_window cpdef void start(self, object loop, double initial_offset=*) @@ -32,6 +33,14 @@ cdef class _ScannerWorker: cpdef void wake(self) + @cython.locals( + source=str, + needs=dict, + address=str, + entries=dict, + next_at=double, + earliest=double, + ) cpdef double _next_event_at(self, double now) @cython.locals( diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 1973ade6..9c7b05e7 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -154,6 +154,7 @@ class _ScannerWorker: """One persistent task per AUTO scanner; sleeps until next due event.""" __slots__ = ( + "_failed_window", "_manager", "_scanner", "_scheduler", @@ -176,6 +177,7 @@ def __init__( self._task: asyncio.Task[None] | None = None self._window_end: float = 0.0 self._sweep_last_completed: float = 0.0 + self._failed_window: bool = False def start( self, loop: asyncio.AbstractEventLoop, initial_offset: float = 0.0 @@ -328,6 +330,12 @@ async def _tick(self) -> None: if loop is None: return now = loop.time() + # Defense-in-depth: _tick is only ever invoked from _run on a + # single per-worker task, and the finally below clears + # _window_end after the await returns, so this re-entry guard + # cannot trip on the current call path. Keep it cheap and + # explicit so a future refactor that calls _tick from + # elsewhere can't accidentally double-fire a window. if self._window_end > now: return self._window_end = 0.0 @@ -357,12 +365,26 @@ async def _tick(self) -> None: self._sweep_last_completed = now try: await self._scanner.async_request_active_window(duration) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "%s: error running active window of %.1fs", - self._scanner.name, - duration, - ) + except Exception as ex: # pylint: disable=broad-except + # First failure per worker gets a full traceback; subsequent + # ones get a one-liner so a persistently broken scanner + # doesn't spam scan_interval-cadenced stack traces. The + # _failed_window flag resets when start() spawns a new + # worker for the source. + if self._failed_window: + _LOGGER.warning( + "%s: error running active window of %.1fs: %s", + self._scanner.name, + duration, + ex, + ) + else: + self._failed_window = True + _LOGGER.exception( + "%s: error running active window of %.1fs", + self._scanner.name, + duration, + ) finally: self._window_end = 0.0 @@ -395,6 +417,14 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: Idempotent on the worker-spawn side: ``_workers`` is only added to for sources that don't already have a worker, so a second ``start()`` call won't create duplicate tasks. + + Replays any ``_requests_by_address`` registered before + ``start()`` into ``_needs`` so the first window for those + requests fires ``scan_interval`` after start (assuming the + device is in history) instead of waiting for the next + advertisement to bootstrap tracking. Same gating as + ``add_request``: no seed when ``last_service_info`` is None; + ``on_advertisement`` will bootstrap on first sight. """ self._loop = loop self._running = True @@ -404,6 +434,19 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: and scanner.source not in self._workers ): self._spawn_worker(scanner) + # Replay pre-start() registrations: seed _needs for any + # request whose address already has a last_service_info, so + # the kick-start contract holds for embedders that register + # before BluetoothManager.async_setup runs. + now = loop.time() + last_service_info = self._manager.async_last_service_info + for address, requests in self._requests_by_address.items(): + if last_service_info(address, False) is None: + continue + existing = self._needs.setdefault(address, {}) + for request in requests: + if request not in existing: + existing[request] = now + request.scan_interval def stop(self) -> None: """ diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index f51481f5..5ca15b20 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -712,7 +712,8 @@ async def async_request_active_window(self, duration: float) -> bool: assert self._loop is not None if self._active_window_handle is not None: if self._loop.time() + duration > self._active_window_end: - self._active_window_handle.cancel() + # _arm_active_window_timer cancels the old handle + # internally so we don't need to do it twice here. self._arm_active_window_timer(duration) return True async with self._start_stop_lock: diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index a34a2790..3673c58b 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -722,6 +722,85 @@ async def async_request_active_window(self, duration: float) -> bool: register_cancel() +@pytest.mark.asyncio +async def test_repeated_window_failures_log_only_first_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Persistently failing scanner gets one exception log then warnings. + + Without rate-limiting, a permanently broken scanner would emit a + full traceback every scan_interval (>= 60s). The first failure + still logs the full stack so the root cause is captured; subsequent + failures collapse to a one-line warning to avoid flooding the log. + """ + import logging + + manager = get_manager() + sched = manager._auto_scheduler + loop = asyncio.get_running_loop() + + class _FailingScanner(_RecordingAutoScanner): + async def async_request_active_window(self, duration: float) -> bool: + raise RuntimeError("boom") + + scanner = _FailingScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): + await worker._tick() + # Trigger a second failure. + worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 + await worker._tick() + records = [ + r for r in caplog.records if "error running active window" in r.message + ] + assert len(records) == 2 + # First has exception info (full traceback), second does not. + assert records[0].exc_info is not None + assert records[1].exc_info is None + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_start_replays_pre_start_requests_when_history_exists() -> None: + """add_request before start() seeds _needs at start() if history exists.""" + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:80" + # Get history in place first. + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:21", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + _inject(scanner, address) + try: + # Drop loop to simulate pre-start() state, register, then + # restore and call start() again to drive the replay path. + saved_loop = sched._loop + assert saved_loop is not None + sched._loop = None + try: + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=5.0 + ) + try: + assert address not in sched._needs + # start() should now seed _needs from + # _requests_by_address because history exists. + sched.start(saved_loop) + assert address in sched._needs + request = next(iter(sched._requests_by_address[address])) + assert request in sched._needs[address] + finally: + cancel() + finally: + sched._loop = saved_loop + finally: + register_cancel() + + @pytest.mark.asyncio async def test_dispatch_does_not_resurrect_cancelled_request() -> None: """A request cancelled while the window awaits is not re-added to entries.""" From 719d92c8ce4593d63857298b9d82d3287157ff2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:37:39 -0500 Subject: [PATCH 55/75] fix(auto): post-review polish - reset failure flag, restore eager import Three review items in one commit: - Reset _ScannerWorker._failed_window to False on the next successful await self._scanner.async_request_active_window so a later failure-after-recovery captures a fresh traceback. Without this the flag only cleared when the worker was respawned, which meant a scanner that fails once, recovers for hours/days, then fails again would silently lose the new failure's stack. - Restore the eager top-level `from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl` in manager.py. The earlier defer-to-async_setup version was added to break a Cython init cycle, but the cycle no longer exists; verified by rebuilding both pure-python and cython modes with the eager import and running the full suite. Reverts the silent test-patching break noted in the review - downstream code that patched habluetooth.manager.MGMTBluetoothCtl now works again. The two in-repo tests are flipped back to that path. - Clarify the TYPE_CHECKING assert in _arm_active_window_timer's docstring: it's a mypy narrowing hint, not a runtime check. The method is unreachable before async_setup runs (entry is via async_request_active_window or _async_start_attempt), so _loop is always set in practice. --- src/habluetooth/auto_scheduler.py | 12 +++++++----- src/habluetooth/manager.py | 4 +--- src/habluetooth/scanner.py | 6 ++++++ tests/test_manager.py | 2 +- tests/test_scanner.py | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 9c7b05e7..683bb4be 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -366,11 +366,11 @@ async def _tick(self) -> None: try: await self._scanner.async_request_active_window(duration) except Exception as ex: # pylint: disable=broad-except - # First failure per worker gets a full traceback; subsequent - # ones get a one-liner so a persistently broken scanner - # doesn't spam scan_interval-cadenced stack traces. The - # _failed_window flag resets when start() spawns a new - # worker for the source. + # First failure per recovery-cycle gets a full traceback; + # subsequent failures get a one-liner so a persistently + # broken scanner doesn't spam scan_interval-cadenced stack + # traces. The flag clears on the next successful call so a + # later failure-after-recovery captures a stack again. if self._failed_window: _LOGGER.warning( "%s: error running active window of %.1fs: %s", @@ -385,6 +385,8 @@ async def _tick(self) -> None: self._scanner.name, duration, ) + else: + self._failed_window = False finally: self._window_end = 0.0 diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 64a618bc..c0216642 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -32,6 +32,7 @@ AdvertisementTracker, ) from .auto_scheduler import ActiveScanRequest, AutoScanScheduler +from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl from .const import ( ADV_RSSI_SWITCH_THRESHOLD, CALLBACK_TYPE, @@ -59,7 +60,6 @@ from bleak.backends.scanner import AdvertisementData from .base_scanner import BaseHaScanner - from .channels.bluez import MGMTBluetoothCtl from .scanner import HaScanner @@ -362,9 +362,7 @@ async def _async_recover_failed_adapters(self) -> None: async def async_setup(self) -> None: """Set up the bluetooth manager.""" - # Lazy-imported to break a Cython init cycle through channels.bluez. from .central_manager import CentralBluetoothManager - from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl if CentralBluetoothManager.manager is None: CentralBluetoothManager.manager = self diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 5ca15b20..c541289a 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -689,6 +689,12 @@ def _arm_active_window_timer(self, duration: float) -> None: contention is hypothetical, but the public method name reads as if external callers may use it and nothing else in the lock-side path defends against the race. + + ``self._loop`` is assigned in ``async_setup`` and this method + is only reachable via ``async_request_active_window`` / + ``_async_start_attempt``, both of which run after setup. The + ``TYPE_CHECKING`` assert below is a mypy narrowing hint only; + it has no runtime effect. """ if TYPE_CHECKING: assert self._loop is not None diff --git a/tests/test_manager.py b/tests/test_manager.py index 1094e958..f5f1207a 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1396,7 +1396,7 @@ async def test_is_operating_degraded_after_permission_error() -> None: with ( patch("habluetooth.manager.IS_LINUX", True), - patch("habluetooth.channels.bluez.MGMTBluetoothCtl") as mock_mgmt_class, + patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class, ): # Make setup fail with permission error mock_mgmt_instance = Mock() diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 171779ec..b41de88d 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1345,7 +1345,7 @@ def _discover_service_info( # Mock MGMTBluetoothCtl setup to raise PermissionError with ( - patch("habluetooth.channels.bluez.MGMTBluetoothCtl") as mock_mgmt_cls, + patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_cls, patch("habluetooth.manager.IS_LINUX", True), ): mock_mgmt = Mock() From e8ccd76c23d19595f2cc2c65efa65285d89cb2c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:41:39 -0500 Subject: [PATCH 56/75] fix(auto): drop stale 'added' local from add_request pxd declaration The 'added' local was removed from add_request when it was rewritten to early-return rather than gate the wake on a tracked flag, but the matching cython.locals entry in the .pxd was left behind. Drop it so the declaration matches the implementation. The other Copilot note in the same review pass (the except Exception around the scanner await catching CancelledError) does not apply on our Python 3.11+ target: asyncio.CancelledError inherits from BaseException since 3.8, so except Exception explicitly does not catch it and cancellation continues to propagate through the worker task. --- src/habluetooth/auto_scheduler.pxd | 1 - 1 file changed, 1 deletion(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 76c824a9..e398bd03 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -73,7 +73,6 @@ cdef class AutoScanScheduler: @cython.locals( existing=dict, - added=bint, ) cpdef void add_request(self, ActiveScanRequest request) From 5d127bc0977cfbddeb005ad7214896934cc2ab2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 15:56:10 -0500 Subject: [PATCH 57/75] fix(auto): make AutoScanScheduler.start() truly idempotent The previous version was idempotent on the worker-spawn side but still reassigned _loop, set _running = True, and re-ran the _requests_by_address replay block on every call. If start() were ever called twice with different loops (test teardown/setup races, embedder misuse), already-spawned workers would keep referencing the original loop while new state was written against the second one - silently bifurcated. Today only BluetoothManager.async_setup calls start() exactly once, but the docstring already advertised idempotency. Guard the whole body with `if self._running: return`. A genuine restart sequence (stop() then start(new_loop)) still works because stop() sets _running = False before clearing the workers. Tests: - test_start_is_idempotent_when_already_running: a second start() call with a sentinel-object loop must not replace _loop. - Update the two pre-existing tests that simulate the pre-start() state (test_add_scanner_before_start_defers_worker and test_start_ignores_non_auto_scanner) to flip _running back to False so the re-run path is reachable. --- src/habluetooth/auto_scheduler.py | 12 +++++++++--- tests/test_auto_scheduler.py | 30 +++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 683bb4be..d06d5550 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -416,9 +416,13 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: """ Bind to the event loop and spawn one worker per AUTO scanner. - Idempotent on the worker-spawn side: ``_workers`` is only added - to for sources that don't already have a worker, so a second - ``start()`` call won't create duplicate tasks. + Fully idempotent: if ``_running`` is already True (start was + called previously without an intervening ``stop()``), this is a + no-op so an accidental double-call can't bind a different loop + to the same scheduler or re-run the replay. A genuine restart + sequence is ``stop()`` (which sets ``_running = False``) and + then ``start(new_loop)``, which works because ``stop()`` clears + the workers dict. Replays any ``_requests_by_address`` registered before ``start()`` into ``_needs`` so the first window for those @@ -428,6 +432,8 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: ``add_request``: no seed when ``last_service_info`` is None; ``on_advertisement`` will bootstrap on first sight. """ + if self._running: + return self._loop = loop self._running = True for scanner in self._manager.async_current_scanners(): diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 3673c58b..b1cf0772 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -523,6 +523,7 @@ async def test_add_scanner_before_start_defers_worker() -> None: loop = sched._loop assert loop is not None sched._loop = None + sched._running = False try: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) sched.add_scanner(scanner) @@ -776,11 +777,12 @@ async def test_start_replays_pre_start_requests_when_history_exists() -> None: register_cancel = manager.async_register_scanner(scanner) _inject(scanner, address) try: - # Drop loop to simulate pre-start() state, register, then - # restore and call start() again to drive the replay path. + # Simulate pre-start() state: clear _loop and _running so + # add_request takes its no-loop path and start() re-runs. saved_loop = sched._loop assert saved_loop is not None sched._loop = None + sched._running = False try: cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 @@ -797,10 +799,30 @@ async def test_start_replays_pre_start_requests_when_history_exists() -> None: cancel() finally: sched._loop = saved_loop + sched._running = True finally: register_cancel() +@pytest.mark.asyncio +async def test_start_is_idempotent_when_already_running() -> None: + """ + A second start() call without an intervening stop() is a no-op. + + Guards against an accidental double-call binding a different loop + to the same scheduler or re-running the pre-start replay block. + """ + manager = get_manager() + sched = manager._auto_scheduler + # The conftest's async_setup already called start(), so _running + # is True. A second start with a different loop must NOT replace + # _loop or re-run anything. + original_loop = sched._loop + bogus_loop = object() + sched.start(bogus_loop) # type: ignore[arg-type] + assert sched._loop is original_loop + + @pytest.mark.asyncio async def test_dispatch_does_not_resurrect_cancelled_request() -> None: """A request cancelled while the window awaits is not re-added to entries.""" @@ -1268,10 +1290,12 @@ async def test_start_ignores_non_auto_scanner() -> None: # Re-run start() so the False branch (non-AUTO scanner) of the # `if scanner.requested_mode is AUTO` check inside start() is hit. # First shut down the worker tasks the existing start() already - # spawned so we don't leak. + # spawned so we don't leak. Also flip _running back to False so + # start()'s idempotency guard lets the re-run through. for worker in list(sched._workers.values()): await _replace_worker_task(worker) sched._workers.clear() + sched._running = False sched.start(loop) assert auto.source in sched._workers assert active.source not in sched._workers From 3a5f9ad14ece1bcbc6a6dbabafe65b363264073a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 16:01:57 -0500 Subject: [PATCH 58/75] test(auto): cover all branches of start() replay loop Codecov flagged auto_scheduler.py at 99% with one line and two branches uncovered after the start() replay was added. The previous single-request test only exercised the happy path. Restructure test_start_replays_pre_start_requests_when_history_exists to: - register two requests on an address with history and pre-populate _needs with one of them, so start() takes the 'request not in existing' False branch for the pre-existing one and the insert branch for the other; - register a third request on an address with no history, so start() takes the 'last_service_info is None: continue' branch. Brings auto_scheduler.py to 100% line + 100% branch coverage without code changes. --- tests/test_auto_scheduler.py | 57 ++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index b1cf0772..9540e4f4 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -768,35 +768,62 @@ async def async_request_active_window(self, duration: float) -> bool: @pytest.mark.asyncio async def test_start_replays_pre_start_requests_when_history_exists() -> None: - """add_request before start() seeds _needs at start() if history exists.""" + """ + add_request before start() seeds _needs at start() if history exists. + + Also covers the no-history skip path and the + already-in-existing-entries no-op so the replay loop's branches + are all exercised. + """ manager = get_manager() sched = manager._auto_scheduler - address = "11:22:33:44:55:80" - # Get history in place first. + address_with_history = "11:22:33:44:55:80" + address_no_history = "11:22:33:44:55:81" + # Get history in place for one address only. scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:21", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) - _inject(scanner, address) + _inject(scanner, address_with_history) try: - # Simulate pre-start() state: clear _loop and _running so - # add_request takes its no-loop path and start() re-runs. saved_loop = sched._loop assert saved_loop is not None sched._loop = None sched._running = False try: - cancel = manager.async_register_active_scan( - address, scan_interval=60.0, scan_duration=5.0 + # Register TWO requests on the with-history address so + # we can pre-populate _needs with one of them and prove + # start() (a) leaves the pre-existing entry alone and + # (b) inserts a fresh entry for the other. + cancel_with_a = manager.async_register_active_scan( + address_with_history, scan_interval=60.0, scan_duration=5.0 + ) + cancel_with_b = manager.async_register_active_scan( + address_with_history, scan_interval=120.0, scan_duration=5.0 + ) + cancel_without = manager.async_register_active_scan( + address_no_history, scan_interval=60.0, scan_duration=5.0 ) try: - assert address not in sched._needs - # start() should now seed _needs from - # _requests_by_address because history exists. + assert address_with_history not in sched._needs + requests = list(sched._requests_by_address[address_with_history]) + pre_existing, to_be_inserted = requests + # Pre-populate _needs with one request only. + sched._needs[address_with_history] = {pre_existing: 1234.5} sched.start(saved_loop) - assert address in sched._needs - request = next(iter(sched._requests_by_address[address])) - assert request in sched._needs[address] + seeded = sched._needs[address_with_history] + # The pre-existing entry was left alone (covers the + # `request not in existing` False branch). + assert seeded[pre_existing] == 1234.5 + # The other request got freshly inserted (covers the + # insert line in the replay loop). + assert to_be_inserted in seeded + assert seeded[to_be_inserted] > 1234.5 + # No-history address: skipped by the + # `last_service_info(...) is None` branch. + assert address_no_history not in sched._needs finally: - cancel() + cancel_with_a() + cancel_with_b() + cancel_without() finally: sched._loop = saved_loop sched._running = True From b70e8cf55d616e0fef803834a7525754ec6c4d95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 16:11:24 -0500 Subject: [PATCH 59/75] fix(auto): deflake replay test, document scan_duration clamp, guard add_scanner Three items: - Deflake test_start_replays_pre_start_requests_when_history_exists. The sentinel value for the pre-existing entry was a hard-coded 1234.5, which is below loop.time() + scan_interval on long-lived loops but above it on a freshly-started CI loop, so the post-start assertion comparing the freshly-inserted value flipped depending on the loop's start time. Use saved_loop.time() + 1e9 as the sentinel so it's always well above any fresh insert, and assert the freshly-inserted value matches loop.time() + scan_interval with a 0.1s tolerance. - Address Copilot's note that async_register_active_scan's docstring describes scan_duration as the window length without mentioning the [AUTO_WINDOW_MIN_DURATION, AUTO_WINDOW_MAX_DURATION] (5..30s) clamp the scheduler applies. Spell out the clamp + coalescing so callers don't expect a very large scan_duration to be honored verbatim. - Guard AutoScanScheduler.add_scanner with `not self._running` in the same line as the `_loop is None` check. stop() leaves _loop set so the previous guard would still let a post-stop add_scanner call spawn a worker that would just exit on its next iteration when _running was checked in _run. Now the spawn is skipped upfront. --- src/habluetooth/auto_scheduler.py | 13 +++++++++++-- src/habluetooth/manager.py | 18 ++++++++++++------ tests/test_auto_scheduler.py | 17 +++++++++++++---- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index d06d5550..fa1254bd 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -478,10 +478,19 @@ def stop(self) -> None: self._workers.clear() def add_scanner(self, scanner: BaseHaScanner) -> None: - """Register an AUTO-mode scanner; spawn its worker if start() has run.""" + """ + Register an AUTO-mode scanner; spawn its worker if start() has run. + + Skips if the scheduler is not currently running. ``stop()`` + sets ``_running = False`` but leaves ``_loop`` set, so without + this guard a scanner registered between stop and (a possible + future) restart would spawn a worker that immediately exits + on its next iteration when ``_running`` is checked in + ``_run``. + """ if scanner.requested_mode is not BluetoothScanningMode.AUTO: return - if self._loop is None or scanner.source in self._workers: + if self._loop is None or not self._running or scanner.source in self._workers: return self._spawn_worker(scanner) diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index c0216642..64d3eedf 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1082,12 +1082,18 @@ def async_register_active_scan( (300s, 5 minutes) and DEFAULT_ACTIVE_SCAN_DURATION (10s) when not provided; those defaults work for the typical sensor case. Integrations that genuinely need faster updates can pass - a smaller ``scan_interval`` explicitly. The scheduler asks the - AUTO-mode scanner currently in range of ``address`` to flip - active for ``scan_duration`` seconds every ``scan_interval`` - seconds (measured between window starts, not between successive - windows) while the device is being seen. ACTIVE and PASSIVE - scanners ignore the request. Returns a cancel callable. + a smaller ``scan_interval`` explicitly. The effective window + the scanner actually runs is the requested ``scan_duration`` + clamped into [AUTO_WINDOW_MIN_DURATION, + AUTO_WINDOW_MAX_DURATION] (5s..30s) and coalesced with any + other due requests for the same scanner, so very large + ``scan_duration`` values are capped rather than honored + verbatim. The scheduler asks the AUTO-mode scanner currently + in range of ``address`` to flip active for that window every + ``scan_interval`` seconds (measured between window starts, + not between successive windows) while the device is being + seen. ACTIVE and PASSIVE scanners ignore the request. Returns + a cancel callable. """ if scan_interval is None: scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 9540e4f4..55ac19a4 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -806,17 +806,26 @@ async def test_start_replays_pre_start_requests_when_history_exists() -> None: assert address_with_history not in sched._needs requests = list(sched._requests_by_address[address_with_history]) pre_existing, to_be_inserted = requests - # Pre-populate _needs with one request only. - sched._needs[address_with_history] = {pre_existing: 1234.5} + # Pre-populate _needs with one request only. The + # sentinel is well above loop.time() + scan_interval + # so the test is robust against the loop being + # freshly-started (CI) or long-lived; we don't care + # about the absolute value, only that start() leaves + # it alone. + sentinel = saved_loop.time() + 1.0e9 + sched._needs[address_with_history] = {pre_existing: sentinel} + before_start = saved_loop.time() sched.start(saved_loop) seeded = sched._needs[address_with_history] # The pre-existing entry was left alone (covers the # `request not in existing` False branch). - assert seeded[pre_existing] == 1234.5 + assert seeded[pre_existing] == sentinel # The other request got freshly inserted (covers the # insert line in the replay loop). assert to_be_inserted in seeded - assert seeded[to_be_inserted] > 1234.5 + assert seeded[to_be_inserted] == pytest.approx( + before_start + to_be_inserted.scan_interval, abs=0.1 + ) # No-history address: skipped by the # `last_service_info(...) is None` branch. assert address_no_history not in sched._needs From 8ef7014bfeaa6819bc9a05f03e85419f53f11930 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 16:16:19 -0500 Subject: [PATCH 60/75] fix(auto): prune orphan _needs on remove_scanner, reject empty address Two suggestions from the latest bluetoothbot review: - remove_scanner now also drops _needs[addr] entries whose current owner (last_service_info.source) is the leaving scanner. Previously those entries would sit pinned until either another scanner picked up the device (history flip) or the device aged out of _all_history. Self-healing within minutes in steady state, but the explicit prune closes the small window where a removed-and-not-rediscovered device kept a tracked entry around. - async_register_active_scan now raises ValueError on an empty address. The existing interval/duration validators already raise on bad inputs; rejecting empty addresses early surfaces caller mistakes (HA integrations that pass an unset matcher key) instead of silently registering a permanent no-op. Tests: - test_remove_scanner_prunes_owned_needs_entries: two scanners, each owns a different address; unregister one and assert only that scanner's owned entry is pruned, the other scanner's entry remains. - Extend test_register_active_scan_validates_inputs to cover the empty-address ValueError path. Coverage on auto_scheduler.py stays at 100% line + 100% branch. --- src/habluetooth/auto_scheduler.pxd | 4 +++ src/habluetooth/auto_scheduler.py | 21 +++++++++++++-- src/habluetooth/manager.py | 2 ++ tests/test_auto_scheduler.py | 43 ++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index e398bd03..43693052 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -80,6 +80,10 @@ cdef class AutoScanScheduler: cpdef void add_scanner(self, object scanner) + @cython.locals( + source=str, + address=str, + ) cpdef void remove_scanner(self, object scanner) @cython.locals( diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index fa1254bd..b160bb2a 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -495,10 +495,27 @@ def add_scanner(self, scanner: BaseHaScanner) -> None: self._spawn_worker(scanner) def remove_scanner(self, scanner: BaseHaScanner) -> None: - """Stop the worker for a scanner leaving the manager.""" - worker = self._workers.pop(scanner.source, None) + """ + Stop the worker for a scanner leaving the manager. + + Also prunes any ``_needs`` entries whose current owner is the + leaving scanner. Without this they'd sit until the device + either turns up on another scanner (history flips, that + worker picks them up) or expires from ``_all_history`` (the + next worker tick on any scanner drops them). Self-healing in + the steady state, but the explicit prune closes the small + window where a removed-and-not-rediscovered device keeps a + tracked entry pinned. + """ + source = scanner.source + worker = self._workers.pop(source, None) if worker is not None: worker.stop() + last_service_info = self._manager.async_last_service_info + for address in list(self._needs): + history = last_service_info(address, False) + if history is not None and history.source == source: + del self._needs[address] def _spawn_worker(self, scanner: BaseHaScanner) -> None: assert self._loop is not None # noqa: S101 diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 64d3eedf..333a8bcf 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1095,6 +1095,8 @@ def async_register_active_scan( seen. ACTIVE and PASSIVE scanners ignore the request. Returns a cancel callable. """ + if not address: + raise ValueError("address must be a non-empty string") if scan_interval is None: scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL if scan_duration is None: diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 55ac19a4..2d4fb7a1 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -515,6 +515,46 @@ async def test_remove_scanner_stops_its_worker() -> None: assert task.cancelled() or task.done() +@pytest.mark.asyncio +async def test_remove_scanner_prunes_owned_needs_entries() -> None: + """ + _needs entries owned by the leaving scanner are pruned at remove. + + Without the prune, those entries would sit pinned until the + device either turns up on another scanner (history flips) or + expires from _all_history. + """ + manager = get_manager() + sched = manager._auto_scheduler + address_owned = "AA:00:00:00:00:10" + address_foreign = "AA:00:00:00:00:11" + s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) + s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) + c_a = manager.async_register_scanner(s_a) + c_b = manager.async_register_scanner(s_b) + cancel_owned = manager.async_register_active_scan( + address_owned, scan_interval=60.0, scan_duration=5.0 + ) + cancel_foreign = manager.async_register_active_scan( + address_foreign, scan_interval=60.0, scan_duration=5.0 + ) + try: + _inject(s_a, address_owned) + _inject(s_b, address_foreign) + assert address_owned in sched._needs + assert address_foreign in sched._needs + # Remove s_a. The owned entry must be pruned; the foreign one + # (owned by s_b) must remain. + c_a() + await asyncio.sleep(0) + assert address_owned not in sched._needs + assert address_foreign in sched._needs + finally: + cancel_owned() + cancel_foreign() + c_b() + + @pytest.mark.asyncio async def test_add_scanner_before_start_defers_worker() -> None: """A scanner registered before start() gets its worker on start().""" @@ -621,6 +661,9 @@ async def test_register_active_scan_validates_inputs() -> None: manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=4.5 ) + # Empty address. + with pytest.raises(ValueError, match="address must be a non-empty string"): + manager.async_register_active_scan("", scan_interval=60.0) @pytest.mark.asyncio From f26dbdcbc5d9f6b72c33401e2c547def81c1e756 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 16:42:03 -0500 Subject: [PATCH 61/75] fix(scanner): clear _scan_mode_override on any restart error, not just ScannerStartError Bot caught that async_request_active_window only caught ScannerStartError; if any other exception leaked out of _async_stop_then_start_under_lock (CancelledError on a worker cancel, an unexpected BleakError, etc.), the try-block exited with _scan_mode_override still set to ACTIVE. The next _async_start_attempt would then see effective_mode = ACTIVE instead of AUTO, poisoning the start. Add `except BaseException: self._scan_mode_override = None; raise` so cancellation and unexpected errors still propagate but no longer leave a stale override behind. async_stop's _clear_active_window_state still handles teardown explicitly so HA shutdown was already safe; this closes the gap for non-shutdown error paths. Also tighten the ValueError messages on async_register_active_scan to render `>= 60s` / `>= 5s` rather than `>= 60.0s` / `>= 5.0s` (MIN_ACTIVE_SCAN_INTERVAL / MIN_ACTIVE_SCAN_DURATION are typed as float so the default %s formatting picked up the trailing zero). Test: test_async_request_active_window_clears_override_on_unexpected_error patches BleakScanner.start to raise RuntimeError on the active restart and asserts _scan_mode_override is None after the exception propagates. --- src/habluetooth/manager.py | 8 ++++-- src/habluetooth/scanner.py | 8 ++++++ tests/test_scanner.py | 51 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index 333a8bcf..d9196423 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1102,9 +1102,13 @@ def async_register_active_scan( if scan_duration is None: scan_duration = DEFAULT_ACTIVE_SCAN_DURATION if scan_interval < MIN_ACTIVE_SCAN_INTERVAL: - raise ValueError(f"scan_interval must be >= {MIN_ACTIVE_SCAN_INTERVAL}s") + raise ValueError( + f"scan_interval must be >= {MIN_ACTIVE_SCAN_INTERVAL:.0f}s" + ) if scan_duration < MIN_ACTIVE_SCAN_DURATION: - raise ValueError(f"scan_duration must be >= {MIN_ACTIVE_SCAN_DURATION}s") + raise ValueError( + f"scan_duration must be >= {MIN_ACTIVE_SCAN_DURATION:.0f}s" + ) request = ActiveScanRequest(address.upper(), scan_interval, scan_duration) self._auto_scheduler.add_request(request) return partial(self._auto_scheduler.remove_request, request) diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index c541289a..6444d10d 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -743,6 +743,14 @@ async def async_request_active_window(self, duration: float) -> bool: with contextlib.suppress(ScannerStartError): await self._async_stop_then_start_under_lock() return False + except BaseException: + # Any other failure (CancelledError, unexpected BleakError + # leaking out, etc.) must not poison the next start with + # a stale ACTIVE override sitting on _scan_mode_override. + # Clear it and re-raise so cancellation / unexpected + # errors still propagate to the caller / task runner. + self._scan_mode_override = None + raise mode_after_restart = self.current_mode if mode_after_restart is not BluetoothScanningMode.ACTIVE: # Linux's 4th-attempt fallback silently drops to PASSIVE. diff --git a/tests/test_scanner.py b/tests/test_scanner.py index b41de88d..0c341b66 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -2135,6 +2135,57 @@ def register_detection_callback(self, callback): await scanner.async_stop() +@pytest.mark.asyncio +async def test_async_request_active_window_clears_override_on_unexpected_error() -> ( + None +): + """ + An unexpected exception from the restart clears _scan_mode_override. + + Regression: only ScannerStartError was caught explicitly, so any + other exception propagating from _async_stop_then_start_under_lock + would leave _scan_mode_override = ACTIVE. The next + _async_start_attempt would then see effective_mode = ACTIVE + instead of AUTO, poisoning subsequent starts. + """ + start_count = 0 + + class MockBleakScanner: + async def start(self): + nonlocal start_count + start_count += 1 + # First start (initial async_start) succeeds; second start + # (the ACTIVE restart from async_request_active_window) + # raises a non-ScannerStartError so we exercise the + # broad-except cleanup path. + if start_count > 1: + raise RuntimeError("simulated unexpected error") + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + with pytest.raises(RuntimeError, match="simulated unexpected error"): + await scanner.async_request_active_window(1.0) + # The override must be cleared even though the exception + # wasn't a ScannerStartError. + assert scanner._scan_mode_override is None + await scanner.async_stop() + + @pytest.mark.asyncio async def test_base_scanner_default_active_window_is_noop( caplog: pytest.LogCaptureFixture, From da66e9c38489bfe80f442f3c2d9ff2ae83eacf92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 16:55:14 -0500 Subject: [PATCH 62/75] fix(auto): reject non-finite scan_interval/scan_duration Copilot caught that NaN comparisons return False, so a NaN scan_interval or scan_duration slipped past async_register_active_scan's lower-bound validators and landed in _needs (as a NaN due-time) and in call_later (as a NaN timeout), which would have busy-looped the worker. - async_register_active_scan now explicitly rejects non-finite values (math.isfinite check) on both scan_interval and scan_duration; the error message reads 'must be a finite number >= 60s' so the cause is obvious. - _coalesce_duration defensively filters out non-finite scan_duration entries (defense in depth: ActiveScanRequest can be constructed directly bypassing the public-API validation). Tests: - test_register_active_scan_validates_inputs parametrizes (math.nan, math.inf, -math.inf) for both scan_interval and scan_duration and asserts each raises ValueError matching 'must be a finite number'. - test_coalesce_skips_non_finite_durations passes a NaN-scan_duration ActiveScanRequest through _coalesce_duration and asserts it gets filtered out (the good entry's 10s wins; an all-NaN list falls back to AUTO_WINDOW_MIN_DURATION). The other three Copilot notes in this review pass (the '# type: ignore[unreachable]' comments in test_scanner.py) are not dead: mypy explicitly flags those exact lines as unreachable, verified by stripping the ignores and re-running mypy. Restored the two that were correctly placed and removed the one stray ignore the last batch put on a still-reachable line so mypy stays clean. auto_scheduler.py coverage: 100% line + 100% branch. --- src/habluetooth/auto_scheduler.py | 16 +++++++++-- src/habluetooth/manager.py | 15 ++++++++--- tests/test_auto_scheduler.py | 44 ++++++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index b160bb2a..25ad395a 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -106,6 +106,7 @@ import asyncio import contextlib import logging +import math from typing import TYPE_CHECKING from .const import ( @@ -626,9 +627,20 @@ def _wake_worker(self, source: str) -> None: worker.wake() def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: - """Pick the max requested duration, clamped to the configured range.""" + """ + Pick the max requested duration, clamped to the configured range. + + ``async_register_active_scan`` already rejects NaN / inf + inputs, but ``ActiveScanRequest`` can be constructed directly + (e.g., internal callers); the ``isfinite`` guard here keeps a + bad value from poisoning ``call_later``'s timeout. + """ requested = max( - (e.scan_duration for e in entries if e.scan_duration is not None), + ( + e.scan_duration + for e in entries + if e.scan_duration is not None and math.isfinite(e.scan_duration) + ), default=_AUTO_WINDOW_MIN_DURATION, ) if requested < _AUTO_WINDOW_MIN_DURATION: diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index d9196423..f45cf2ef 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -5,6 +5,7 @@ import asyncio import itertools import logging +import math import platform from collections.abc import Callable, Iterable from dataclasses import asdict @@ -1101,13 +1102,19 @@ def async_register_active_scan( scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL if scan_duration is None: scan_duration = DEFAULT_ACTIVE_SCAN_DURATION - if scan_interval < MIN_ACTIVE_SCAN_INTERVAL: + # Reject non-finite values explicitly: NaN compared to anything + # returns False, so a NaN would slip past the lower-bound + # checks below and end up in _needs and call_later as a NaN + # due-time / duration, busy-looping the worker. + if not math.isfinite(scan_interval) or scan_interval < MIN_ACTIVE_SCAN_INTERVAL: raise ValueError( - f"scan_interval must be >= {MIN_ACTIVE_SCAN_INTERVAL:.0f}s" + f"scan_interval must be a finite number >= " + f"{MIN_ACTIVE_SCAN_INTERVAL:.0f}s" ) - if scan_duration < MIN_ACTIVE_SCAN_DURATION: + if not math.isfinite(scan_duration) or scan_duration < MIN_ACTIVE_SCAN_DURATION: raise ValueError( - f"scan_duration must be >= {MIN_ACTIVE_SCAN_DURATION:.0f}s" + f"scan_duration must be a finite number >= " + f"{MIN_ACTIVE_SCAN_DURATION:.0f}s" ) request = ActiveScanRequest(address.upper(), scan_interval, scan_duration) self._auto_scheduler.add_request(request) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 2d4fb7a1..a266cd14 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -648,22 +648,34 @@ async def test_register_active_scan_validates_inputs() -> None: """scan_interval / scan_duration below the configured minimums raise.""" manager = get_manager() # scan_interval below 60s. - with pytest.raises(ValueError, match="scan_interval must be >="): + with pytest.raises(ValueError, match="scan_interval must"): manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=0) - with pytest.raises(ValueError, match="scan_interval must be >="): + with pytest.raises(ValueError, match="scan_interval must"): manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=30.0) # scan_duration below 5s. - with pytest.raises(ValueError, match="scan_duration must be >="): + with pytest.raises(ValueError, match="scan_duration must"): manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=-0.5 ) - with pytest.raises(ValueError, match="scan_duration must be >="): + with pytest.raises(ValueError, match="scan_duration must"): manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=4.5 ) # Empty address. with pytest.raises(ValueError, match="address must be a non-empty string"): manager.async_register_active_scan("", scan_interval=60.0) + # Non-finite values must be rejected: NaN compared to anything + # returns False, so without the explicit isfinite() check a NaN + # would slip past the lower-bound validators. + import math as _math + + for bad in (_math.nan, _math.inf, -_math.inf): + with pytest.raises(ValueError, match="scan_interval must be a finite number"): + manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=bad) + with pytest.raises(ValueError, match="scan_duration must be a finite number"): + manager.async_register_active_scan( + "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=bad + ) @pytest.mark.asyncio @@ -1477,6 +1489,30 @@ async def test_coalesce_none_duration_uses_min() -> None: register_cancel() +@pytest.mark.asyncio +async def test_coalesce_skips_non_finite_durations() -> None: + """ + A NaN / inf scan_duration on a hand-built request must not propagate. + + async_register_active_scan rejects non-finite values at the + boundary, but ActiveScanRequest can be constructed directly; + _coalesce_duration must defensively skip non-finite scan_duration + entries so a bad value never lands in call_later as a NaN + timeout. + """ + import math as _math + + manager = get_manager() + sched = manager._auto_scheduler + bad = ActiveScanRequest("ZZ:00:00:00:00:00", 60.0, _math.nan) + good = ActiveScanRequest("ZZ:00:00:00:00:01", 60.0, 10.0) + result = sched._coalesce_duration([bad, good]) + assert result == 10.0 + # All-NaN falls back to MIN. + result_all_bad = sched._coalesce_duration([bad]) + assert result_all_bad == AUTO_WINDOW_MIN_DURATION + + @pytest.mark.asyncio async def test_coalesce_only_due_requests_count() -> None: """Only the requests that are actually due contribute to coalesced duration.""" From 522181aa55f981fa6c47d5fa3fbcaefcaad479e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 16:57:37 -0500 Subject: [PATCH 63/75] revert(auto): drop per-tick isfinite check in _coalesce_duration The hot-path isfinite filter added in da66e9c is unnecessary: NaN / inf can no longer enter the scheduler via the public API (async_register_active_scan rejects them at registration), so defending again per-tick is wasted work. _coalesce_duration goes back to trusting the boundary check; the docstring spells out the contract for internal callers that construct ActiveScanRequest directly. Drop the now-irrelevant test_coalesce_skips_non_finite_durations test. The boundary check in async_register_active_scan and its test_register_active_scan_validates_inputs coverage stay in place; NaN can't enter the system in the first place. --- src/habluetooth/auto_scheduler.py | 16 ++++++---------- tests/test_auto_scheduler.py | 24 ------------------------ 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 25ad395a..53570ed6 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -106,7 +106,6 @@ import asyncio import contextlib import logging -import math from typing import TYPE_CHECKING from .const import ( @@ -630,17 +629,14 @@ def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """ Pick the max requested duration, clamped to the configured range. - ``async_register_active_scan`` already rejects NaN / inf - inputs, but ``ActiveScanRequest`` can be constructed directly - (e.g., internal callers); the ``isfinite`` guard here keeps a - bad value from poisoning ``call_later``'s timeout. + Hot path; trusts ``scan_duration`` to be a finite number. + ``async_register_active_scan`` rejects non-finite values at + the public boundary, so this function does not pay a per-tick + ``isfinite`` cost. Internal callers that construct + ``ActiveScanRequest`` directly must respect the same contract. """ requested = max( - ( - e.scan_duration - for e in entries - if e.scan_duration is not None and math.isfinite(e.scan_duration) - ), + (e.scan_duration for e in entries if e.scan_duration is not None), default=_AUTO_WINDOW_MIN_DURATION, ) if requested < _AUTO_WINDOW_MIN_DURATION: diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index a266cd14..5aab8388 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -1489,30 +1489,6 @@ async def test_coalesce_none_duration_uses_min() -> None: register_cancel() -@pytest.mark.asyncio -async def test_coalesce_skips_non_finite_durations() -> None: - """ - A NaN / inf scan_duration on a hand-built request must not propagate. - - async_register_active_scan rejects non-finite values at the - boundary, but ActiveScanRequest can be constructed directly; - _coalesce_duration must defensively skip non-finite scan_duration - entries so a bad value never lands in call_later as a NaN - timeout. - """ - import math as _math - - manager = get_manager() - sched = manager._auto_scheduler - bad = ActiveScanRequest("ZZ:00:00:00:00:00", 60.0, _math.nan) - good = ActiveScanRequest("ZZ:00:00:00:00:01", 60.0, 10.0) - result = sched._coalesce_duration([bad, good]) - assert result == 10.0 - # All-NaN falls back to MIN. - result_all_bad = sched._coalesce_duration([bad]) - assert result_all_bad == AUTO_WINDOW_MIN_DURATION - - @pytest.mark.asyncio async def test_coalesce_only_due_requests_count() -> None: """Only the requests that are actually due contribute to coalesced duration.""" From 7f4aeacb0daf3454a22655cab70f6bd6b1e6c1f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 17:01:33 -0500 Subject: [PATCH 64/75] fix(auto): tighten ActiveScanRequest.scan_duration to non-Optional scan_duration was typed as float | None to permit hand-built internal requests, but None handling forced a per-tick filter in _coalesce_duration. async_register_active_scan already substitutes DEFAULT_ACTIVE_SCAN_DURATION for None at the public boundary, so the None path is unused by the supported entry point. Tighten the type to plain float, drop the .pxd object boxing in favor of a typed double, remove the `if e.scan_duration is not None` filter from _coalesce_duration, and update the docstring on ActiveScanRequest to document the contract for direct callers. Test updates: - Replace the handful of test sites that built ActiveScanRequest with scan_duration=None to use 10.0 instead. - Drop test_coalesce_none_duration_uses_min since None is no longer representable; the empty-list fallback path is now covered inline in test_duration_clamped_to_bounds with _coalesce_duration([]). Coverage stays at 100% line + 100% branch on auto_scheduler.py. --- src/habluetooth/auto_scheduler.pxd | 2 +- src/habluetooth/auto_scheduler.py | 25 +++++++++++------ tests/test_auto_scheduler.py | 44 ++++++------------------------ 3 files changed, 26 insertions(+), 45 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 43693052..2dfdeb24 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -13,7 +13,7 @@ cdef class ActiveScanRequest: cdef public str address cdef public double scan_interval - cdef public object scan_duration + cdef public double scan_duration cdef class _ScannerWorker: diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 53570ed6..b233ddae 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -135,7 +135,15 @@ class ActiveScanRequest: - """A registered need for on-demand active scans on a specific address.""" + """ + A registered need for on-demand active scans on a specific address. + + ``scan_interval`` and ``scan_duration`` must both be finite positive + floats. ``async_register_active_scan`` enforces the boundary + (rejecting NaN / inf / below-minimum values and substituting the + DEFAULT_* constants when callers pass None); internal callers + constructing this directly are expected to honor the same contract. + """ __slots__ = ("address", "scan_duration", "scan_interval") @@ -143,7 +151,7 @@ def __init__( self, address: str, scan_interval: float, - scan_duration: float | None, + scan_duration: float, ) -> None: self.address = address self.scan_interval = scan_interval @@ -629,14 +637,15 @@ def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """ Pick the max requested duration, clamped to the configured range. - Hot path; trusts ``scan_duration`` to be a finite number. - ``async_register_active_scan`` rejects non-finite values at - the public boundary, so this function does not pay a per-tick - ``isfinite`` cost. Internal callers that construct - ``ActiveScanRequest`` directly must respect the same contract. + Hot path; trusts ``ActiveScanRequest.scan_duration`` to be a + finite positive float. The public boundary + (``async_register_active_scan``) substitutes + ``DEFAULT_ACTIVE_SCAN_DURATION`` for ``None`` and rejects + NaN / inf / below-minimum values, so this function pays no + per-tick None / isfinite cost. """ requested = max( - (e.scan_duration for e in entries if e.scan_duration is not None), + (e.scan_duration for e in entries), default=_AUTO_WINDOW_MIN_DURATION, ) if requested < _AUTO_WINDOW_MIN_DURATION: diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 5aab8388..3103b535 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -591,7 +591,7 @@ async def test_duration_clamped_to_bounds() -> None: """_coalesce_duration clamps the requested duration to the configured range.""" sched = get_manager()._auto_scheduler - def _req(duration: float | None) -> ActiveScanRequest: + def _req(duration: float) -> ActiveScanRequest: return ActiveScanRequest("AA", 60.0, duration) assert sched._coalesce_duration([_req(0.01)]) == AUTO_WINDOW_MIN_DURATION @@ -601,7 +601,8 @@ def _req(duration: float | None) -> ActiveScanRequest: assert ( sched._coalesce_duration([_req(7.5), _req(1000.0)]) == AUTO_WINDOW_MAX_DURATION ) - assert sched._coalesce_duration([_req(None)]) == AUTO_WINDOW_MIN_DURATION + # Empty list falls back to the configured minimum. + assert sched._coalesce_duration([]) == AUTO_WINDOW_MIN_DURATION @pytest.mark.asyncio @@ -1219,7 +1220,7 @@ async def test_remove_request_handles_missing_bucket() -> None: """remove_request tolerates a request whose bucket is already gone.""" manager = get_manager() sched = manager._auto_scheduler - request = ActiveScanRequest("AA:BB:CC:DD:EE:99", 60.0, None) + request = ActiveScanRequest("AA:BB:CC:DD:EE:99", 60.0, 10.0) # Bucket was never added; remove_request must be a no-op. sched.remove_request(request) assert "AA:BB:CC:DD:EE:99" not in sched._requests_by_address @@ -1285,8 +1286,8 @@ async def test_on_advertisement_with_all_requests_already_tracked() -> None: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) address = "11:22:33:44:55:66" - req_a = ActiveScanRequest(address, 60.0, None) - req_b = ActiveScanRequest(address, 120.0, None) + req_a = ActiveScanRequest(address, 60.0, 10.0) + req_b = ActiveScanRequest(address, 120.0, 10.0) sched._requests_by_address[address] = {req_a, req_b} sched._needs[address] = {req_a: 0.0, req_b: 0.0} try: @@ -1460,35 +1461,6 @@ async def test_coalesce_clamps_oversize_request() -> None: register_cancel() -@pytest.mark.asyncio -async def test_coalesce_none_duration_uses_min() -> None: - """ - An explicit None scan_duration on a request falls back to the minimum. - - Goes around async_register_active_scan (which defaults scan_duration - to DEFAULT_ACTIVE_SCAN_DURATION) to exercise the None branch of - _coalesce_duration directly with a hand-built ActiveScanRequest. - """ - manager = get_manager() - sched = manager._auto_scheduler - loop = asyncio.get_running_loop() - address = "11:22:33:44:55:66" - request = ActiveScanRequest(address, 60.0, None) - sched._requests_by_address.setdefault(address, set()).add(request) - scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) - register_cancel = manager.async_register_scanner(scanner) - try: - _inject(scanner, address) - entries = sched._needs[address] - for req in list(entries): - entries[req] = loop.time() - 1.0 - await sched._workers[scanner.source]._tick() - assert scanner.active_window_calls == [AUTO_WINDOW_MIN_DURATION] - finally: - sched.remove_request(request) - register_cancel() - - @pytest.mark.asyncio async def test_coalesce_only_due_requests_count() -> None: """Only the requests that are actually due contribute to coalesced duration.""" @@ -1787,7 +1759,7 @@ async def test_add_request_before_start_does_not_seed_needs() -> None: original_loop = sched._loop sched._loop = None try: - sched.add_request(ActiveScanRequest(address, 60.0, None)) + sched.add_request(ActiveScanRequest(address, 60.0, 10.0)) assert address in sched._requests_by_address assert address not in sched._needs finally: @@ -1810,7 +1782,7 @@ async def test_add_request_idempotent_keeps_existing_due() -> None: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:42", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: - request = ActiveScanRequest(address, 60.0, None) + request = ActiveScanRequest(address, 60.0, 10.0) sched.add_request(request) # Inject so add_request can see history on the second call. _inject(scanner, address) From 17da0ca4299f3b83d8826b4f360b8b85255a8ce5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 17:10:43 -0500 Subject: [PATCH 65/75] docs(auto): trim verbose docstrings and address macOS-UUID correctness 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. --- src/habluetooth/auto_scheduler.py | 265 +++++++++++------------------- src/habluetooth/manager.py | 41 ++--- src/habluetooth/scanner.py | 45 ++--- tests/test_auto_scheduler.py | 22 +++ 4 files changed, 156 insertions(+), 217 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index b233ddae..8fba15fe 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -136,13 +136,11 @@ class ActiveScanRequest: """ - A registered need for on-demand active scans on a specific address. + A registered need for on-demand active scans on one address. - ``scan_interval`` and ``scan_duration`` must both be finite positive - floats. ``async_register_active_scan`` enforces the boundary - (rejecting NaN / inf / below-minimum values and substituting the - DEFAULT_* constants when callers pass None); internal callers - constructing this directly are expected to honor the same contract. + ``scan_interval`` and ``scan_duration`` must be finite positive + floats. ``async_register_active_scan`` enforces this at the + public boundary; direct constructors must honor the same contract. """ __slots__ = ("address", "scan_duration", "scan_interval") @@ -191,12 +189,10 @@ def start( self, loop: asyncio.AbstractEventLoop, initial_offset: float = 0.0 ) -> None: """ - Start the worker task; first sweep AUTO_INITIAL_SWEEP_DELAY out. + Start the worker; first sweep at AUTO_INITIAL_SWEEP_DELAY + offset. - ``initial_offset`` lets the caller stagger first sweeps across - concurrently-registered scanners so they don't all flip ACTIVE in - the same second; subsequent sweeps stay staggered because each - worker advances its own clock from when its prior window finished. + ``initial_offset`` staggers first sweeps across concurrently- + registered scanners so they don't all flip ACTIVE at once. """ self._sweep_last_completed = ( loop.time() @@ -219,11 +215,9 @@ def _next_event_at(self, now: float) -> float: """ Return the earliest loop-time at which this worker has work. - O(M) over every tracked address per wake. Acceptable at HA's - typical scale (a few dozen registered devices per manager); if - the API gets adopted by deployments with hundreds of registered - devices, replace with a per-worker invariant maintained at - add_request/on_advertisement/_advance_due time so this is O(1). + O(M) over tracked addresses per wake. Fine at HA scale (a few + dozen devices); replace with a per-worker invariant maintained + at add_request/on_advertisement/_advance_due time if M grows. """ if self._window_end > now: return self._window_end @@ -264,13 +258,12 @@ def _collect_due_buckets(self, now: float) -> tuple[ list[ActiveScanRequest], ]: """ - Return (due_buckets, all_due) for every address this scanner owns. + Return (due_buckets, all_due) for addresses this scanner owns. - ``due_buckets`` is the list of (entries dict, due requests) pairs to - advance after the window fires; ``all_due`` is the flattened list of - every due request, used to coalesce the window duration. - Addresses whose owning scanner is no longer known are pruned from - ``_needs`` in passing. + ``due_buckets`` is the (entries, due) pairs to advance after + the window; ``all_due`` is the flattened list used to coalesce + the window duration. Prunes orphan ``_needs`` entries + (history None) in passing. """ source = self._scanner.source needs = self._scheduler._needs @@ -306,13 +299,11 @@ def _advance_due( """ Set every advanced request's next-due to from_time + scan_interval. - ``from_time`` is the timestamp the next-due is measured against; - ``_tick`` passes the tick's start ``now`` so ``scan_interval`` - is the period between window starts. Called pre-await from - ``_tick`` so the window's owner has already claimed the slot - before any other worker can wake; no membership check is needed - because nothing has yielded since ``_collect_due_buckets`` - populated due_buckets. + ``_tick`` passes its start ``now`` so ``scan_interval`` is the + period between window starts. Called pre-await so the owner + has claimed the slot before any other worker can wake; + nothing has yielded since ``_collect_due_buckets`` populated + the buckets, so no membership re-check is needed. """ for entries, due in due_buckets: for request in due: @@ -322,28 +313,22 @@ async def _tick(self) -> None: """ Fire one coalesced window covering due per-device + sweep work. - Collection is sync; only the scanner's active-window call is - awaited. The window duration is the max of every due per-device - duration and (if the sweep is due) the configured sweep duration - so a single ACTIVE flip on the scanner catches every device it - sees during the window. ``scan_interval`` is measured between - window *starts* (not after each window ends), so the next due - time advances from ``now`` (this tick's start) rather than from - ``window_end``; the same applies to the sweep clock. The return - value of ``async_request_active_window`` is intentionally - ignored: even on failure we still advance by ``scan_interval`` - so a stuck scanner can't busy-loop the worker. + Collection is sync; only the scanner call is awaited. The + window duration is the max of every due per-device duration + and (if sweep is due) the sweep duration. Next-due / sweep + clock advance from ``now`` (tick start), not ``window_end``, + so ``scan_interval`` is a true period between window starts + rather than ``scan_interval + duration``. The scanner call's + return value is ignored: we advance on failure too so a stuck + scanner can't busy-loop the worker. """ loop = self._scheduler._loop if loop is None: return now = loop.time() - # Defense-in-depth: _tick is only ever invoked from _run on a - # single per-worker task, and the finally below clears - # _window_end after the await returns, so this re-entry guard - # cannot trip on the current call path. Keep it cheap and - # explicit so a future refactor that calls _tick from - # elsewhere can't accidentally double-fire a window. + # Defense-in-depth re-entry guard: unreachable on the current + # call path (single per-worker task, finally clears + # _window_end) but kept for future callers of _tick. if self._window_end > now: return self._window_end = 0.0 @@ -355,30 +340,20 @@ async def _tick(self) -> None: if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: duration = _AUTO_REDISCOVERY_SWEEP_DURATION self._window_end = now + duration - # Advance per-device next-due times and the sweep clock BEFORE the - # await so a concurrent worker that becomes the new owner of any - # of these addresses mid-window (e.g. an RSSI flip on a fresh - # advertisement) doesn't fire a duplicate window for the same - # request. Advancing from ``now`` (not ``window_end``) makes - # ``scan_interval`` a true period between window starts; the - # alternative ("interval after window ends") would make the - # effective cadence ``scan_interval + duration`` and drift with - # the actual stop/start cost. Failure of the scanner call is - # handled the same way as success: we still don't retry until - # scan_interval out (or AUTO_REDISCOVERY_INTERVAL out for the - # sweep), which prevents busy-looping the worker on a stuck - # scanner. + # Advance pre-await: a new owner that wakes mid-window must + # see the entries already advanced, otherwise an RSSI flip + # would let the new owner fire a duplicate window. self._advance_due(due_buckets, now) if sweep_due: self._sweep_last_completed = now try: await self._scanner.async_request_active_window(duration) except Exception as ex: # pylint: disable=broad-except - # First failure per recovery-cycle gets a full traceback; - # subsequent failures get a one-liner so a persistently - # broken scanner doesn't spam scan_interval-cadenced stack - # traces. The flag clears on the next successful call so a - # later failure-after-recovery captures a stack again. + # First failure per recovery-cycle gets a traceback; + # subsequent failures collapse to a one-liner so a + # persistently broken scanner can't spam the log. Flag + # clears on the next success so failure-after-recovery + # captures a stack again. if self._failed_window: _LOGGER.warning( "%s: error running active window of %.1fs: %s", @@ -424,21 +399,12 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: """ Bind to the event loop and spawn one worker per AUTO scanner. - Fully idempotent: if ``_running`` is already True (start was - called previously without an intervening ``stop()``), this is a - no-op so an accidental double-call can't bind a different loop - to the same scheduler or re-run the replay. A genuine restart - sequence is ``stop()`` (which sets ``_running = False``) and - then ``start(new_loop)``, which works because ``stop()`` clears - the workers dict. - - Replays any ``_requests_by_address`` registered before - ``start()`` into ``_needs`` so the first window for those - requests fires ``scan_interval`` after start (assuming the - device is in history) instead of waiting for the next - advertisement to bootstrap tracking. Same gating as - ``add_request``: no seed when ``last_service_info`` is None; - ``on_advertisement`` will bootstrap on first sight. + Idempotent: no-op if already running. A genuine restart is + ``stop()`` (which flips ``_running`` to False) then + ``start(new_loop)``. Also replays any pre-start + ``_requests_by_address`` into ``_needs`` so embedders that + register before ``async_setup`` still get the kick-start + cadence; same history-gating as ``add_request``. """ if self._running: return @@ -450,10 +416,6 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: and scanner.source not in self._workers ): self._spawn_worker(scanner) - # Replay pre-start() registrations: seed _needs for any - # request whose address already has a last_service_info, so - # the kick-start contract holds for embedders that register - # before BluetoothManager.async_setup runs. now = loop.time() last_service_info = self._manager.async_last_service_info for address, requests in self._requests_by_address.items(): @@ -468,17 +430,13 @@ def stop(self) -> None: """ Cancel all worker tasks (fire-and-forget). - Sync to match ``BluetoothManager.async_stop``. ``worker.stop()`` - calls ``task.cancel()`` but doesn't await: the cancellation - propagates on the next event-loop iteration and the task is - reaped by asyncio. If a worker is mid-``_tick`` (mid-await on - ``scanner.async_request_active_window``) when stop runs, the - scanner call may complete its current await before - ``CancelledError`` is delivered; for HA shutdown that's harmless - because the scanners themselves are being torn down. Callers - outside teardown that need to know the workers have actually - stopped should ensure the event loop runs at least one more - iteration after this returns. + Sync to match ``BluetoothManager.async_stop``; + ``worker.stop()`` calls ``task.cancel()`` without awaiting. + Cancellation lands on the next loop iteration and asyncio + reaps the task; a mid-``_tick`` scanner call may complete + first. Harmless for HA shutdown (scanners are being torn + down). Non-teardown callers should run the loop one more + iteration to know workers have actually stopped. """ self._running = False for worker in self._workers.values(): @@ -487,14 +445,11 @@ def stop(self) -> None: def add_scanner(self, scanner: BaseHaScanner) -> None: """ - Register an AUTO-mode scanner; spawn its worker if start() has run. - - Skips if the scheduler is not currently running. ``stop()`` - sets ``_running = False`` but leaves ``_loop`` set, so without - this guard a scanner registered between stop and (a possible - future) restart would spawn a worker that immediately exits - on its next iteration when ``_running`` is checked in - ``_run``. + Register an AUTO-mode scanner; spawn its worker if running. + + Skips when ``_running`` is False (``stop()`` leaves ``_loop`` + set, so without this guard a post-stop registration would + spawn a worker that exits on its first iteration). """ if scanner.requested_mode is not BluetoothScanningMode.AUTO: return @@ -506,14 +461,9 @@ def remove_scanner(self, scanner: BaseHaScanner) -> None: """ Stop the worker for a scanner leaving the manager. - Also prunes any ``_needs`` entries whose current owner is the - leaving scanner. Without this they'd sit until the device - either turns up on another scanner (history flips, that - worker picks them up) or expires from ``_all_history`` (the - next worker tick on any scanner drops them). Self-healing in - the steady state, but the explicit prune closes the small - window where a removed-and-not-rediscovered device keeps a - tracked entry pinned. + Also prunes ``_needs`` entries the scanner currently owns so + a removed-and-not-rediscovered device doesn't keep a tracked + entry pinned until the next history flip / age-out. """ source = scanner.source worker = self._workers.pop(source, None) @@ -528,16 +478,12 @@ def remove_scanner(self, scanner: BaseHaScanner) -> None: def _spawn_worker(self, scanner: BaseHaScanner) -> None: assert self._loop is not None # noqa: S101 worker = _ScannerWorker(self, scanner, self._manager) - # Stagger first sweeps so concurrently-registered scanners don't - # all flip ACTIVE in the same second. Each new worker's first - # sweep is one sweep duration later than the previous one's, - # wrapped into the initial-sweep window so the Nth scanner's - # first sweep is bounded to AUTO_INITIAL_SWEEP_DELAY + delay - # rather than growing linearly with worker count. Past - # AUTO_INITIAL_SWEEP_DELAY / SWEEP_DURATION scanners the offsets - # start to repeat, which is fine: BLE radios don't interfere - # when multiple are active so collisions are harmless and the - # natural advertisement jitter spreads them out over time. + # Stagger first sweeps so concurrently-registered scanners + # don't all flip ACTIVE at once. Modulo into the initial-sweep + # window so the Nth offset is bounded; past + # AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION scanners offsets + # repeat, harmless since BLE radios don't interfere when + # multiple are active. offset = ( len(self._workers) * _AUTO_REDISCOVERY_SWEEP_DURATION ) % _AUTO_INITIAL_SWEEP_DELAY @@ -546,42 +492,30 @@ def _spawn_worker(self, scanner: BaseHaScanner) -> None: def add_request(self, request: ActiveScanRequest) -> None: """ - Register an active-scan request and start tracking immediately. - - If a previous advertisement for ``request.address`` is in - ``_all_history`` when this runs, the first window fires - ``scan_interval`` seconds after registration on the current - owner. If the device hasn't been seen yet, no ``_needs`` entry - is seeded (a speculative seed would just be pruned on the - next tick because ``_collect_due_buckets`` drops addresses - with no ``last_service_info``); ``on_advertisement`` creates - the entry and wakes the owner's worker the first time the - device is seen, so the first window fires ``scan_interval`` - after that advertisement instead. - - ``ActiveScanRequest`` is compared by identity, so each public - call to ``BluetoothManager.async_register_active_scan`` creates - a new request that contributes its own cadence to the same - address (two callers asking for windows every 60s on the same - device get two independent 60s cadences, not one). Adding the - *same* request object twice is idempotent and no-ops the - wake. Cancellation is per-registration — the callable returned - from ``async_register_active_scan`` only removes that specific - request, not other registrations against the same address. - - Pre-``start()`` registrations (no event loop yet) record the - request only — no ``_needs`` entry is seeded, no wake fires. + Register an active-scan request and start tracking. + + First window fires ``scan_interval`` after registration on + the current owner if history exists; otherwise + ``on_advertisement`` bootstraps tracking on first sight (the + first window then fires ``scan_interval`` after that ad). + + ``ActiveScanRequest`` compares by identity: each public + ``async_register_active_scan`` call adds an independent + cadence (two 60s registrations on the same address yield two + independent 60s cadences). Re-adding the same object is a + no-op; cancellation is per-registration. + + Pre-``start()`` calls record the request only; no seed, no + wake (``start()`` replays them). """ self._requests_by_address.setdefault(request.address, set()).add(request) if self._loop is None: return history = self._manager.async_last_service_info(request.address, False) if history is None: - # No history yet; seeding _needs would just be pruned on - # the next tick because _collect_due_buckets drops - # addresses with no last_service_info. on_advertisement - # will create the entry the first time the device is seen - # and wake the owner's worker. + # No history: skip the seed (the next tick would prune + # it anyway); on_advertisement will bootstrap on first + # sight. return existing = self._needs.setdefault(request.address, {}) if request in existing: @@ -602,17 +536,13 @@ def remove_request(self, request: ActiveScanRequest) -> None: def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: """ - Hot path. Track requests for the advertisement's address. - - Always wakes the worker for ``service_info.source`` when the - address has registered active-scan requests. The wake covers - two cases: (1) bootstrap, when an entry is created in _needs - because the previous owner was pruned; (2) ownership flip, - when this scanner becomes the device's new owner and its - worker needs to re-evaluate _next_event_at to include the - (already-tracked) entry. A single wake() is one Event.set - call; cheap enough to do per accepted advertisement on a - tracked address. + Hot path. Track requests for the ad's address; wake the owner. + + Wake is unconditional (when the address has requests) so it + covers both bootstrap (entry created) and ownership flip + (existing entry, this scanner is now the owner and must + re-evaluate ``_next_event_at``). ``Event.set`` is cheap + enough to fire per tracked-address advertisement. """ if not self._requests_by_address or self._loop is None: return @@ -635,14 +565,11 @@ def _wake_worker(self, source: str) -> None: def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """ - Pick the max requested duration, clamped to the configured range. - - Hot path; trusts ``ActiveScanRequest.scan_duration`` to be a - finite positive float. The public boundary - (``async_register_active_scan``) substitutes - ``DEFAULT_ACTIVE_SCAN_DURATION`` for ``None`` and rejects - NaN / inf / below-minimum values, so this function pays no - per-tick None / isfinite cost. + Pick max requested duration, clamped to [MIN, MAX]. + + Hot path; trusts ``scan_duration`` to be a finite positive + float (``async_register_active_scan`` enforces this at the + boundary). """ requested = max( (e.scan_duration for e in entries), diff --git a/src/habluetooth/manager.py b/src/habluetooth/manager.py index f45cf2ef..f030b4ea 100644 --- a/src/habluetooth/manager.py +++ b/src/habluetooth/manager.py @@ -1076,25 +1076,22 @@ def async_register_active_scan( """ Declare an on-demand active-scan need for a specific address. - ``address`` is normalized to upper-case so it matches the - case BlueZ / bleak use for advertisement source addresses; - callers don't have to think about case. ``scan_interval`` and - ``scan_duration`` default to DEFAULT_ACTIVE_SCAN_INTERVAL - (300s, 5 minutes) and DEFAULT_ACTIVE_SCAN_DURATION (10s) when - not provided; those defaults work for the typical sensor - case. Integrations that genuinely need faster updates can pass - a smaller ``scan_interval`` explicitly. The effective window - the scanner actually runs is the requested ``scan_duration`` - clamped into [AUTO_WINDOW_MIN_DURATION, - AUTO_WINDOW_MAX_DURATION] (5s..30s) and coalesced with any - other due requests for the same scanner, so very large - ``scan_duration`` values are capped rather than honored - verbatim. The scheduler asks the AUTO-mode scanner currently - in range of ``address`` to flip active for that window every - ``scan_interval`` seconds (measured between window starts, - not between successive windows) while the device is being - seen. ACTIVE and PASSIVE scanners ignore the request. Returns - a cancel callable. + Colon-form MAC addresses are normalized to upper-case to + match BlueZ / ESPHome / Shelly source addresses; UUIDs (no + colons, used by macOS CoreBluetooth) are passed through + as-is since CoreBluetooth preserves case on its source + addresses. + + ``scan_interval`` / ``scan_duration`` default to + DEFAULT_ACTIVE_SCAN_INTERVAL (300s, 5 min) and + DEFAULT_ACTIVE_SCAN_DURATION (10s); pass smaller values to + get a tighter cadence. The effective window is clamped to + [AUTO_WINDOW_MIN_DURATION, AUTO_WINDOW_MAX_DURATION] + (5s..30s) and coalesced with other due requests for the + scanner; very large ``scan_duration`` values are capped. + ``scan_interval`` is measured between window starts (not + between successive windows). ACTIVE / PASSIVE scanners + ignore the request. Returns a cancel callable. """ if not address: raise ValueError("address must be a non-empty string") @@ -1116,7 +1113,11 @@ def async_register_active_scan( f"scan_duration must be a finite number >= " f"{MIN_ACTIVE_SCAN_DURATION:.0f}s" ) - request = ActiveScanRequest(address.upper(), scan_interval, scan_duration) + # MAC addresses (colon-form) get upper-cased to match BlueZ / + # ESPHome conventions; UUIDs (macOS CoreBluetooth) pass + # through as-is. + normalized = address.upper() if ":" in address else address + request = ActiveScanRequest(normalized, scan_interval, scan_duration) self._auto_scheduler.add_request(request) return partial(self._auto_scheduler.remove_request, request) diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 6444d10d..06b80acd 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -674,27 +674,14 @@ def _arm_active_window_timer(self, duration: float) -> None: """ Schedule the end-of-window callback. - Computes ``_active_window_end`` from ``loop.time()`` at the - moment of arming so it matches the actual fire time of the - underlying ``call_later``. Earlier versions accepted an - externally-computed ``new_end`` snapshot, which drifted out of - sync with the real timer fire time across the stop/restart - cycle and let a *shorter* follow-up request masquerade as an - extension. - - Cancels any existing handle before arming the new one so two - concurrent ``async_request_active_window`` calls cannot leak a - pending timer; today only the per-scanner scheduler worker - drives this and ``_tick`` serializes per worker, so the - contention is hypothetical, but the public method name reads - as if external callers may use it and nothing else in the - lock-side path defends against the race. - - ``self._loop`` is assigned in ``async_setup`` and this method - is only reachable via ``async_request_active_window`` / - ``_async_start_attempt``, both of which run after setup. The - ``TYPE_CHECKING`` assert below is a mypy narrowing hint only; - it has no runtime effect. + Stores ``_active_window_end`` as ``loop.time() + duration`` + at arming time so it matches the real ``call_later`` fire + time; an earlier ``new_end`` snapshot drifted across the + stop/restart cycle and let a *shorter* follow-up masquerade + as an extension. Cancels any existing handle first so two + callers can't leak a pending timer. ``_loop`` is set in + ``async_setup``; the TYPE_CHECKING assert is a mypy + narrowing hint with no runtime effect. """ if TYPE_CHECKING: assert self._loop is not None @@ -709,8 +696,10 @@ async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds then restore prior mode. - No-op on non-AUTO scanners. Overlapping requests extend the - existing window in place instead of triggering a second restart. + No-op on non-AUTO scanners. While a window is already open, a + longer follow-up extends the timer in place; a shorter (or + equal) follow-up is a no-op on the timer but still returns + True. Either way no second stop/restart cycle fires. """ if self.requested_mode is not BluetoothScanningMode.AUTO: return False @@ -744,11 +733,11 @@ async def async_request_active_window(self, duration: float) -> bool: await self._async_stop_then_start_under_lock() return False except BaseException: - # Any other failure (CancelledError, unexpected BleakError - # leaking out, etc.) must not poison the next start with - # a stale ACTIVE override sitting on _scan_mode_override. - # Clear it and re-raise so cancellation / unexpected - # errors still propagate to the caller / task runner. + # CancelledError, SystemExit, KeyboardInterrupt, an + # unexpected BleakError leaking out, etc. — any of + # those must not leave _scan_mode_override stuck at + # ACTIVE for the next start. Clear and re-raise so + # propagation is preserved. self._scan_mode_override = None raise mode_after_restart = self.current_mode diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 3103b535..60d7a22a 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -699,6 +699,28 @@ async def test_register_active_scan_applies_defaults() -> None: cancel() +@pytest.mark.asyncio +async def test_register_active_scan_uuid_passes_through_unchanged() -> None: + """ + MacOS CoreBluetooth UUIDs are not uppercased. + + BlueZ / proxy addresses are colon-form MACs and get normalized + to upper-case; UUIDs (no colons) must pass through unchanged + because CoreBluetooth preserves case on its source addresses. + """ + manager = get_manager() + sched = manager._auto_scheduler + uuid = "abcd1234-5678-90ab-cdef-1234567890ab" + cancel = manager.async_register_active_scan(uuid, scan_interval=60.0) + try: + assert uuid in sched._requests_by_address + assert uuid.upper() not in sched._requests_by_address + request = next(iter(sched._requests_by_address[uuid])) + assert request.address == uuid + finally: + cancel() + + @pytest.mark.asyncio async def test_register_active_scan_normalizes_address_case() -> None: """ From 31677b3ddfa944faef511ebf3600cd13cd39f5af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 17:36:02 -0500 Subject: [PATCH 66/75] perf(scanner): toggle scanning_mode in-place across active-window cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/habluetooth/auto_scheduler.py | 13 +- src/habluetooth/scanner.py | 117 +++- tests/test_scanner.py | 867 +++++++++++++++++------------- 3 files changed, 600 insertions(+), 397 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 8fba15fe..e6876b30 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -432,11 +432,14 @@ def stop(self) -> None: Sync to match ``BluetoothManager.async_stop``; ``worker.stop()`` calls ``task.cancel()`` without awaiting. - Cancellation lands on the next loop iteration and asyncio - reaps the task; a mid-``_tick`` scanner call may complete - first. Harmless for HA shutdown (scanners are being torn - down). Non-teardown callers should run the loop one more - iteration to know workers have actually stopped. + Cancellation lands on the next loop iteration; a mid-``_tick`` + scanner call may complete first. Harmless for HA shutdown. + Callers doing an in-place restart (``stop()`` then ``start()`` + on the same scheduler) must ``await asyncio.sleep(0)`` between + them so cancelled tasks finish their finally blocks before + new workers spawn on the same sources; ``start()`` does not + guard against this since HA's setup/teardown flow never + does an in-place restart. """ self._running = False for worker in self._workers.values(): diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 06b80acd..0f70b5a5 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -105,6 +105,10 @@ class InvalidMessageError(Exception): # type: ignore[no-redef] BluetoothScanningMode.PASSIVE: "passive", # AUTO starts in passive; the scheduler will request transient ACTIVE # windows by calling HaScanner.async_request_active_window. + # On macOS, create_bleak_scanner translates AUTO -> ACTIVE before + # the dict lookup since CoreBluetooth doesn't support passive; + # async_request_active_window is a no-op there because the radio + # is already active. BluetoothScanningMode.AUTO: "passive", } @@ -132,6 +136,11 @@ def create_bleak_scanner( adapter: str | None, ) -> bleak.BleakScanner: """Create a Bleak scanner.""" + # CoreBluetooth doesn't support passive scanning, so AUTO maps to + # ACTIVE on macOS (the radio just stays in active mode and + # async_request_active_window is a no-op). + if scanning_mode is BluetoothScanningMode.AUTO and IS_MACOS: + scanning_mode = BluetoothScanningMode.ACTIVE scanner_kwargs: dict[str, Any] = { "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], } @@ -696,13 +705,17 @@ async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds then restore prior mode. - No-op on non-AUTO scanners. While a window is already open, a + No-op on non-AUTO scanners. On macOS, AUTO already maps to + active (CoreBluetooth has no passive mode), so this is a + no-op success there too. While a window is already open, a longer follow-up extends the timer in place; a shorter (or equal) follow-up is a no-op on the timer but still returns True. Either way no second stop/restart cycle fires. """ if self.requested_mode is not BluetoothScanningMode.AUTO: return False + if IS_MACOS: + return True if TYPE_CHECKING: assert self._loop is not None if self._active_window_handle is not None: @@ -718,20 +731,11 @@ async def async_request_active_window(self, duration: float) -> bool: # would have cleared current_mode to PASSIVE). Skip the # restart, arm a new timer; _async_end_active_window will # see the new handle and bail when it acquires the lock. - mode_before_restart = self.current_mode - if mode_before_restart is BluetoothScanningMode.ACTIVE: + if self.current_mode is BluetoothScanningMode.ACTIVE: self._arm_active_window_timer(duration) return True try: - await self._async_stop_then_start_under_lock() - except ScannerStartError: - # ACTIVE start failed; try to bring the scanner back up - # in its underlying AUTO/passive mode so we don't leave - # it stopped. - self._scan_mode_override = None - with contextlib.suppress(ScannerStartError): - await self._async_stop_then_start_under_lock() - return False + flipped = await self._async_toggle_active_window_mode() except BaseException: # CancelledError, SystemExit, KeyboardInterrupt, an # unexpected BleakError leaking out, etc. — any of @@ -740,10 +744,13 @@ async def async_request_active_window(self, duration: float) -> bool: # propagation is preserved. self._scan_mode_override = None raise - mode_after_restart = self.current_mode - if mode_after_restart is not BluetoothScanningMode.ACTIVE: - # Linux's 4th-attempt fallback silently drops to PASSIVE. + if not flipped: + # Toggle failed; try to bring the scanner back up in + # its underlying AUTO/passive mode via the full + # teardown path so we don't leave it stopped. self._scan_mode_override = None + with contextlib.suppress(ScannerStartError): + await self._async_stop_then_start_under_lock() return False self._arm_active_window_timer(duration) return True @@ -762,6 +769,10 @@ async def _async_end_active_window(self) -> None: self._scan_mode_override = None if not self.scanning: return + if await self._async_toggle_active_window_mode(): + return + # Toggle failed; fall back to a full restart so we don't + # leave the scanner stuck in ACTIVE. try: await self._async_stop_then_start_under_lock() except ScannerStartError as ex: @@ -772,10 +783,84 @@ async def _async_end_active_window(self) -> None: ) async def _async_stop_then_start_under_lock(self) -> None: - """Stop and restart the BleakScanner; caller holds _start_stop_lock.""" + """ + Stop and restart the BleakScanner; caller holds _start_stop_lock. + + Full teardown path: nulls ``self.scanner`` and constructs a + fresh one in ``_async_start``. AUTO active-window flips use + ``_async_toggle_active_window_mode`` instead so they reuse + the existing BleakScanner instance and skip the new dbus + setup + ``restore_discoveries`` cost. + """ await self._async_stop_scanner() await self._async_start() + async def _async_toggle_active_window_mode(self) -> bool: + """ + Toggle the existing BleakScanner between active and passive. + + Reuses the live ``self.scanner`` instance: stops discovery, + mutates the backend's private ``_scanning_mode`` to the value + derived from ``_scan_mode_override or requested_mode``, then + restarts. Bleak's BlueZ backend stores ``_scanning_mode`` as + a mutable instance attribute and reads it on every ``start``, + so the flip is observable without recreating the scanner. + + Saves two costs per active-window cycle vs a full + stop_then_start: a fresh dbus client construction and the + ``restore_discoveries`` repopulation that runs on every new + instance. Bleak's internal device cache survives the + same-instance stop+start so ``BleakClient(address)`` lookups + keep working across the flip. + + Linux/BlueZ only. On macOS AUTO maps to permanent active + scanning so ``async_request_active_window`` early-returns + without ever reaching this method. The watchdog / + ``async_stop`` / ``async_start`` paths still go through the + full-teardown ``_async_stop_then_start_under_lock`` so this + private-attribute access is bounded to the active-window + flip path. + + Returns ``False`` if the scanner has been torn down (caller + should fall back to the full path); otherwise returns + ``True`` after a successful flip. + """ + if self.scanner is None: + return False + effective_mode = self._scan_mode_override or self.requested_mode + if TYPE_CHECKING: + assert effective_mode is not None + mode_str = SCANNING_MODE_TO_BLEAK[effective_mode] + try: + async with asyncio.timeout(STOP_TIMEOUT): + await self.scanner.stop() + except (TimeoutError, BleakError) as ex: + _LOGGER.warning( + "%s: Error stopping scanner during active-window flip: %s", + self.name, + ex, + ) + return False + # Private bleak attribute, but the only public way to change + # mode is to recreate the scanner. The BlueZ backend reads + # this on every start; CoreBluetooth (not supported for + # AUTO) reads it at construction so this branch never runs + # on macOS. + self.scanner._backend._scanning_mode = mode_str + try: + async with asyncio.timeout(START_TIMEOUT): + await self.scanner.start() + except (TimeoutError, BleakError, ScannerStartError) as ex: + _LOGGER.warning( + "%s: Error starting scanner during active-window flip: %s", + self.name, + ex, + ) + return False + self.scanning = True + self.set_current_mode(effective_mode) + return True + async def _async_stop_scanner(self) -> None: """Stop bluetooth discovery under the lock.""" self.scanning = False diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 0c341b66..ba7f9709 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -4,6 +4,7 @@ import logging import platform import time +import types from datetime import timedelta from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch @@ -363,6 +364,8 @@ async def test_adapter_needs_reset_at_start( mock_discovered: list[Any] = [] class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start @@ -414,6 +417,8 @@ async def test_recovery_from_dbus_restart() -> None: mock_discovered: list[Any] = [] class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + def __init__(self, detection_callback, *args, **kwargs): nonlocal _callback _callback = detection_callback @@ -496,6 +501,8 @@ async def test_adapter_recovery() -> None: mock_discovered: list[Any] = [] class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start @@ -597,6 +604,8 @@ async def test_adapter_scanner_fails_to_start_first_time() -> None: mock_discovered: list[Any] = [] class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start @@ -713,6 +722,8 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( mock_discovered: list[Any] = [] class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start @@ -785,6 +796,8 @@ async def test_restart_takes_longer_than_watchdog_time( called_start = 0 class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start @@ -853,6 +866,8 @@ async def test_setup_and_stop_macos() -> None: init_kwargs = None class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + def __init__(self, *args, **kwargs): """Init the scanner.""" nonlocal init_kwargs @@ -894,6 +909,8 @@ async def test_adapter_init_fails_fallback_to_passive( mock_discovered: list[Any] = [] class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start @@ -1726,53 +1743,61 @@ async def test_async_request_active_window_rejected_when_not_auto() -> None: @pytest.mark.asyncio async def test_async_request_active_window_restarts_scanner_in_active_mode() -> None: """An AUTO scanner flips to ACTIVE and schedules a return to the prior mode.""" + with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - def __init__(self): - self.start_modes: list[str] = [] + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - self.start_modes.append("started") + def __init__(self): + self.start_modes: list[str] = [] - async def stop(self): - pass + async def start(self): + self.start_modes.append("started") - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - starts: list[str] = [] + def register_detection_callback(self, callback): + pass - def _factory(*_args, **kwargs): - starts.append(kwargs["scanning_mode"]) - return MockBleakScanner() + starts: list[str] = [] - with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - # Initial start: AUTO maps to passive in bleak's scanning_mode. - assert starts == ["passive"] - - # Window with 0 duration so call_later fires on the next loop turn. - assert await scanner.async_request_active_window(0.0) is True - # The restart cycle ran in ACTIVE mode. - assert starts == ["passive", "active"] - assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE - assert scanner._active_window_handle is not None - - # Let the call_later fire and the background restart task complete. - for _ in range(6): - await asyncio.sleep(0) - # End-of-window restored to passive (the underlying AUTO mode). - assert starts == ["passive", "active", "passive"] - assert scanner._scan_mode_override is None - assert scanner._active_window_handle is None # type: ignore[unreachable] + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() - await scanner.async_stop() + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Initial construction: AUTO maps to passive in bleak's + # scanning_mode. The active-window toggle path reuses + # this single BleakScanner instance and just mutates + # _backend._scanning_mode instead of constructing again. + assert starts == ["passive"] + backend = scanner.scanner._backend # type: ignore[union-attr] + backend._scanning_mode = "passive" + + # Window with 0 duration so call_later fires on the next loop turn. + assert await scanner.async_request_active_window(0.0) is True + # The toggle flipped the existing instance to active. + assert backend._scanning_mode == "active" + assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE + assert scanner._active_window_handle is not None + + # Let the call_later fire and the background restart task complete. + for _ in range(6): + await asyncio.sleep(0) + # End-of-window toggled the same instance back to passive. + assert backend._scanning_mode == "passive" + assert scanner._scan_mode_override is None + assert scanner._active_window_handle is None # type: ignore[unreachable] + + await scanner.async_stop() @pytest.mark.asyncio @@ -1793,6 +1818,8 @@ async def test_active_window_restart_does_not_log_fallback_warning( """ class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self): pass @@ -1822,6 +1849,50 @@ def register_detection_callback(self, callback): await scanner.async_stop() +@pytest.mark.asyncio +async def test_async_toggle_active_window_mode_returns_false_when_no_scanner() -> None: + """The toggle helper bails when the scanner instance is gone.""" + with patch("habluetooth.scanner.IS_MACOS", False): + scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner_obj.async_setup() + assert scanner_obj.scanner is None + assert await scanner_obj._async_toggle_active_window_mode() is False + + +@pytest.mark.asyncio +async def test_async_toggle_active_window_mode_returns_false_on_stop_error() -> None: + """The toggle helper logs and bails when scanner.stop() raises.""" + with patch("habluetooth.scanner.IS_MACOS", False): + + class StopErrorMockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + + async def start(self): + pass + + async def stop(self): + raise BleakError("simulated stop failure") + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: StopErrorMockBleakScanner(), + ): + scanner_obj = HaScanner( + BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF" + ) + scanner_obj.async_setup() + await scanner_obj.async_start() + scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE + assert await scanner_obj._async_toggle_active_window_mode() is False + + @pytest.mark.asyncio async def test_arm_active_window_timer_cancels_existing_handle() -> None: """ @@ -1836,86 +1907,96 @@ async def test_arm_active_window_timer_cancels_existing_handle() -> None: isn't reachable through normal callers, but the contract on _arm_active_window_timer must defend against it. """ + with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - async def start(self): - pass + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + pass - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_a, **_kw: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() + def register_detection_callback(self, callback): + pass - # Arm a window so there's a handle to potentially leak. - assert await scanner.async_request_active_window(100.0) is True - first_handle = scanner._active_window_handle - assert first_handle is not None - # Directly call _arm again (simulating the race-path second - # caller). The first handle must be cancelled, not leaked. - scanner._arm_active_window_timer(50.0) - assert first_handle.cancelled() - assert scanner._active_window_handle is not first_handle + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_a, **_kw: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() - await scanner.async_stop() + # Arm a window so there's a handle to potentially leak. + assert await scanner.async_request_active_window(100.0) is True + first_handle = scanner._active_window_handle + assert first_handle is not None + # Directly call _arm again (simulating the race-path second + # caller). The first handle must be cancelled, not leaked. + scanner._arm_active_window_timer(50.0) + assert first_handle.cancelled() + assert scanner._active_window_handle is not first_handle + + await scanner.async_stop() @pytest.mark.asyncio async def test_async_request_active_window_extends_existing_window() -> None: """A second request inside an active window extends the timer in place.""" + with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - async def start(self): - pass + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + pass - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - starts: list[str] = [] + def register_detection_callback(self, callback): + pass - def _factory(*_args, **kwargs): - starts.append(kwargs["scanning_mode"]) - return MockBleakScanner() + starts: list[str] = [] - with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() - assert await scanner.async_request_active_window(100.0) is True - first_handle = scanner._active_window_handle - first_end = scanner._active_window_end - # A longer request extends the existing window without a second restart. - assert await scanner.async_request_active_window(200.0) is True - assert scanner._active_window_handle is not first_handle - assert scanner._active_window_end > first_end - # Restart only happened once (initial + active), not three times. - assert starts == ["passive", "active"] - # A shorter follow-up is a no-op on the timer. - kept_end = scanner._active_window_end - assert await scanner.async_request_active_window(0.001) is True - assert scanner._active_window_end == kept_end + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() - await scanner.async_stop() + assert await scanner.async_request_active_window(100.0) is True + first_handle = scanner._active_window_handle + first_end = scanner._active_window_end + # A longer request extends the existing window without a second restart. + assert await scanner.async_request_active_window(200.0) is True + assert scanner._active_window_handle is not first_handle + assert scanner._active_window_end > first_end + # Only one BleakScanner construction happened (the initial + # passive one). The active-window flip toggles the existing + # instance's _backend._scanning_mode instead of creating a + # new scanner. + assert starts == ["passive"] + assert scanner.current_mode is BluetoothScanningMode.ACTIVE + # A shorter follow-up is a no-op on the timer. + kept_end = scanner._active_window_end + assert await scanner.async_request_active_window(0.001) is True + assert scanner._active_window_end == kept_end + + await scanner.async_stop() @pytest.mark.asyncio @@ -1935,72 +2016,74 @@ async def test_async_request_active_window_end_time_matches_real_timer() -> None deterministically rather than relying on asyncio.sleep precision, which can fire slightly early on busy CI runners. """ - duration = 10.0 - restart_started = asyncio.Event() - gate = asyncio.Event() - - class GatedMockBleakScanner: - _first_start_done = False + with patch("habluetooth.scanner.IS_MACOS", False): + duration = 10.0 + restart_started = asyncio.Event() + gate = asyncio.Event() - async def start(self): - if not type(self)._first_start_done: - type(self)._first_start_done = True - return - restart_started.set() - await gate.wait() + class GatedMockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + _first_start_done = False - async def stop(self): - pass + async def start(self): + if not type(self)._first_start_done: + type(self)._first_start_done = True + return + restart_started.set() + await gate.wait() - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *a, **k: GatedMockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() + def register_detection_callback(self, callback): + pass - loop = asyncio.get_running_loop() - before = loop.time() - task = asyncio.create_task(scanner.async_request_active_window(duration)) - await restart_started.wait() - # Provably advance loop.time() past `before` before the restart - # completes; the exact amount doesn't matter for the assertion - # below as long as loop.time() has visibly moved. - await asyncio.sleep(0.05) - elapsed = loop.time() - before - gate.set() - assert await task is True - - # Contract: _active_window_end matches loop.time() + duration - # measured AFTER the restart, not before. Pre-fix it would be - # before + duration. Allow generous tolerance for the small - # gap between arming and reading. - now = loop.time() - assert scanner._active_window_end == pytest.approx(now + duration, abs=0.1) - # Reject pre-fix value (before + duration) explicitly with a - # margin well above asyncio scheduling jitter: the stored end - # is at least ``elapsed`` ahead of before + duration. - assert scanner._active_window_end - before - duration >= elapsed / 2 - first_handle = scanner._active_window_handle - - # A follow-up whose new_end lands between the pre-fix stored - # end and the real fire time must NOT be treated as an - # extension. With the fix this is rejected; without it the - # live timer would be cancelled and armed shorter. - target_new_end = before + duration + elapsed / 2 - shorter_duration = target_new_end - loop.time() - assert await scanner.async_request_active_window(shorter_duration) is True - assert scanner._active_window_handle is first_handle + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *a, **k: GatedMockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() - await scanner.async_stop() + loop = asyncio.get_running_loop() + before = loop.time() + task = asyncio.create_task(scanner.async_request_active_window(duration)) + await restart_started.wait() + # Provably advance loop.time() past `before` before the restart + # completes; the exact amount doesn't matter for the assertion + # below as long as loop.time() has visibly moved. + await asyncio.sleep(0.05) + elapsed = loop.time() - before + gate.set() + assert await task is True + + # Contract: _active_window_end matches loop.time() + duration + # measured AFTER the restart, not before. Pre-fix it would be + # before + duration. Allow generous tolerance for the small + # gap between arming and reading. + now = loop.time() + assert scanner._active_window_end == pytest.approx(now + duration, abs=0.1) + # Reject pre-fix value (before + duration) explicitly with a + # margin well above asyncio scheduling jitter: the stored end + # is at least ``elapsed`` ahead of before + duration. + assert scanner._active_window_end - before - duration >= elapsed / 2 + first_handle = scanner._active_window_handle + + # A follow-up whose new_end lands between the pre-fix stored + # end and the real fire time must NOT be treated as an + # extension. With the fix this is rejected; without it the + # live timer would be cancelled and armed shorter. + target_new_end = before + duration + elapsed / 2 + shorter_duration = target_new_end - loop.time() + assert await scanner.async_request_active_window(shorter_duration) is True + assert scanner._active_window_handle is first_handle + + await scanner.async_stop() @pytest.mark.asyncio @@ -2012,127 +2095,141 @@ async def test_async_request_active_window_skips_restart_if_still_active() -> No before the bg task runs reuses the in-flight ACTIVE mode and just arms a new timer. """ + with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - async def start(self): - pass + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + pass - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - starts: list[str] = [] + def register_detection_callback(self, callback): + pass - def _factory(*_args, **kwargs): - starts.append(kwargs["scanning_mode"]) - return MockBleakScanner() + starts: list[str] = [] - with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - assert starts == ["passive"] - - assert await scanner.async_request_active_window(100.0) is True - assert starts == ["passive", "active"] - # Simulate the timer firing but the end-window task not having - # run yet: clear the handle (like _schedule_end_active_window - # does) but leave _scan_mode_override / current_mode == ACTIVE. - handle = scanner._active_window_handle - assert handle is not None - handle.cancel() - scanner._active_window_handle = None - - # Scanner is still ACTIVE; the new request must just re-arm - # the timer, not do an active->passive->active pair. - before_len = len(starts) - assert await scanner.async_request_active_window(50.0) is True - assert scanner._active_window_handle is not None - # No new starts; the restart was skipped. - assert len(starts) == before_len # type: ignore[unreachable] + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() - await scanner.async_stop() + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Single construction (passive); toggle reuses the instance. + assert starts == ["passive"] + backend = scanner.scanner._backend # type: ignore[union-attr] + backend._scanning_mode = "passive" + + assert await scanner.async_request_active_window(100.0) is True + # Toggle flipped the existing instance to active. + assert backend._scanning_mode == "active" + # Simulate the timer firing but the end-window task not having + # run yet: clear the handle (like _schedule_end_active_window + # does) but leave _scan_mode_override / current_mode == ACTIVE. + handle = scanner._active_window_handle + assert handle is not None + handle.cancel() + scanner._active_window_handle = None + + # Scanner is still ACTIVE; the new request must just re-arm + # the timer, not flip the radio again. + assert await scanner.async_request_active_window(50.0) is True + assert scanner._active_window_handle is not None + # Mode unchanged: no toggle happened on the still-ACTIVE path. + assert backend._scanning_mode == "active" # type: ignore[unreachable] + # Still only one BleakScanner construction. + assert starts == ["passive"] + + await scanner.async_stop() @pytest.mark.asyncio async def test_async_stop_clears_active_window_state() -> None: """Stopping mid-window cancels the timer and clears the override.""" + with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - async def start(self): - pass + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + pass - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - await scanner.async_request_active_window(100.0) - assert scanner._active_window_handle is not None - await scanner.async_stop() - assert scanner._active_window_handle is None - assert scanner._scan_mode_override is None # type: ignore[unreachable] - assert scanner._active_window_end == 0.0 + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + await scanner.async_request_active_window(100.0) + assert scanner._active_window_handle is not None + await scanner.async_stop() + assert scanner._active_window_handle is None + assert scanner._scan_mode_override is None # type: ignore[unreachable] + assert scanner._active_window_end == 0.0 @pytest.mark.asyncio async def test_async_request_active_window_recovers_on_start_failure() -> None: """If the ACTIVE restart raises, recovery brings the scanner back up.""" - call_count = 0 - fail_until = 0 + with patch("habluetooth.scanner.IS_MACOS", False): + call_count = 0 + fail_until = 0 - class MockBleakScanner: - async def start(self): - nonlocal call_count - call_count += 1 - if call_count <= fail_until: - raise BleakError("simulated start failure") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + nonlocal call_count + call_count += 1 + if call_count <= fail_until: + raise BleakError("simulated start failure") - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - before = call_count - # Fail the next 4 start attempts so the ACTIVE swap raises; - # then succeed so the recovery restart can come back up. - fail_until = call_count + 4 - result = await scanner.async_request_active_window(1.0) - assert result is False - assert scanner._scan_mode_override is None - # Recovery restart happened after the failure path. - assert call_count > before + 4 - await scanner.async_stop() + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + before = call_count + # Fail the next 4 start attempts so the ACTIVE swap raises; + # then succeed so the recovery restart can come back up. + fail_until = call_count + 4 + result = await scanner.async_request_active_window(1.0) + assert result is False + assert scanner._scan_mode_override is None + # Recovery restart happened after the failure path. + assert call_count > before + 4 + await scanner.async_stop() @pytest.mark.asyncio @@ -2148,42 +2245,45 @@ async def test_async_request_active_window_clears_override_on_unexpected_error() _async_start_attempt would then see effective_mode = ACTIVE instead of AUTO, poisoning subsequent starts. """ - start_count = 0 + with patch("habluetooth.scanner.IS_MACOS", False): + start_count = 0 - class MockBleakScanner: - async def start(self): - nonlocal start_count - start_count += 1 - # First start (initial async_start) succeeds; second start - # (the ACTIVE restart from async_request_active_window) - # raises a non-ScannerStartError so we exercise the - # broad-except cleanup path. - if start_count > 1: - raise RuntimeError("simulated unexpected error") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + nonlocal start_count + start_count += 1 + # First start (initial async_start) succeeds; second start + # (the ACTIVE restart from async_request_active_window) + # raises a non-ScannerStartError so we exercise the + # broad-except cleanup path. + if start_count > 1: + raise RuntimeError("simulated unexpected error") - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - with pytest.raises(RuntimeError, match="simulated unexpected error"): - await scanner.async_request_active_window(1.0) - # The override must be cleared even though the exception - # wasn't a ScannerStartError. - assert scanner._scan_mode_override is None - await scanner.async_stop() + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + with pytest.raises(RuntimeError, match="simulated unexpected error"): + await scanner.async_request_active_window(1.0) + # The override must be cleared even though the exception + # wasn't a ScannerStartError. + assert scanner._scan_mode_override is None + await scanner.async_stop() @pytest.mark.asyncio @@ -2231,37 +2331,40 @@ def discovered_addresses(self) -> Iterable[str]: @pytest.mark.asyncio async def test_async_end_active_window_defers_to_new_window() -> None: """If a new window armed the timer, the end-window task returns early.""" + with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - async def start(self): - pass + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + pass - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - await scanner.async_request_active_window(3600.0) - # Simulate a new window taking over by leaving the handle in place - # and call _async_end_active_window directly; it must short-circuit. - assert scanner._active_window_handle is not None - await scanner._async_end_active_window() - # Override and handle untouched because we deferred to the new window. - assert scanner._scan_mode_override == BluetoothScanningMode.ACTIVE - assert scanner._active_window_handle is not None - await scanner.async_stop() + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + await scanner.async_request_active_window(3600.0) + # Simulate a new window taking over by leaving the handle in place + # and call _async_end_active_window directly; it must short-circuit. + assert scanner._active_window_handle is not None + await scanner._async_end_active_window() + # Override and handle untouched because we deferred to the new window. + assert scanner._scan_mode_override == BluetoothScanningMode.ACTIVE + assert scanner._active_window_handle is not None + await scanner.async_stop() @pytest.mark.asyncio @@ -2269,6 +2372,8 @@ async def test_async_end_active_window_skips_when_not_scanning() -> None: """If the scanner was stopped during the window the restart is skipped.""" class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + async def start(self): pass @@ -2304,45 +2409,48 @@ def register_detection_callback(self, callback): @pytest.mark.asyncio async def test_async_request_active_window_passive_fallback_on_linux() -> None: """If the swap restart falls back to PASSIVE on Linux, request returns False.""" - starts = 0 + with patch("habluetooth.scanner.IS_MACOS", False): + starts = 0 - class MockBleakScanner: - async def start(self): - nonlocal starts - starts += 1 - # Fail the first three attempts so the 4th-attempt PASSIVE - # fallback inside _async_start_attempt kicks in. - if 2 <= starts <= 4: - raise BleakError("simulated active failure") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + nonlocal starts + starts += 1 + # Fail the first three attempts so the 4th-attempt PASSIVE + # fallback inside _async_start_attempt kicks in. + if 2 <= starts <= 4: + raise BleakError("simulated active failure") - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with ( - patch("habluetooth.scanner.IS_LINUX", True), - patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ), - patch("habluetooth.scanner.async_reset_adapter", AsyncMock()), - patch("habluetooth.scanner.ADAPTER_INIT_TIME", 0), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - result = await scanner.async_request_active_window(1.0) - # The swap ran through to the 4th attempt and fell back to PASSIVE; - # the request reports False because the scanner is not ACTIVE. - assert result is False - assert scanner._scan_mode_override is None - await scanner.async_stop() + def register_detection_callback(self, callback): + pass + + with ( + patch("habluetooth.scanner.IS_LINUX", True), + patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ), + patch("habluetooth.scanner.async_reset_adapter", AsyncMock()), + patch("habluetooth.scanner.ADAPTER_INIT_TIME", 0), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + result = await scanner.async_request_active_window(1.0) + # The swap ran through to the 4th attempt and fell back to PASSIVE; + # the request reports False because the scanner is not ACTIVE. + assert result is False + assert scanner._scan_mode_override is None + await scanner.async_stop() @pytest.mark.asyncio @@ -2350,47 +2458,54 @@ async def test_async_end_active_window_handles_start_error( caplog: pytest.LogCaptureFixture, ) -> None: """ScannerStartError during the end-of-window restart logs a warning.""" - starts = 0 - fail_until = 0 + with patch("habluetooth.scanner.IS_MACOS", False): + starts = 0 + fail_until = 0 - class MockBleakScanner: - async def start(self): - nonlocal starts - starts += 1 - if starts <= fail_until: - raise BleakError("simulated end-window failure") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def stop(self): - pass + async def start(self): + nonlocal starts + starts += 1 + if starts <= fail_until: + raise BleakError("simulated end-window failure") - @property - def discovered_devices(self): - return [] + async def stop(self): + pass - def register_detection_callback(self, callback): - pass + @property + def discovered_devices(self): + return [] - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - # Open a long active window then drive end-of-window with the - # bleak start mocked to fail. - await scanner.async_request_active_window(3600.0) - assert scanner._active_window_handle is not None - fail_until = starts + 4 - scanner._active_window_handle.cancel() - scanner._active_window_handle = None - caplog.clear() - with caplog.at_level(logging.WARNING): - await scanner._async_end_active_window() - assert any( - "Failed to restart scanner after active window" in record.message - for record in caplog.records - ) + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Open a long active window then drive end-of-window with the + # bleak start mocked to fail. + await scanner.async_request_active_window(3600.0) + assert scanner._active_window_handle is not None + # Fail enough start() calls that BOTH the toggle attempt + # and every retry in the fallback _async_start cycle + # raise, so we exercise the + # "Failed to restart scanner after active window" warning. + fail_until = starts + 100 + scanner._active_window_handle.cancel() + scanner._active_window_handle = None + caplog.clear() + with caplog.at_level(logging.WARNING): + await scanner._async_end_active_window() + assert any( + "Failed to restart scanner after active window" in record.message + for record in caplog.records + ) @pytest.mark.parametrize("exc", [FileNotFoundError("no dbus"), BleakError("nope")]) From 67efc9b0e5a4047608e2fb463b081ff6be822c4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 17:39:37 -0500 Subject: [PATCH 67/75] test(auto): deflake first-sweep stagger assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_auto_scheduler.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index 60d7a22a..a070c60a 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -312,15 +312,17 @@ async def test_first_sweeps_stagger_across_scanners() -> None: sweep_3 = ( sched._workers[s3.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL ) - # Each subsequent worker's first sweep is one sweep-duration - # later than the previous one's (slack for loop.time() advancing - # between spawn calls). - assert sweep_2 - sweep_1 == pytest.approx( - AUTO_REDISCOVERY_SWEEP_DURATION, abs=0.01 - ) - assert sweep_3 - sweep_2 == pytest.approx( - AUTO_REDISCOVERY_SWEEP_DURATION, abs=0.01 - ) + # Each subsequent worker's first sweep is at least one + # sweep-duration later than the previous one's. The delta is + # `SWEEP_DURATION + (loop.time() drift between spawn calls)`, + # so assert the floor rather than equality with a tight + # tolerance — CI registrations can take >10ms between + # _spawn_worker calls and would otherwise flake. + assert sweep_2 - sweep_1 >= AUTO_REDISCOVERY_SWEEP_DURATION + assert sweep_3 - sweep_2 >= AUTO_REDISCOVERY_SWEEP_DURATION + # And the drift component stays small — well under a second. + assert sweep_2 - sweep_1 < AUTO_REDISCOVERY_SWEEP_DURATION + 1.0 + assert sweep_3 - sweep_2 < AUTO_REDISCOVERY_SWEEP_DURATION + 1.0 # Roughly the configured initial delay from now. assert sweep_1 - now == pytest.approx(AUTO_INITIAL_SWEEP_DELAY, abs=1.0) finally: From 04d4ff9dcb75c8bf9f112c7e8c63c389af062dca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 17:46:31 -0500 Subject: [PATCH 68/75] refactor(scanner): extract _effective_mode() helper 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. --- src/habluetooth/scanner.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 0f70b5a5..c4464138 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -387,13 +387,24 @@ async def _async_on_successful_start(self) -> None: self._async_setup_scanner_watchdog() await restore_discoveries(self.scanner, self.adapter) + def _effective_mode(self) -> BluetoothScanningMode | None: + """ + Return the mode the scanner should actually start in. + + ``_scan_mode_override`` takes precedence so the scheduler can + transiently flip an AUTO scanner to ACTIVE for an on-demand + window without losing the integration-declared + ``requested_mode``. + """ + return self._scan_mode_override or self.requested_mode + async def _async_start_attempt(self, attempt: int) -> bool: """Start the scanner and handle errors.""" assert ( # noqa: S101 self._loop is not None ), "Loop is not set, call async_setup first" - effective_mode = self._scan_mode_override or self.requested_mode + effective_mode = self._effective_mode() self.set_current_mode(effective_mode) # 1st attempt - no auto reset # 2nd attempt - try to reset the adapter and wait a bit @@ -827,7 +838,7 @@ async def _async_toggle_active_window_mode(self) -> bool: """ if self.scanner is None: return False - effective_mode = self._scan_mode_override or self.requested_mode + effective_mode = self._effective_mode() if TYPE_CHECKING: assert effective_mode is not None mode_str = SCANNING_MODE_TO_BLEAK[effective_mode] From 960e426e64f81a198c950c7a89618e31726de062 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 17:56:09 -0500 Subject: [PATCH 69/75] fix(scanner): apply shrink-prevention guard to the still-ACTIVE locked 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. --- src/habluetooth/auto_scheduler.py | 16 +- src/habluetooth/scanner.py | 8 +- tests/test_auto_scheduler.py | 37 ++ tests/test_scanner.py | 964 ++++++++++++++++-------------- 4 files changed, 570 insertions(+), 455 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index e6876b30..f287fe5c 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -434,17 +434,21 @@ def stop(self) -> None: ``worker.stop()`` calls ``task.cancel()`` without awaiting. Cancellation lands on the next loop iteration; a mid-``_tick`` scanner call may complete first. Harmless for HA shutdown. - Callers doing an in-place restart (``stop()`` then ``start()`` - on the same scheduler) must ``await asyncio.sleep(0)`` between - them so cancelled tasks finish their finally blocks before - new workers spawn on the same sources; ``start()`` does not - guard against this since HA's setup/teardown flow never - does an in-place restart. + Also nulls ``_loop`` so post-stop ``add_request`` / + ``on_advertisement`` fall back to the record-only path + instead of seeding ``_needs`` with timestamps from the + cancelled loop. Callers doing an in-place restart + (``stop()`` then ``start(new_loop)``) must + ``await asyncio.sleep(0)`` between them so cancelled tasks + finish their finally blocks before new workers spawn on the + same sources; ``start()`` does not guard against this since + HA's setup/teardown flow never does an in-place restart. """ self._running = False for worker in self._workers.values(): worker.stop() self._workers.clear() + self._loop = None def add_scanner(self, scanner: BaseHaScanner) -> None: """ diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index c4464138..fcc68d30 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -740,10 +740,12 @@ async def async_request_active_window(self, duration: float) -> bool: # If the scanner is still ACTIVE here, the end-of-window task # for the previous timer is queued but hasn't run yet (it # would have cleared current_mode to PASSIVE). Skip the - # restart, arm a new timer; _async_end_active_window will - # see the new handle and bail when it acquires the lock. + # restart, re-arm only if our new duration extends past the + # current end; a shorter concurrent caller must not shrink + # an in-flight window someone else asked for. if self.current_mode is BluetoothScanningMode.ACTIVE: - self._arm_active_window_timer(duration) + if self._loop.time() + duration > self._active_window_end: + self._arm_active_window_timer(duration) return True try: flipped = await self._async_toggle_active_window_mode() diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index a070c60a..def9287b 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -588,6 +588,43 @@ async def test_stop_is_safe_when_already_idle() -> None: assert sched._workers == {} +@pytest.mark.asyncio +async def test_stop_clears_loop_so_post_stop_add_request_is_record_only() -> None: + """ + After stop(), add_request and on_advertisement skip _needs. + + Without nulling _loop, post-stop add_request would seed _needs + with timestamps from the cancelled loop and try to wake a worker + that no longer exists. on_advertisement is similar. Both must + fall back to the record-only / no-op path once stop() runs. + """ + manager = get_manager() + sched = manager._auto_scheduler + address = "AA:BB:CC:DD:EE:90" + scanner = _RecordingAutoScanner("AA:00:00:00:00:33", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) # seed history + sched.stop() + assert sched._loop is None + # add_request after stop: still tracked in _requests_by_address + # but no _needs seed (loop is None). + cancel = manager.async_register_active_scan(address, scan_interval=60.0) + try: + assert address in sched._requests_by_address + assert address not in sched._needs + # on_advertisement after stop is a no-op on _needs too. + _inject(scanner, address) + assert address not in sched._needs + finally: + cancel() + finally: + register_cancel() + # Restore the scheduler so the conftest teardown isn't surprised + # by a None loop. + sched.start(asyncio.get_running_loop()) + + @pytest.mark.asyncio async def test_duration_clamped_to_bounds() -> None: """_coalesce_duration clamps the requested duration to the configured range.""" diff --git a/tests/test_scanner.py b/tests/test_scanner.py index ba7f9709..3cb87456 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -5,6 +5,7 @@ import platform import time import types +from collections.abc import Generator from datetime import timedelta from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch @@ -91,6 +92,18 @@ def disable_stop_discovery(): yield +@pytest.fixture +def force_linux_scanner_mode() -> Generator[None, None, None]: + """ + Force scanner.IS_MACOS=False for the Linux/BlueZ AUTO flow. + + Lets tests exercise the active-window toggle path regardless of + the host running them; macOS would short-circuit AUTO to ACTIVE. + """ + with patch("habluetooth.scanner.IS_MACOS", False): + yield + + @pytest.fixture(autouse=True, scope="module") def manager(): """Return the BluetoothManager instance.""" @@ -1740,64 +1753,64 @@ async def test_async_request_active_window_rejected_when_not_auto() -> None: assert scanner._scan_mode_override is None +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_restarts_scanner_in_active_mode() -> None: """An AUTO scanner flips to ACTIVE and schedules a return to the prior mode.""" - with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - def __init__(self): - self.start_modes: list[str] = [] + def __init__(self): + self.start_modes: list[str] = [] - async def start(self): - self.start_modes.append("started") + async def start(self): + self.start_modes.append("started") - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - starts: list[str] = [] + starts: list[str] = [] - def _factory(*_args, **kwargs): - starts.append(kwargs["scanning_mode"]) - return MockBleakScanner() + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() - with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - # Initial construction: AUTO maps to passive in bleak's - # scanning_mode. The active-window toggle path reuses - # this single BleakScanner instance and just mutates - # _backend._scanning_mode instead of constructing again. - assert starts == ["passive"] - backend = scanner.scanner._backend # type: ignore[union-attr] - backend._scanning_mode = "passive" - - # Window with 0 duration so call_later fires on the next loop turn. - assert await scanner.async_request_active_window(0.0) is True - # The toggle flipped the existing instance to active. - assert backend._scanning_mode == "active" - assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE - assert scanner._active_window_handle is not None - - # Let the call_later fire and the background restart task complete. - for _ in range(6): - await asyncio.sleep(0) - # End-of-window toggled the same instance back to passive. - assert backend._scanning_mode == "passive" - assert scanner._scan_mode_override is None - assert scanner._active_window_handle is None # type: ignore[unreachable] + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Initial construction: AUTO maps to passive in bleak's + # scanning_mode. The active-window toggle path reuses + # this single BleakScanner instance and just mutates + # _backend._scanning_mode instead of constructing again. + assert starts == ["passive"] + backend = scanner.scanner._backend # type: ignore[union-attr] + backend._scanning_mode = "passive" + + # Window with 0 duration so call_later fires on the next loop turn. + assert await scanner.async_request_active_window(0.0) is True + # The toggle flipped the existing instance to active. + assert backend._scanning_mode == "active" + assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE + assert scanner._active_window_handle is not None + + # Let the call_later fire and the background restart task complete. + for _ in range(6): + await asyncio.sleep(0) + # End-of-window toggled the same instance back to passive. + assert backend._scanning_mode == "passive" + assert scanner._scan_mode_override is None + assert scanner._active_window_handle is None # type: ignore[unreachable] - await scanner.async_stop() + await scanner.async_stop() @pytest.mark.asyncio @@ -1849,50 +1862,49 @@ def register_detection_callback(self, callback): await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_toggle_active_window_mode_returns_false_when_no_scanner() -> None: """The toggle helper bails when the scanner instance is gone.""" - with patch("habluetooth.scanner.IS_MACOS", False): - scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner_obj.async_setup() - assert scanner_obj.scanner is None - assert await scanner_obj._async_toggle_active_window_mode() is False + scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner_obj.async_setup() + assert scanner_obj.scanner is None + assert await scanner_obj._async_toggle_active_window_mode() is False +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_toggle_active_window_mode_returns_false_on_stop_error() -> None: """The toggle helper logs and bails when scanner.stop() raises.""" - with patch("habluetooth.scanner.IS_MACOS", False): - class StopErrorMockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class StopErrorMockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - pass + async def start(self): + pass - async def stop(self): - raise BleakError("simulated stop failure") + async def stop(self): + raise BleakError("simulated stop failure") - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: StopErrorMockBleakScanner(), - ): - scanner_obj = HaScanner( - BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF" - ) - scanner_obj.async_setup() - await scanner_obj.async_start() - scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE - assert await scanner_obj._async_toggle_active_window_mode() is False + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: StopErrorMockBleakScanner(), + ): + scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner_obj.async_setup() + await scanner_obj.async_start() + scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE + assert await scanner_obj._async_toggle_active_window_mode() is False +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_arm_active_window_timer_cancels_existing_handle() -> None: """ @@ -1907,98 +1919,98 @@ async def test_arm_active_window_timer_cancels_existing_handle() -> None: isn't reachable through normal callers, but the contract on _arm_active_window_timer must defend against it. """ - with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - pass + async def start(self): + pass - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_a, **_kw: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_a, **_kw: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() - # Arm a window so there's a handle to potentially leak. - assert await scanner.async_request_active_window(100.0) is True - first_handle = scanner._active_window_handle - assert first_handle is not None - # Directly call _arm again (simulating the race-path second - # caller). The first handle must be cancelled, not leaked. - scanner._arm_active_window_timer(50.0) - assert first_handle.cancelled() - assert scanner._active_window_handle is not first_handle + # Arm a window so there's a handle to potentially leak. + assert await scanner.async_request_active_window(100.0) is True + first_handle = scanner._active_window_handle + assert first_handle is not None + # Directly call _arm again (simulating the race-path second + # caller). The first handle must be cancelled, not leaked. + scanner._arm_active_window_timer(50.0) + assert first_handle.cancelled() + assert scanner._active_window_handle is not first_handle - await scanner.async_stop() + await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_extends_existing_window() -> None: """A second request inside an active window extends the timer in place.""" - with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - pass + async def start(self): + pass - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - starts: list[str] = [] + starts: list[str] = [] - def _factory(*_args, **kwargs): - starts.append(kwargs["scanning_mode"]) - return MockBleakScanner() + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() - with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() - assert await scanner.async_request_active_window(100.0) is True - first_handle = scanner._active_window_handle - first_end = scanner._active_window_end - # A longer request extends the existing window without a second restart. - assert await scanner.async_request_active_window(200.0) is True - assert scanner._active_window_handle is not first_handle - assert scanner._active_window_end > first_end - # Only one BleakScanner construction happened (the initial - # passive one). The active-window flip toggles the existing - # instance's _backend._scanning_mode instead of creating a - # new scanner. - assert starts == ["passive"] - assert scanner.current_mode is BluetoothScanningMode.ACTIVE - # A shorter follow-up is a no-op on the timer. - kept_end = scanner._active_window_end - assert await scanner.async_request_active_window(0.001) is True - assert scanner._active_window_end == kept_end - - await scanner.async_stop() + assert await scanner.async_request_active_window(100.0) is True + first_handle = scanner._active_window_handle + first_end = scanner._active_window_end + # A longer request extends the existing window without a second restart. + assert await scanner.async_request_active_window(200.0) is True + assert scanner._active_window_handle is not first_handle + assert scanner._active_window_end > first_end + # Only one BleakScanner construction happened (the initial + # passive one). The active-window flip toggles the existing + # instance's _backend._scanning_mode instead of creating a + # new scanner. + assert starts == ["passive"] + assert scanner.current_mode is BluetoothScanningMode.ACTIVE + # A shorter follow-up is a no-op on the timer. + kept_end = scanner._active_window_end + assert await scanner.async_request_active_window(0.001) is True + assert scanner._active_window_end == kept_end + + await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_end_time_matches_real_timer() -> None: """ @@ -2016,76 +2028,76 @@ async def test_async_request_active_window_end_time_matches_real_timer() -> None deterministically rather than relying on asyncio.sleep precision, which can fire slightly early on busy CI runners. """ - with patch("habluetooth.scanner.IS_MACOS", False): - duration = 10.0 - restart_started = asyncio.Event() - gate = asyncio.Event() + duration = 10.0 + restart_started = asyncio.Event() + gate = asyncio.Event() + + class GatedMockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + _first_start_done = False - class GatedMockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") - _first_start_done = False + async def start(self): + if not type(self)._first_start_done: + type(self)._first_start_done = True + return + restart_started.set() + await gate.wait() - async def start(self): - if not type(self)._first_start_done: - type(self)._first_start_done = True - return - restart_started.set() - await gate.wait() + async def stop(self): + pass - async def stop(self): - pass + @property + def discovered_devices(self): + return [] - @property - def discovered_devices(self): - return [] + def register_detection_callback(self, callback): + pass - def register_detection_callback(self, callback): - pass + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *a, **k: GatedMockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *a, **k: GatedMockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() + loop = asyncio.get_running_loop() + before = loop.time() + task = asyncio.create_task(scanner.async_request_active_window(duration)) + await restart_started.wait() + # Provably advance loop.time() past `before` before the restart + # completes; the exact amount doesn't matter for the assertion + # below as long as loop.time() has visibly moved. + await asyncio.sleep(0.05) + elapsed = loop.time() - before + gate.set() + assert await task is True + + # Contract: _active_window_end matches loop.time() + duration + # measured AFTER the restart, not before. Pre-fix it would be + # before + duration. Allow generous tolerance for the small + # gap between arming and reading. + now = loop.time() + assert scanner._active_window_end == pytest.approx(now + duration, abs=0.1) + # Reject pre-fix value (before + duration) explicitly with a + # margin well above asyncio scheduling jitter: the stored end + # is at least ``elapsed`` ahead of before + duration. + assert scanner._active_window_end - before - duration >= elapsed / 2 + first_handle = scanner._active_window_handle + + # A follow-up whose new_end lands between the pre-fix stored + # end and the real fire time must NOT be treated as an + # extension. With the fix this is rejected; without it the + # live timer would be cancelled and armed shorter. + target_new_end = before + duration + elapsed / 2 + shorter_duration = target_new_end - loop.time() + assert await scanner.async_request_active_window(shorter_duration) is True + assert scanner._active_window_handle is first_handle - loop = asyncio.get_running_loop() - before = loop.time() - task = asyncio.create_task(scanner.async_request_active_window(duration)) - await restart_started.wait() - # Provably advance loop.time() past `before` before the restart - # completes; the exact amount doesn't matter for the assertion - # below as long as loop.time() has visibly moved. - await asyncio.sleep(0.05) - elapsed = loop.time() - before - gate.set() - assert await task is True - - # Contract: _active_window_end matches loop.time() + duration - # measured AFTER the restart, not before. Pre-fix it would be - # before + duration. Allow generous tolerance for the small - # gap between arming and reading. - now = loop.time() - assert scanner._active_window_end == pytest.approx(now + duration, abs=0.1) - # Reject pre-fix value (before + duration) explicitly with a - # margin well above asyncio scheduling jitter: the stored end - # is at least ``elapsed`` ahead of before + duration. - assert scanner._active_window_end - before - duration >= elapsed / 2 - first_handle = scanner._active_window_handle - - # A follow-up whose new_end lands between the pre-fix stored - # end and the real fire time must NOT be treated as an - # extension. With the fix this is rejected; without it the - # live timer would be cancelled and armed shorter. - target_new_end = before + duration + elapsed / 2 - shorter_duration = target_new_end - loop.time() - assert await scanner.async_request_active_window(shorter_duration) is True - assert scanner._active_window_handle is first_handle - - await scanner.async_stop() + await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_skips_restart_if_still_active() -> None: """ @@ -2095,143 +2107,204 @@ async def test_async_request_active_window_skips_restart_if_still_active() -> No before the bg task runs reuses the in-flight ACTIVE mode and just arms a new timer. """ - with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass - async def start(self): - pass + starts: list[str] = [] - async def stop(self): - pass + def _factory(*_args, **kwargs): + starts.append(kwargs["scanning_mode"]) + return MockBleakScanner() + + with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Single construction (passive); toggle reuses the instance. + assert starts == ["passive"] + backend = scanner.scanner._backend # type: ignore[union-attr] + backend._scanning_mode = "passive" + + assert await scanner.async_request_active_window(100.0) is True + # Toggle flipped the existing instance to active. + assert backend._scanning_mode == "active" + # Simulate the timer firing but the end-window task not having + # run yet: clear the handle (like _schedule_end_active_window + # does) but leave _scan_mode_override / current_mode == ACTIVE. + handle = scanner._active_window_handle + assert handle is not None + handle.cancel() + scanner._active_window_handle = None - @property - def discovered_devices(self): - return [] + # Scanner is still ACTIVE; a longer follow-up re-arms the + # timer without flipping the radio again. A shorter follow-up + # would no-op the timer (covered by + # test_async_request_active_window_still_active_does_not_shrink). + assert await scanner.async_request_active_window(200.0) is True + assert scanner._active_window_handle is not None + # Mode unchanged: no toggle happened on the still-ACTIVE path. + assert backend._scanning_mode == "active" # type: ignore[unreachable] + # Still only one BleakScanner construction. + assert starts == ["passive"] - def register_detection_callback(self, callback): - pass + await scanner.async_stop() - starts: list[str] = [] - def _factory(*_args, **kwargs): - starts.append(kwargs["scanning_mode"]) - return MockBleakScanner() +@pytest.mark.usefixtures("force_linux_scanner_mode") +@pytest.mark.asyncio +async def test_async_request_active_window_still_active_does_not_shrink() -> None: + """ + Concurrent shorter caller into the still-ACTIVE locked branch is a no-op. + + Regression: the locked early-return at the top of + ``async_request_active_window``'s lock block re-armed the timer + unconditionally when ``current_mode is ACTIVE``. A second caller + with a shorter duration could shrink an in-flight window someone + else asked for. Guarded with the same + ``loop.time() + duration > _active_window_end`` check the + lockless fast-path uses. + """ - with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - # Single construction (passive); toggle reuses the instance. - assert starts == ["passive"] - backend = scanner.scanner._backend # type: ignore[union-attr] - backend._scanning_mode = "passive" - - assert await scanner.async_request_active_window(100.0) is True - # Toggle flipped the existing instance to active. - assert backend._scanning_mode == "active" - # Simulate the timer firing but the end-window task not having - # run yet: clear the handle (like _schedule_end_active_window - # does) but leave _scan_mode_override / current_mode == ACTIVE. - handle = scanner._active_window_handle - assert handle is not None - handle.cancel() - scanner._active_window_handle = None - - # Scanner is still ACTIVE; the new request must just re-arm - # the timer, not flip the radio again. - assert await scanner.async_request_active_window(50.0) is True - assert scanner._active_window_handle is not None - # Mode unchanged: no toggle happened on the still-ACTIVE path. - assert backend._scanning_mode == "active" # type: ignore[unreachable] - # Still only one BleakScanner construction. - assert starts == ["passive"] - - await scanner.async_stop() + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Open a long window so _active_window_handle is set and + # current_mode is ACTIVE. + assert await scanner.async_request_active_window(100.0) is True + long_end = scanner._active_window_end + long_handle = scanner._active_window_handle + # Simulate the timer firing without _async_end_active_window + # running yet: clear the handle so the locked branch is + # reachable (lockless fast path needs handle is not None). + assert long_handle is not None + long_handle.cancel() + scanner._active_window_handle = None + # Concurrent shorter caller now hits the locked + # current_mode-is-ACTIVE branch. Pre-fix this would re-arm + # at end = now + 5 (shrinking the live window); post-fix the + # stored end-time stays put and the timer isn't re-armed. + assert await scanner.async_request_active_window(5.0) is True + assert scanner._active_window_end == long_end + + await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_stop_clears_active_window_state() -> None: """Stopping mid-window cancels the timer and clears the override.""" - with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - pass + async def start(self): + pass - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - await scanner.async_request_active_window(100.0) - assert scanner._active_window_handle is not None - await scanner.async_stop() - assert scanner._active_window_handle is None - assert scanner._scan_mode_override is None # type: ignore[unreachable] - assert scanner._active_window_end == 0.0 + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + await scanner.async_request_active_window(100.0) + assert scanner._active_window_handle is not None + await scanner.async_stop() + assert scanner._active_window_handle is None + assert scanner._scan_mode_override is None # type: ignore[unreachable] + assert scanner._active_window_end == 0.0 +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_recovers_on_start_failure() -> None: """If the ACTIVE restart raises, recovery brings the scanner back up.""" - with patch("habluetooth.scanner.IS_MACOS", False): - call_count = 0 - fail_until = 0 + call_count = 0 + fail_until = 0 - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - nonlocal call_count - call_count += 1 - if call_count <= fail_until: - raise BleakError("simulated start failure") + async def start(self): + nonlocal call_count + call_count += 1 + if call_count <= fail_until: + raise BleakError("simulated start failure") - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - before = call_count - # Fail the next 4 start attempts so the ACTIVE swap raises; - # then succeed so the recovery restart can come back up. - fail_until = call_count + 4 - result = await scanner.async_request_active_window(1.0) - assert result is False - assert scanner._scan_mode_override is None - # Recovery restart happened after the failure path. - assert call_count > before + 4 - await scanner.async_stop() + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + before = call_count + # Fail the next 4 start attempts so the ACTIVE swap raises; + # then succeed so the recovery restart can come back up. + fail_until = call_count + 4 + result = await scanner.async_request_active_window(1.0) + assert result is False + assert scanner._scan_mode_override is None + # Recovery restart happened after the failure path. + assert call_count > before + 4 + await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_clears_override_on_unexpected_error() -> ( None @@ -2245,45 +2318,44 @@ async def test_async_request_active_window_clears_override_on_unexpected_error() _async_start_attempt would then see effective_mode = ACTIVE instead of AUTO, poisoning subsequent starts. """ - with patch("habluetooth.scanner.IS_MACOS", False): - start_count = 0 + start_count = 0 - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - nonlocal start_count - start_count += 1 - # First start (initial async_start) succeeds; second start - # (the ACTIVE restart from async_request_active_window) - # raises a non-ScannerStartError so we exercise the - # broad-except cleanup path. - if start_count > 1: - raise RuntimeError("simulated unexpected error") + async def start(self): + nonlocal start_count + start_count += 1 + # First start (initial async_start) succeeds; second start + # (the ACTIVE restart from async_request_active_window) + # raises a non-ScannerStartError so we exercise the + # broad-except cleanup path. + if start_count > 1: + raise RuntimeError("simulated unexpected error") - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - with pytest.raises(RuntimeError, match="simulated unexpected error"): - await scanner.async_request_active_window(1.0) - # The override must be cleared even though the exception - # wasn't a ScannerStartError. - assert scanner._scan_mode_override is None - await scanner.async_stop() + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + with pytest.raises(RuntimeError, match="simulated unexpected error"): + await scanner.async_request_active_window(1.0) + # The override must be cleared even though the exception + # wasn't a ScannerStartError. + assert scanner._scan_mode_override is None + await scanner.async_stop() @pytest.mark.asyncio @@ -2328,43 +2400,43 @@ def discovered_addresses(self) -> Iterable[str]: ) +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_end_active_window_defers_to_new_window() -> None: """If a new window armed the timer, the end-window task returns early.""" - with patch("habluetooth.scanner.IS_MACOS", False): - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - pass + async def start(self): + pass - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - await scanner.async_request_active_window(3600.0) - # Simulate a new window taking over by leaving the handle in place - # and call _async_end_active_window directly; it must short-circuit. - assert scanner._active_window_handle is not None - await scanner._async_end_active_window() - # Override and handle untouched because we deferred to the new window. - assert scanner._scan_mode_override == BluetoothScanningMode.ACTIVE - assert scanner._active_window_handle is not None - await scanner.async_stop() + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + await scanner.async_request_active_window(3600.0) + # Simulate a new window taking over by leaving the handle in place + # and call _async_end_active_window directly; it must short-circuit. + assert scanner._active_window_handle is not None + await scanner._async_end_active_window() + # Override and handle untouched because we deferred to the new window. + assert scanner._scan_mode_override == BluetoothScanningMode.ACTIVE + assert scanner._active_window_handle is not None + await scanner.async_stop() @pytest.mark.asyncio @@ -2406,106 +2478,106 @@ def register_detection_callback(self, callback): await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_passive_fallback_on_linux() -> None: """If the swap restart falls back to PASSIVE on Linux, request returns False.""" - with patch("habluetooth.scanner.IS_MACOS", False): - starts = 0 + starts = 0 - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - nonlocal starts - starts += 1 - # Fail the first three attempts so the 4th-attempt PASSIVE - # fallback inside _async_start_attempt kicks in. - if 2 <= starts <= 4: - raise BleakError("simulated active failure") + async def start(self): + nonlocal starts + starts += 1 + # Fail the first three attempts so the 4th-attempt PASSIVE + # fallback inside _async_start_attempt kicks in. + if 2 <= starts <= 4: + raise BleakError("simulated active failure") - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with ( - patch("habluetooth.scanner.IS_LINUX", True), - patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ), - patch("habluetooth.scanner.async_reset_adapter", AsyncMock()), - patch("habluetooth.scanner.ADAPTER_INIT_TIME", 0), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - result = await scanner.async_request_active_window(1.0) - # The swap ran through to the 4th attempt and fell back to PASSIVE; - # the request reports False because the scanner is not ACTIVE. - assert result is False - assert scanner._scan_mode_override is None - await scanner.async_stop() + with ( + patch("habluetooth.scanner.IS_LINUX", True), + patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ), + patch("habluetooth.scanner.async_reset_adapter", AsyncMock()), + patch("habluetooth.scanner.ADAPTER_INIT_TIME", 0), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + result = await scanner.async_request_active_window(1.0) + # The swap ran through to the 4th attempt and fell back to PASSIVE; + # the request reports False because the scanner is not ACTIVE. + assert result is False + assert scanner._scan_mode_override is None + await scanner.async_stop() +@pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_end_active_window_handles_start_error( caplog: pytest.LogCaptureFixture, ) -> None: """ScannerStartError during the end-of-window restart logs a warning.""" - with patch("habluetooth.scanner.IS_MACOS", False): - starts = 0 - fail_until = 0 + starts = 0 + fail_until = 0 - class MockBleakScanner: - _backend = types.SimpleNamespace(_scanning_mode="passive") + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") - async def start(self): - nonlocal starts - starts += 1 - if starts <= fail_until: - raise BleakError("simulated end-window failure") + async def start(self): + nonlocal starts + starts += 1 + if starts <= fail_until: + raise BleakError("simulated end-window failure") - async def stop(self): - pass + async def stop(self): + pass - @property - def discovered_devices(self): - return [] + @property + def discovered_devices(self): + return [] - def register_detection_callback(self, callback): - pass + def register_detection_callback(self, callback): + pass - with patch( - "habluetooth.scanner.OriginalBleakScanner", - side_effect=lambda *_, **__: MockBleakScanner(), - ): - scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") - scanner.async_setup() - await scanner.async_start() - # Open a long active window then drive end-of-window with the - # bleak start mocked to fail. - await scanner.async_request_active_window(3600.0) - assert scanner._active_window_handle is not None - # Fail enough start() calls that BOTH the toggle attempt - # and every retry in the fallback _async_start cycle - # raise, so we exercise the - # "Failed to restart scanner after active window" warning. - fail_until = starts + 100 - scanner._active_window_handle.cancel() - scanner._active_window_handle = None - caplog.clear() - with caplog.at_level(logging.WARNING): - await scanner._async_end_active_window() - assert any( - "Failed to restart scanner after active window" in record.message - for record in caplog.records - ) + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: MockBleakScanner(), + ): + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + await scanner.async_start() + # Open a long active window then drive end-of-window with the + # bleak start mocked to fail. + await scanner.async_request_active_window(3600.0) + assert scanner._active_window_handle is not None + # Fail enough start() calls that BOTH the toggle attempt + # and every retry in the fallback _async_start cycle + # raise, so we exercise the + # "Failed to restart scanner after active window" warning. + fail_until = starts + 100 + scanner._active_window_handle.cancel() + scanner._active_window_handle = None + caplog.clear() + with caplog.at_level(logging.WARNING): + await scanner._async_end_active_window() + assert any( + "Failed to restart scanner after active window" in record.message + for record in caplog.records + ) @pytest.mark.parametrize("exc", [FileNotFoundError("no dbus"), BleakError("nope")]) From ba3787ea1cf0155422a7ae7a9b6d3c2a07425f8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:04:05 -0500 Subject: [PATCH 70/75] fix(scanner): gate active-window toggle on IS_LINUX, split into helpers 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. --- src/habluetooth/auto_scheduler.pxd | 1 + src/habluetooth/auto_scheduler.py | 3 +- src/habluetooth/scanner.py | 97 +++++++++++++++++++++--------- tests/test_scanner.py | 55 ++++++++++------- 4 files changed, 106 insertions(+), 50 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 2dfdeb24..901c81ea 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -91,6 +91,7 @@ cdef class AutoScanScheduler: existing=dict, requests=set, request=ActiveScanRequest, + now=double, ) cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index f287fe5c..32975b7b 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -558,11 +558,12 @@ def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: if requests is None: return existing = self._needs.get(address) + now = self._loop.time() for request in requests: if existing is None: existing = self._needs[address] = {} if request not in existing: - existing[request] = self._loop.time() + request.scan_interval + existing[request] = now + request.scan_interval self._wake_worker(service_info.source) def _wake_worker(self, source: str) -> None: diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index fcc68d30..71cf927e 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -747,27 +747,67 @@ async def async_request_active_window(self, duration: float) -> bool: if self._loop.time() + duration > self._active_window_end: self._arm_active_window_timer(duration) return True - try: - flipped = await self._async_toggle_active_window_mode() - except BaseException: - # CancelledError, SystemExit, KeyboardInterrupt, an - # unexpected BleakError leaking out, etc. — any of - # those must not leave _scan_mode_override stuck at - # ACTIVE for the next start. Clear and re-raise so - # propagation is preserved. - self._scan_mode_override = None - raise - if not flipped: - # Toggle failed; try to bring the scanner back up in - # its underlying AUTO/passive mode via the full - # teardown path so we don't leave it stopped. - self._scan_mode_override = None - with contextlib.suppress(ScannerStartError): - await self._async_stop_then_start_under_lock() + if IS_LINUX: + entered = await self._async_enter_via_toggle() + else: + entered = await self._async_enter_via_restart() + if not entered: return False self._arm_active_window_timer(duration) return True + async def _async_enter_via_toggle(self) -> bool: + """ + Cheap Linux/BlueZ entry: in-place ``_scanning_mode`` flip. + + Caller holds ``_start_stop_lock`` and has already set + ``_scan_mode_override``. On failure clears the override and + recovers the scanner via a full restart so we don't leave it + stopped. + """ + try: + flipped = await self._async_toggle_active_window_mode() + except BaseException: + # CancelledError, SystemExit, KeyboardInterrupt, an + # unexpected BleakError leaking out, etc. — any of those + # must not leave _scan_mode_override stuck at ACTIVE for + # the next start. Clear and re-raise so propagation is + # preserved. + self._scan_mode_override = None + raise + if not flipped: + self._scan_mode_override = None + with contextlib.suppress(ScannerStartError): + await self._async_stop_then_start_under_lock() + return False + return True + + async def _async_enter_via_restart(self) -> bool: + """ + Non-Linux entry: full stop + recreate + start in ACTIVE mode. + + Caller holds ``_start_stop_lock`` and has already set + ``_scan_mode_override`` so the fresh BleakScanner picks up + ACTIVE at construction. On a Linux 4th-attempt PASSIVE + fallback or ScannerStartError, clears the override and + returns False; the override-clear ensures the recovery + restart comes back up in AUTO/passive. + """ + try: + await self._async_stop_then_start_under_lock() + except ScannerStartError: + self._scan_mode_override = None + with contextlib.suppress(ScannerStartError): + await self._async_stop_then_start_under_lock() + return False + except BaseException: + self._scan_mode_override = None + raise + if self.current_mode is not BluetoothScanningMode.ACTIVE: + self._scan_mode_override = None + return False + return True + def _schedule_end_active_window(self) -> None: """Spawn the end-of-window restart task.""" self._active_window_handle = None @@ -782,10 +822,10 @@ async def _async_end_active_window(self) -> None: self._scan_mode_override = None if not self.scanning: return - if await self._async_toggle_active_window_mode(): + if IS_LINUX and await self._async_toggle_active_window_mode(): return - # Toggle failed; fall back to a full restart so we don't - # leave the scanner stuck in ACTIVE. + # Non-Linux backend, or toggle failed; full restart so we + # don't leave the scanner stuck in ACTIVE. try: await self._async_stop_then_start_under_lock() except ScannerStartError as ex: @@ -826,16 +866,19 @@ async def _async_toggle_active_window_mode(self) -> bool: same-instance stop+start so ``BleakClient(address)`` lookups keep working across the flip. - Linux/BlueZ only. On macOS AUTO maps to permanent active - scanning so ``async_request_active_window`` early-returns - without ever reaching this method. The watchdog / - ``async_stop`` / ``async_start`` paths still go through the - full-teardown ``_async_stop_then_start_under_lock`` so this + Linux/BlueZ only — callers must check ``IS_LINUX`` before + invoking. On non-BlueZ backends ``_backend._scanning_mode`` + may not exist, and on macOS AUTO already maps to permanent + active scanning so ``async_request_active_window`` + early-returns. The watchdog / ``async_stop`` / + ``async_start`` paths still go through the full-teardown + ``_async_stop_then_start_under_lock`` so this private-attribute access is bounded to the active-window flip path. - Returns ``False`` if the scanner has been torn down (caller - should fall back to the full path); otherwise returns + Returns ``False`` if the scanner has been torn down or the + stop/start raised (caller should fall back to the full + path); otherwise returns ``True`` after a successful flip. """ if self.scanner is None: diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 3cb87456..87fe2a29 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -95,12 +95,16 @@ def disable_stop_discovery(): @pytest.fixture def force_linux_scanner_mode() -> Generator[None, None, None]: """ - Force scanner.IS_MACOS=False for the Linux/BlueZ AUTO flow. + Force scanner.IS_LINUX=True / IS_MACOS=False for AUTO-flow tests. - Lets tests exercise the active-window toggle path regardless of - the host running them; macOS would short-circuit AUTO to ACTIVE. + Lets the active-window toggle path run on any host: the toggle + is gated on IS_LINUX (BlueZ-only private attribute), and AUTO + on macOS short-circuits to permanent active. """ - with patch("habluetooth.scanner.IS_MACOS", False): + with ( + patch("habluetooth.scanner.IS_LINUX", True), + patch("habluetooth.scanner.IS_MACOS", False), + ): yield @@ -2560,24 +2564,31 @@ def register_detection_callback(self, callback): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() - # Open a long active window then drive end-of-window with the - # bleak start mocked to fail. - await scanner.async_request_active_window(3600.0) - assert scanner._active_window_handle is not None - # Fail enough start() calls that BOTH the toggle attempt - # and every retry in the fallback _async_start cycle - # raise, so we exercise the - # "Failed to restart scanner after active window" warning. - fail_until = starts + 100 - scanner._active_window_handle.cancel() - scanner._active_window_handle = None - caplog.clear() - with caplog.at_level(logging.WARNING): - await scanner._async_end_active_window() - assert any( - "Failed to restart scanner after active window" in record.message - for record in caplog.records - ) + try: + # Open a long active window then drive end-of-window + # with the bleak start mocked to fail. + await scanner.async_request_active_window(3600.0) + assert scanner._active_window_handle is not None + # Fail enough start() calls that BOTH the toggle attempt + # and every retry in the fallback _async_start cycle + # raise, so we exercise the "Failed to restart scanner + # after active window" warning. + fail_until = starts + 100 + scanner._active_window_handle.cancel() + scanner._active_window_handle = None + caplog.clear() + with caplog.at_level(logging.WARNING): + await scanner._async_end_active_window() + assert any( + "Failed to restart scanner after active window" in record.message + for record in caplog.records + ) + finally: + # Allow the fallback restart to succeed for teardown, + # then stop the scanner so we don't leak the watchdog + # timer / background tasks into later tests. + fail_until = 0 + await scanner.async_stop() @pytest.mark.parametrize("exc", [FileNotFoundError("no dbus"), BleakError("nope")]) From e74f49d01462bacef1211e3f69ad66acd643fe07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:04:47 -0500 Subject: [PATCH 71/75] refactor(scanner): rename active-window entry helpers _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. --- src/habluetooth/scanner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 71cf927e..52e7240c 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -748,15 +748,15 @@ async def async_request_active_window(self, duration: float) -> bool: self._arm_active_window_timer(duration) return True if IS_LINUX: - entered = await self._async_enter_via_toggle() + entered = await self._async_begin_active_window_via_toggle() else: - entered = await self._async_enter_via_restart() + entered = await self._async_begin_active_window_via_restart() if not entered: return False self._arm_active_window_timer(duration) return True - async def _async_enter_via_toggle(self) -> bool: + async def _async_begin_active_window_via_toggle(self) -> bool: """ Cheap Linux/BlueZ entry: in-place ``_scanning_mode`` flip. @@ -782,7 +782,7 @@ async def _async_enter_via_toggle(self) -> bool: return False return True - async def _async_enter_via_restart(self) -> bool: + async def _async_begin_active_window_via_restart(self) -> bool: """ Non-Linux entry: full stop + recreate + start in ACTIVE mode. From c3e93c5491e3434e147659d50d2e936f6ecf1d16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:11:50 -0500 Subject: [PATCH 72/75] docs: trim verbose docstrings again 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. --- src/habluetooth/auto_scheduler.py | 38 ++++------ src/habluetooth/scanner.py | 116 +++++++++++------------------- 2 files changed, 56 insertions(+), 98 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 32975b7b..20aae141 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -431,18 +431,14 @@ def stop(self) -> None: Cancel all worker tasks (fire-and-forget). Sync to match ``BluetoothManager.async_stop``; - ``worker.stop()`` calls ``task.cancel()`` without awaiting. - Cancellation lands on the next loop iteration; a mid-``_tick`` - scanner call may complete first. Harmless for HA shutdown. - Also nulls ``_loop`` so post-stop ``add_request`` / - ``on_advertisement`` fall back to the record-only path - instead of seeding ``_needs`` with timestamps from the - cancelled loop. Callers doing an in-place restart - (``stop()`` then ``start(new_loop)``) must + ``worker.stop()`` cancels without awaiting. Nulls ``_loop`` + too so post-stop ``add_request`` / ``on_advertisement`` fall + back to the record-only path instead of seeding ``_needs`` + with timestamps from the cancelled loop. In-place restart + (``stop()`` then ``start(new_loop)``) needs an ``await asyncio.sleep(0)`` between them so cancelled tasks - finish their finally blocks before new workers spawn on the - same sources; ``start()`` does not guard against this since - HA's setup/teardown flow never does an in-place restart. + finish before new workers spawn on the same sources; HA's + flow never does this. """ self._running = False for worker in self._workers.values(): @@ -501,19 +497,13 @@ def add_request(self, request: ActiveScanRequest) -> None: """ Register an active-scan request and start tracking. - First window fires ``scan_interval`` after registration on - the current owner if history exists; otherwise - ``on_advertisement`` bootstraps tracking on first sight (the - first window then fires ``scan_interval`` after that ad). - - ``ActiveScanRequest`` compares by identity: each public - ``async_register_active_scan`` call adds an independent - cadence (two 60s registrations on the same address yield two - independent 60s cadences). Re-adding the same object is a - no-op; cancellation is per-registration. - - Pre-``start()`` calls record the request only; no seed, no - wake (``start()`` replays them). + First window fires ``scan_interval`` after registration if + history exists; otherwise ``on_advertisement`` bootstraps on + first sight. ``ActiveScanRequest`` compares by identity so + each public ``async_register_active_scan`` call adds an + independent cadence; cancellation is per-registration. + Pre-``start()`` calls just record the request (``start()`` + replays them). """ self._requests_by_address.setdefault(request.address, set()).add(request) if self._loop is None: diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 52e7240c..061337cc 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -389,12 +389,10 @@ async def _async_on_successful_start(self) -> None: def _effective_mode(self) -> BluetoothScanningMode | None: """ - Return the mode the scanner should actually start in. + Mode the scanner should actually start in. - ``_scan_mode_override`` takes precedence so the scheduler can - transiently flip an AUTO scanner to ACTIVE for an on-demand - window without losing the integration-declared - ``requested_mode``. + Override beats requested_mode so the scheduler can flip AUTO + to ACTIVE for an on-demand window without losing intent. """ return self._scan_mode_override or self.requested_mode @@ -694,14 +692,11 @@ def _arm_active_window_timer(self, duration: float) -> None: """ Schedule the end-of-window callback. - Stores ``_active_window_end`` as ``loop.time() + duration`` - at arming time so it matches the real ``call_later`` fire - time; an earlier ``new_end`` snapshot drifted across the - stop/restart cycle and let a *shorter* follow-up masquerade - as an extension. Cancels any existing handle first so two - callers can't leak a pending timer. ``_loop`` is set in - ``async_setup``; the TYPE_CHECKING assert is a mypy - narrowing hint with no runtime effect. + Stores ``_active_window_end`` from ``loop.time()`` at arming + time so it matches the real ``call_later`` fire time (a + pre-restart snapshot would let a shorter follow-up masquerade + as an extension). Cancels any existing handle first to avoid + leaking a pending timer. """ if TYPE_CHECKING: assert self._loop is not None @@ -716,12 +711,11 @@ async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds then restore prior mode. - No-op on non-AUTO scanners. On macOS, AUTO already maps to - active (CoreBluetooth has no passive mode), so this is a - no-op success there too. While a window is already open, a - longer follow-up extends the timer in place; a shorter (or - equal) follow-up is a no-op on the timer but still returns - True. Either way no second stop/restart cycle fires. + No-op on non-AUTO scanners. On macOS AUTO is permanent active + (no passive mode in CoreBluetooth), so a no-op success there. + Concurrent / repeat callers while a window is open: a longer + follow-up extends the timer; a shorter follow-up is a no-op + on the timer but still returns True. No second restart fires. """ if self.requested_mode is not BluetoothScanningMode.AUTO: return False @@ -758,21 +752,18 @@ async def async_request_active_window(self, duration: float) -> bool: async def _async_begin_active_window_via_toggle(self) -> bool: """ - Cheap Linux/BlueZ entry: in-place ``_scanning_mode`` flip. + Cheap Linux/BlueZ entry via in-place ``_scanning_mode`` flip. - Caller holds ``_start_stop_lock`` and has already set - ``_scan_mode_override``. On failure clears the override and - recovers the scanner via a full restart so we don't leave it - stopped. + Caller holds ``_start_stop_lock`` and has already set the + override. On failure clears the override and recovers via a + full restart so the scanner isn't left stopped. """ try: flipped = await self._async_toggle_active_window_mode() except BaseException: - # CancelledError, SystemExit, KeyboardInterrupt, an - # unexpected BleakError leaking out, etc. — any of those - # must not leave _scan_mode_override stuck at ACTIVE for - # the next start. Clear and re-raise so propagation is - # preserved. + # Any error (CancelledError, SystemExit, leaked BleakError, + # etc.) must not leave the override stuck at ACTIVE for + # the next start. Clear and re-raise. self._scan_mode_override = None raise if not flipped: @@ -784,14 +775,13 @@ async def _async_begin_active_window_via_toggle(self) -> bool: async def _async_begin_active_window_via_restart(self) -> bool: """ - Non-Linux entry: full stop + recreate + start in ACTIVE mode. - - Caller holds ``_start_stop_lock`` and has already set - ``_scan_mode_override`` so the fresh BleakScanner picks up - ACTIVE at construction. On a Linux 4th-attempt PASSIVE - fallback or ScannerStartError, clears the override and - returns False; the override-clear ensures the recovery - restart comes back up in AUTO/passive. + Non-Linux entry via full stop+recreate+start in ACTIVE mode. + + Caller holds ``_start_stop_lock`` and has set the override so + the fresh BleakScanner is constructed in ACTIVE. On + ScannerStartError or the Linux 4th-attempt PASSIVE fallback + the override is cleared (so the recovery restart comes back + up in AUTO/passive) and False is returned. """ try: await self._async_stop_then_start_under_lock() @@ -839,10 +829,9 @@ async def _async_stop_then_start_under_lock(self) -> None: """ Stop and restart the BleakScanner; caller holds _start_stop_lock. - Full teardown path: nulls ``self.scanner`` and constructs a - fresh one in ``_async_start``. AUTO active-window flips use - ``_async_toggle_active_window_mode`` instead so they reuse - the existing BleakScanner instance and skip the new dbus + Full teardown: nulls ``self.scanner`` and constructs a fresh + one. AUTO active-window flips on Linux use + ``_async_toggle_active_window_mode`` instead to skip the dbus setup + ``restore_discoveries`` cost. """ await self._async_stop_scanner() @@ -852,34 +841,16 @@ async def _async_toggle_active_window_mode(self) -> bool: """ Toggle the existing BleakScanner between active and passive. - Reuses the live ``self.scanner`` instance: stops discovery, - mutates the backend's private ``_scanning_mode`` to the value - derived from ``_scan_mode_override or requested_mode``, then - restarts. Bleak's BlueZ backend stores ``_scanning_mode`` as - a mutable instance attribute and reads it on every ``start``, - so the flip is observable without recreating the scanner. - - Saves two costs per active-window cycle vs a full - stop_then_start: a fresh dbus client construction and the - ``restore_discoveries`` repopulation that runs on every new - instance. Bleak's internal device cache survives the - same-instance stop+start so ``BleakClient(address)`` lookups - keep working across the flip. - - Linux/BlueZ only — callers must check ``IS_LINUX`` before - invoking. On non-BlueZ backends ``_backend._scanning_mode`` - may not exist, and on macOS AUTO already maps to permanent - active scanning so ``async_request_active_window`` - early-returns. The watchdog / ``async_stop`` / - ``async_start`` paths still go through the full-teardown - ``_async_stop_then_start_under_lock`` so this - private-attribute access is bounded to the active-window - flip path. - - Returns ``False`` if the scanner has been torn down or the - stop/start raised (caller should fall back to the full - path); otherwise returns - ``True`` after a successful flip. + Stops the live ``self.scanner``, mutates its private + ``_backend._scanning_mode`` to the value from + ``_effective_mode()``, restarts the same instance. Skips the + new dbus client + ``restore_discoveries`` cost of a fresh + construction; bleak's device cache survives same-instance + stop+start so ``BleakClient(address)`` keeps working. + + Linux/BlueZ only — callers must check ``IS_LINUX``. Returns + False if the scanner is gone or stop/start raised (caller + falls back to the full path). """ if self.scanner is None: return False @@ -897,11 +868,8 @@ async def _async_toggle_active_window_mode(self) -> bool: ex, ) return False - # Private bleak attribute, but the only public way to change - # mode is to recreate the scanner. The BlueZ backend reads - # this on every start; CoreBluetooth (not supported for - # AUTO) reads it at construction so this branch never runs - # on macOS. + # Private bleak attribute — no public API for mode change. + # BlueZ reads it on every start; macOS isn't reachable here. self.scanner._backend._scanning_mode = mode_str try: async with asyncio.timeout(START_TIMEOUT): From b44664b00d76bb8deef49a9d29520449ef21eb50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:17:06 -0500 Subject: [PATCH 73/75] refactor: DRY up active-window helpers 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. --- src/habluetooth/auto_scheduler.pxd | 11 ++++-- src/habluetooth/auto_scheduler.py | 26 +++++++++----- src/habluetooth/scanner.py | 57 ++++++++++++++++++------------ 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/habluetooth/auto_scheduler.pxd b/src/habluetooth/auto_scheduler.pxd index 901c81ea..60a1e127 100644 --- a/src/habluetooth/auto_scheduler.pxd +++ b/src/habluetooth/auto_scheduler.pxd @@ -88,13 +88,18 @@ cdef class AutoScanScheduler: @cython.locals( address=str, - existing=dict, requests=set, - request=ActiveScanRequest, - now=double, ) cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) + @cython.locals( + existing=dict, + request=ActiveScanRequest, + ) + cpdef void _seed_requests( + self, str address, set requests, double now + ) + cpdef void start(self, object loop) cpdef void stop(self) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 20aae141..3ae0b0e3 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -421,10 +421,7 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: for address, requests in self._requests_by_address.items(): if last_service_info(address, False) is None: continue - existing = self._needs.setdefault(address, {}) - for request in requests: - if request not in existing: - existing[request] = now + request.scan_interval + self._seed_requests(address, requests, now) def stop(self) -> None: """ @@ -547,14 +544,25 @@ def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: requests = self._requests_by_address.get(address) if requests is None: return - existing = self._needs.get(address) - now = self._loop.time() + self._seed_requests(address, requests, self._loop.time()) + self._wake_worker(service_info.source) + + def _seed_requests( + self, + address: str, + requests: set[ActiveScanRequest], + now: float, + ) -> None: + """ + Insert any not-yet-tracked requests with next-due = now + interval. + + Shared by ``on_advertisement`` and the ``start()`` replay + loop. Leaves existing entries' due times untouched. + """ + existing = self._needs.setdefault(address, {}) for request in requests: - if existing is None: - existing = self._needs[address] = {} if request not in existing: existing[request] = now + request.scan_interval - self._wake_worker(service_info.source) def _wake_worker(self, source: str) -> None: """Wake the worker for ``source`` if one is registered.""" diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 061337cc..1e635b25 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -688,6 +688,18 @@ def _clear_active_window_state(self) -> None: self._scan_mode_override = None self._active_window_end = 0.0 + def _arm_active_window_timer_if_extends(self, duration: float) -> None: + """ + Re-arm the timer only if the new duration extends the window. + + Shorter callers no-op so they can't shrink a window another + caller is depending on. + """ + if TYPE_CHECKING: + assert self._loop is not None + if self._loop.time() + duration > self._active_window_end: + self._arm_active_window_timer(duration) + def _arm_active_window_timer(self, duration: float) -> None: """ Schedule the end-of-window callback. @@ -724,22 +736,16 @@ async def async_request_active_window(self, duration: float) -> bool: if TYPE_CHECKING: assert self._loop is not None if self._active_window_handle is not None: - if self._loop.time() + duration > self._active_window_end: - # _arm_active_window_timer cancels the old handle - # internally so we don't need to do it twice here. - self._arm_active_window_timer(duration) + self._arm_active_window_timer_if_extends(duration) return True async with self._start_stop_lock: self._scan_mode_override = BluetoothScanningMode.ACTIVE # If the scanner is still ACTIVE here, the end-of-window task # for the previous timer is queued but hasn't run yet (it # would have cleared current_mode to PASSIVE). Skip the - # restart, re-arm only if our new duration extends past the - # current end; a shorter concurrent caller must not shrink - # an in-flight window someone else asked for. + # restart; same extend-only rule as the lockless fast path. if self.current_mode is BluetoothScanningMode.ACTIVE: - if self._loop.time() + duration > self._active_window_end: - self._arm_active_window_timer(duration) + self._arm_active_window_timer_if_extends(duration) return True if IS_LINUX: entered = await self._async_begin_active_window_via_toggle() @@ -754,9 +760,9 @@ async def _async_begin_active_window_via_toggle(self) -> bool: """ Cheap Linux/BlueZ entry via in-place ``_scanning_mode`` flip. - Caller holds ``_start_stop_lock`` and has already set the - override. On failure clears the override and recovers via a - full restart so the scanner isn't left stopped. + Caller holds ``_start_stop_lock`` and has set the override. + On failure clears the override and recovers via a full + restart so the scanner isn't left stopped. """ try: flipped = await self._async_toggle_active_window_mode() @@ -767,10 +773,7 @@ async def _async_begin_active_window_via_toggle(self) -> bool: self._scan_mode_override = None raise if not flipped: - self._scan_mode_override = None - with contextlib.suppress(ScannerStartError): - await self._async_stop_then_start_under_lock() - return False + return await self._async_abort_active_window() return True async def _async_begin_active_window_via_restart(self) -> bool: @@ -780,16 +783,12 @@ async def _async_begin_active_window_via_restart(self) -> bool: Caller holds ``_start_stop_lock`` and has set the override so the fresh BleakScanner is constructed in ACTIVE. On ScannerStartError or the Linux 4th-attempt PASSIVE fallback - the override is cleared (so the recovery restart comes back - up in AUTO/passive) and False is returned. + the override is cleared and False is returned. """ try: await self._async_stop_then_start_under_lock() except ScannerStartError: - self._scan_mode_override = None - with contextlib.suppress(ScannerStartError): - await self._async_stop_then_start_under_lock() - return False + return await self._async_abort_active_window() except BaseException: self._scan_mode_override = None raise @@ -798,6 +797,20 @@ async def _async_begin_active_window_via_restart(self) -> bool: return False return True + async def _async_abort_active_window(self) -> bool: + """ + Roll back a failed active-window entry. + + Clears the ACTIVE override and runs a best-effort + stop+restart so the scanner comes back up in its underlying + AUTO/passive mode rather than being left stopped. Returns + False so callers can ``return await self._async_abort_...``. + """ + self._scan_mode_override = None + with contextlib.suppress(ScannerStartError): + await self._async_stop_then_start_under_lock() + return False + def _schedule_end_active_window(self) -> None: """Spawn the end-of-window restart task.""" self._active_window_handle = None From cf55c0bef15324d7b86559461c2a4c80bf263096 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:28:00 -0500 Subject: [PATCH 74/75] fix(auto): cover sync-phase _tick errors, scanning flag on toggle fail, 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. --- src/habluetooth/auto_scheduler.py | 90 +++++++++++++----------- src/habluetooth/scanner.py | 7 ++ tests/test_auto_scheduler.py | 110 +++++++++++++++++++++++++++++- tests/test_scanner.py | 52 ++++++++++++++ 4 files changed, 218 insertions(+), 41 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index 3ae0b0e3..ba2b2d5d 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -320,7 +320,9 @@ async def _tick(self) -> None: so ``scan_interval`` is a true period between window starts rather than ``scan_interval + duration``. The scanner call's return value is ignored: we advance on failure too so a stuck - scanner can't busy-loop the worker. + scanner can't busy-loop the worker. An outer except keeps + the worker alive if the sync-phase (``_collect_due_buckets``, + ``_advance_due``) raises unexpectedly. """ loop = self._scheduler._loop if loop is None: @@ -332,44 +334,52 @@ async def _tick(self) -> None: if self._window_end > now: return self._window_end = 0.0 - due_buckets, all_due = self._collect_due_buckets(now) - sweep_due = now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL - if not all_due and not sweep_due: - return - duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0 - if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: - duration = _AUTO_REDISCOVERY_SWEEP_DURATION - self._window_end = now + duration - # Advance pre-await: a new owner that wakes mid-window must - # see the entries already advanced, otherwise an RSSI flip - # would let the new owner fire a duplicate window. - self._advance_due(due_buckets, now) - if sweep_due: - self._sweep_last_completed = now try: - await self._scanner.async_request_active_window(duration) - except Exception as ex: # pylint: disable=broad-except - # First failure per recovery-cycle gets a traceback; - # subsequent failures collapse to a one-liner so a - # persistently broken scanner can't spam the log. Flag - # clears on the next success so failure-after-recovery - # captures a stack again. - if self._failed_window: - _LOGGER.warning( - "%s: error running active window of %.1fs: %s", - self._scanner.name, - duration, - ex, - ) + due_buckets, all_due = self._collect_due_buckets(now) + sweep_due = now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL + if not all_due and not sweep_due: + return + duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0 + if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: + duration = _AUTO_REDISCOVERY_SWEEP_DURATION + self._window_end = now + duration + # Advance pre-await: a new owner that wakes mid-window + # must see the entries already advanced, otherwise an + # RSSI flip would let the new owner fire a duplicate + # window. + self._advance_due(due_buckets, now) + if sweep_due: + self._sweep_last_completed = now + try: + await self._scanner.async_request_active_window(duration) + except Exception as ex: # pylint: disable=broad-except + # First failure per recovery-cycle gets a traceback; + # subsequent failures collapse to a one-liner so a + # persistently broken scanner can't spam the log. + # Flag clears on the next success so failure-after- + # recovery captures a stack again. + if self._failed_window: + _LOGGER.warning( + "%s: error running active window of %.1fs: %s", + self._scanner.name, + duration, + ex, + ) + else: + self._failed_window = True + _LOGGER.exception( + "%s: error running active window of %.1fs", + self._scanner.name, + duration, + ) else: - self._failed_window = True - _LOGGER.exception( - "%s: error running active window of %.1fs", - self._scanner.name, - duration, - ) - else: - self._failed_window = False + self._failed_window = False + except Exception: # pylint: disable=broad-except + # Sync-phase failure (collect/advance/coalesce). Log so + # the worker doesn't die silently, then continue. + _LOGGER.exception( + "%s: unexpected error in auto-window tick", self._scanner.name + ) finally: self._window_end = 0.0 @@ -447,9 +457,9 @@ def add_scanner(self, scanner: BaseHaScanner) -> None: """ Register an AUTO-mode scanner; spawn its worker if running. - Skips when ``_running`` is False (``stop()`` leaves ``_loop`` - set, so without this guard a post-stop registration would - spawn a worker that exits on its first iteration). + Skips when ``_running`` or ``_loop`` are unset (both cleared + by ``stop()``), so a post-stop registration doesn't spawn a + worker that would have to exit on its first iteration. """ if scanner.requested_mode is not BluetoothScanningMode.AUTO: return diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index 1e635b25..ab9d38d3 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -880,6 +880,10 @@ async def _async_toggle_active_window_mode(self) -> bool: self.name, ex, ) + # The bleak scanner may be in an undefined state; mark + # the wrapper not-scanning so the caller's fallback path + # treats it as stopped. + self.scanning = False return False # Private bleak attribute — no public API for mode change. # BlueZ reads it on every start; macOS isn't reachable here. @@ -893,6 +897,9 @@ async def _async_toggle_active_window_mode(self) -> bool: self.name, ex, ) + # Scanner was stopped above and didn't come back; mark + # not-scanning so it matches reality. + self.scanning = False return False self.scanning = True self.set_current_mode(effective_mode) diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index def9287b..b4eb58cb 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -884,7 +884,115 @@ async def async_request_active_window(self, duration: float) -> bool: @pytest.mark.asyncio -async def test_start_replays_pre_start_requests_when_history_exists() -> None: +async def test_tick_sync_phase_exception_is_logged_and_worker_survives( + caplog: pytest.LogCaptureFixture, +) -> None: + """ + Sync-phase failures in _tick are logged; worker survives. + + Stubs async_last_service_info to raise so _collect_due_buckets + blows up; the outer except in _tick catches it and logs. + """ + import logging + + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:91" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=5.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:31", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + worker = sched._workers[scanner.source] + original = manager.async_last_service_info + + def _boom(_addr: str, _conn: bool) -> None: + raise RuntimeError("boom in last_service_info") + + manager.async_last_service_info = _boom # type: ignore[assignment,method-assign] + try: + with caplog.at_level(logging.ERROR): + await worker._tick() + assert any( + "unexpected error in auto-window tick" in record.message + for record in caplog.records + ) + # Worker is still alive; _window_end was reset. + assert worker._window_end == 0.0 + finally: + manager.async_last_service_info = original # type: ignore[method-assign] + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +async def test_mode_switch_unregister_then_register_picks_up_existing_request() -> None: + """ + Scheduler survives a HA-style scanner mode switch on the same source. + + HA's UI mode-switch path reloads the config entry: the old + scanner is unregistered, a new one with the same source is + registered with the new mode. The scheduler must (1) prune + _needs entries the leaving scanner owned via remove_scanner, + (2) keep user-registered ActiveScanRequests in + _requests_by_address, (3) spawn a fresh worker for a new AUTO + scanner via add_scanner, and (4) bootstrap _needs on the first + advertisement from the new scanner. + """ + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:92" + # Register the active-scan need first. + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=5.0 + ) + # Start in AUTO, see the device, then "switch to ACTIVE". + auto_scanner = _RecordingAutoScanner( + "AA:BB:CC:DD:EE:32", BluetoothScanningMode.AUTO + ) + auto_cancel = manager.async_register_scanner(auto_scanner) + try: + _inject(auto_scanner, address) + assert address in sched._needs + assert auto_scanner.source in sched._workers + # Mode switch in UI -> unregister AUTO scanner. + auto_cancel() + assert auto_scanner.source not in sched._workers + assert address not in sched._needs + # User's registration is preserved across the switch. + assert address in sched._requests_by_address + # Re-register with the SAME source but PASSIVE mode. + passive_scanner = _RecordingAutoScanner( + "AA:BB:CC:DD:EE:32", BluetoothScanningMode.PASSIVE + ) + passive_cancel = manager.async_register_scanner(passive_scanner) + try: + # PASSIVE doesn't get a worker. + assert passive_scanner.source not in sched._workers + # Still no _needs entry (no AUTO scanner owns it). + assert address not in sched._needs + passive_cancel() + # Now switch BACK to AUTO with the same source. + new_auto = _RecordingAutoScanner( + "AA:BB:CC:DD:EE:32", BluetoothScanningMode.AUTO + ) + new_auto_cancel = manager.async_register_scanner(new_auto) + try: + assert new_auto.source in sched._workers + # First advertisement on the new AUTO scanner bootstraps + # tracking again from the still-registered request. + _inject(new_auto, address) + assert address in sched._needs + finally: + new_auto_cancel() + except BaseException: + passive_cancel() + raise + finally: + cancel() """ add_request before start() seeds _needs at start() if history exists. diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 87fe2a29..25ae5e8f 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1905,7 +1905,59 @@ def register_detection_callback(self, callback): scanner_obj.async_setup() await scanner_obj.async_start() scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE + assert scanner_obj.scanning is True assert await scanner_obj._async_toggle_active_window_mode() is False + # scanner.stop() raised so the bleak scanner is in an + # undefined state; the wrapper must reflect that as not- + # scanning so the caller's fallback path treats it correctly. + assert scanner_obj.scanning is False + + +@pytest.mark.usefixtures("force_linux_scanner_mode") +@pytest.mark.asyncio +async def test_async_toggle_active_window_mode_marks_not_scanning_on_start_error() -> ( + None +): + """ + Toggle's start-error path also clears self.scanning. + + The stop succeeded but the post-mode-flip start raised, so the + bleak scanner is stopped. self.scanning must follow. + """ + starts = 0 + + class StartErrorMockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + + async def start(self): + nonlocal starts + starts += 1 + # First start (initial async_start) succeeds; the + # post-flip start (second call) raises. + if starts > 1: + raise BleakError("simulated start failure") + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: StartErrorMockBleakScanner(), + ): + scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner_obj.async_setup() + await scanner_obj.async_start() + scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE + assert scanner_obj.scanning is True + assert await scanner_obj._async_toggle_active_window_mode() is False + assert scanner_obj.scanning is False @pytest.mark.usefixtures("force_linux_scanner_mode") From 5f009c7b1871548ebe81ad3e791a83e2088e4c0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2026 18:37:51 -0500 Subject: [PATCH 75/75] fix(auto): guard private bleak attr, clear stale needs, validate duration; 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. --- src/habluetooth/auto_scheduler.py | 15 ++++-- src/habluetooth/scanner.py | 27 +++++++++- tests/test_auto_scheduler.py | 47 ++++++++++++++++++ tests/test_scanner.py | 82 ++++++++++++++++++++++++++++++- 4 files changed, 163 insertions(+), 8 deletions(-) diff --git a/src/habluetooth/auto_scheduler.py b/src/habluetooth/auto_scheduler.py index ba2b2d5d..3d2e370d 100644 --- a/src/habluetooth/auto_scheduler.py +++ b/src/habluetooth/auto_scheduler.py @@ -441,16 +441,21 @@ def stop(self) -> None: ``worker.stop()`` cancels without awaiting. Nulls ``_loop`` too so post-stop ``add_request`` / ``on_advertisement`` fall back to the record-only path instead of seeding ``_needs`` - with timestamps from the cancelled loop. In-place restart - (``stop()`` then ``start(new_loop)``) needs an - ``await asyncio.sleep(0)`` between them so cancelled tasks - finish before new workers spawn on the same sources; HA's - flow never does this. + with timestamps from the cancelled loop. Clears ``_needs`` + so a later ``start(new_loop)`` re-seeds from + ``_requests_by_address`` against the new loop's clock base; + leaving stale due-times would let them fire instantly (or + never) under a loop with a different ``time()`` origin. + In-place restart (``stop()`` then ``start(new_loop)``) + needs an ``await asyncio.sleep(0)`` between them so + cancelled tasks finish before new workers spawn on the same + sources; HA's flow never does this. """ self._running = False for worker in self._workers.values(): worker.stop() self._workers.clear() + self._needs.clear() self._loop = None def add_scanner(self, scanner: BaseHaScanner) -> None: diff --git a/src/habluetooth/scanner.py b/src/habluetooth/scanner.py index ab9d38d3..3017c4d6 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -5,6 +5,7 @@ import asyncio import contextlib import logging +import math import platform from collections.abc import Coroutine, Iterable from functools import lru_cache @@ -728,9 +729,20 @@ async def async_request_active_window(self, duration: float) -> bool: Concurrent / repeat callers while a window is open: a longer follow-up extends the timer; a shorter follow-up is a no-op on the timer but still returns True. No second restart fires. + Rejects non-finite or non-positive ``duration`` so a stray + NaN/inf can't poison ``loop.call_later`` or the extension + comparison; the scheduler clamps before calling but other + callers (subclasses, tests) may not. """ if self.requested_mode is not BluetoothScanningMode.AUTO: return False + if not math.isfinite(duration) or duration <= 0.0: + _LOGGER.warning( + "%s: refusing active window with invalid duration %r", + self.name, + duration, + ) + return False if IS_MACOS: return True if TYPE_CHECKING: @@ -887,7 +899,20 @@ async def _async_toggle_active_window_mode(self) -> bool: return False # Private bleak attribute — no public API for mode change. # BlueZ reads it on every start; macOS isn't reachable here. - self.scanner._backend._scanning_mode = mode_str + # Guarded so a future bleak refactor that renames/drops the + # attribute can't leave the scanner stopped with no restart; + # caller falls back to the full stop+recreate+start path. + try: + self.scanner._backend._scanning_mode = mode_str + except AttributeError as ex: + _LOGGER.warning( + "%s: bleak _backend._scanning_mode unavailable; " + "cannot toggle in place: %s", + self.name, + ex, + ) + self.scanning = False + return False try: async with asyncio.timeout(START_TIMEOUT): await self.scanner.start() diff --git a/tests/test_auto_scheduler.py b/tests/test_auto_scheduler.py index b4eb58cb..6f9ef0de 100644 --- a/tests/test_auto_scheduler.py +++ b/tests/test_auto_scheduler.py @@ -993,6 +993,10 @@ async def test_mode_switch_unregister_then_register_picks_up_existing_request() raise finally: cancel() + + +@pytest.mark.asyncio +async def test_start_replays_pre_start_requests_into_needs() -> None: """ add_request before start() seeds _needs at start() if history exists. @@ -2161,3 +2165,46 @@ async def test_device_migration_wakes_new_owner_worker() -> None: c_a() c_b() cancel() + + +@pytest.mark.asyncio +async def test_stop_clears_needs_so_restart_does_not_reuse_stale_due_times() -> None: + """ + stop() drops _needs so a later start(new_loop) seeds fresh due-times. + + Without this, a restart against a different event loop (whose + ``time()`` origin differs) would reuse timestamps from the + cancelled loop and either fire windows immediately or never. + """ + manager = get_manager() + sched = manager._auto_scheduler + address = "11:22:33:44:55:CC" + cancel = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=5.0 + ) + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:CC", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + _inject(scanner, address) + assert address in sched._needs + original_loop = sched._loop + sched.stop() + # _needs cleared so stale timestamps from the now-defunct loop + # can't survive into a re-start. + assert sched._needs == {} + assert sched._loop is None + assert sched._workers == {} + # _requests_by_address is loop-independent and must persist so + # start() can replay registrations on the new loop. + assert address in sched._requests_by_address + # Restart against the same loop; the request gets re-seeded with + # a fresh due time from the new loop.time() base. + assert original_loop is not None + sched.start(original_loop) + assert address in sched._needs + entries = sched._needs[address] + expected_due = original_loop.time() + 60.0 + assert all(abs(due - expected_due) < 0.5 for due in entries.values()) + finally: + cancel() + register_cancel() diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 25ae5e8f..d6f06126 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1757,6 +1757,27 @@ async def test_async_request_active_window_rejected_when_not_auto() -> None: assert scanner._scan_mode_override is None +@pytest.mark.asyncio +@pytest.mark.parametrize("duration", [float("nan"), float("inf"), -1.0, 0.0]) +async def test_async_request_active_window_rejects_invalid_duration( + duration: float, +) -> None: + """ + NaN/inf/non-positive durations are refused at the entry point. + + A bad duration would poison ``loop.call_later`` (which raises on + NaN) and the extension comparison (NaN ordering is always False, + inf would lock the window open). Guard the public entry so a + misbehaving subclass / direct caller can't corrupt the scheduler + state. + """ + scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner.async_setup() + assert await scanner.async_request_active_window(duration) is False + assert scanner._scan_mode_override is None + assert scanner._active_window_handle is None + + @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_restarts_scanner_in_active_mode() -> None: @@ -1799,8 +1820,11 @@ def _factory(*_args, **kwargs): backend = scanner.scanner._backend # type: ignore[union-attr] backend._scanning_mode = "passive" - # Window with 0 duration so call_later fires on the next loop turn. - assert await scanner.async_request_active_window(0.0) is True + # Tiny duration so call_later fires on the next loop turn. + # async_request_active_window rejects 0/NaN/inf at the boundary, + # so we use the smallest positive value that round-trips through + # the timer arithmetic. + assert await scanner.async_request_active_window(1e-9) is True # The toggle flipped the existing instance to active. assert backend._scanning_mode == "active" assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE @@ -1960,6 +1984,60 @@ def register_detection_callback(self, callback): assert scanner_obj.scanning is False +@pytest.mark.usefixtures("force_linux_scanner_mode") +@pytest.mark.asyncio +async def test_async_toggle_active_window_mode_attribute_error_marks_not_scanning() -> ( + None +): + """ + Toggle gracefully handles bleak refactoring away ``_scanning_mode``. + + If a future bleak version drops or renames ``_backend._scanning_mode``, + the mutation raises AttributeError. The stop has already completed, + so without a guard the scanner would be left stopped and the caller + would have no signal to fall back to the full path. The guard logs, + clears ``self.scanning``, and returns False so the caller can + recover via the full restart path. + """ + + class MockBackend: + @property + def _scanning_mode(self) -> str: + raise AttributeError("simulated bleak refactor — attribute removed") + + @_scanning_mode.setter + def _scanning_mode(self, value: str) -> None: + raise AttributeError("simulated bleak refactor — attribute removed") + + class AttrErrorMockBleakScanner: + _backend = MockBackend() + + async def start(self): + pass + + async def stop(self): + pass + + @property + def discovered_devices(self): + return [] + + def register_detection_callback(self, callback): + pass + + with patch( + "habluetooth.scanner.OriginalBleakScanner", + side_effect=lambda *_, **__: AttrErrorMockBleakScanner(), + ): + scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") + scanner_obj.async_setup() + await scanner_obj.async_start() + scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE + assert scanner_obj.scanning is True + assert await scanner_obj._async_toggle_active_window_mode() is False + assert scanner_obj.scanning is False + + @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_arm_active_window_timer_cancels_existing_handle() -> None: