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..60a1e127 --- /dev/null +++ b/src/habluetooth/auto_scheduler.pxd @@ -0,0 +1,105 @@ +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: + + cdef public str address + cdef public double scan_interval + cdef public double scan_duration + + +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 + cdef public double _sweep_last_completed + cdef public bint _failed_window + + cpdef void start(self, object loop, double initial_offset=*) + + cpdef void stop(self) + + 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( + 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 from_time) + + +cdef class AutoScanScheduler: + + cdef public object _manager + cdef public dict _requests_by_address + cdef public dict _needs + cdef public dict _workers + cdef public object _loop + cdef public bint _running + + @cython.locals( + existing=dict, + ) + cpdef void add_request(self, ActiveScanRequest request) + + cpdef void remove_request(self, ActiveScanRequest request) + + cpdef void add_scanner(self, object scanner) + + @cython.locals( + source=str, + address=str, + ) + cpdef void remove_scanner(self, object scanner) + + @cython.locals( + address=str, + requests=set, + ) + 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 new file mode 100644 index 00000000..3d2e370d --- /dev/null +++ b/src/habluetooth/auto_scheduler.py @@ -0,0 +1,603 @@ +""" +Auto-mode active-window scheduler. + +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. + +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 (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 +==== + + add_request(req) on_advertisement(adv) + | | + | seed _needs[addr][req] | seed if pruned; + | = now + scan_interval | always wake + | wake address's owner | adv.source's worker + 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. _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| + +------------------------------------------+ + + +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 +========== + +* 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. +* 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 + +import asyncio +import contextlib +import logging +from typing import TYPE_CHECKING + +from .const import ( + AUTO_INITIAL_SWEEP_DELAY, + 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 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 +_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 + + +_LOGGER = logging.getLogger(__name__) + + +class ActiveScanRequest: + """ + A registered need for on-demand active scans on one address. + + ``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") + + def __init__( + self, + address: str, + scan_interval: float, + scan_duration: float, + ) -> None: + self.address = address + self.scan_interval = scan_interval + self.scan_duration = scan_duration + + +class _ScannerWorker: + """One persistent task per AUTO scanner; sleeps until next due event.""" + + __slots__ = ( + "_failed_window", + "_manager", + "_scanner", + "_scheduler", + "_sweep_last_completed", + "_task", + "_wake", + "_window_end", + ) + + 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 + self._sweep_last_completed: float = 0.0 + self._failed_window: bool = False + + def start( + self, loop: asyncio.AbstractEventLoop, initial_offset: float = 0.0 + ) -> None: + """ + Start the worker; first sweep at AUTO_INITIAL_SWEEP_DELAY + offset. + + ``initial_offset`` staggers first sweeps across concurrently- + registered scanners so they don't all flip ACTIVE at once. + """ + self._sweep_last_completed = ( + loop.time() + + _AUTO_INITIAL_SWEEP_DELAY + + initial_offset + - _AUTO_REDISCOVERY_INTERVAL + ) + self._task = loop.create_task(self._run()) + + def stop(self) -> None: + """Cancel the worker task.""" + 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. + + 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 + next_at = self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL + source = self._scanner.source + needs = self._scheduler._needs + last_service_info = self._manager.async_last_service_info + for address, entries in needs.items(): + if not entries: + continue + history = last_service_info(address, False) + 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: + """Sleep until next event or wake, then process due work.""" + 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() + + def _collect_due_buckets(self, now: float) -> tuple[ + list[tuple[dict[ActiveScanRequest, float], list[ActiveScanRequest]]], + list[ActiveScanRequest], + ]: + """ + Return (due_buckets, all_due) for addresses this scanner owns. + + ``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 + last_service_info = self._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: + continue + history = last_service_info(address, False) + if history is None: + del needs[address] + continue + if history.source != source: + continue + due = [r for r, t in entries.items() if t <= now] + if not due: + continue + 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]] + ], + from_time: float, + ) -> None: + """ + Set every advanced request's next-due to from_time + scan_interval. + + ``_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: + entries[request] = from_time + request.scan_interval + + async def _tick(self) -> None: + """ + Fire one coalesced window covering due per-device + sweep work. + + 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. 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: + return + now = loop.time() + # 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 + try: + 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 = 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 + + +class AutoScanScheduler: + """Coordinates on-demand active windows across AUTO-mode scanners.""" + + __slots__ = ( + "_loop", + "_manager", + "_needs", + "_requests_by_address", + "_running", + "_workers", + ) + + def __init__(self, manager: BluetoothManager) -> None: + """Initialize the scheduler bound to a manager.""" + self._manager = manager + self._requests_by_address: dict[str, set[ActiveScanRequest]] = {} + self._needs: dict[str, dict[ActiveScanRequest, float]] = {} + self._workers: dict[str, _ScannerWorker] = {} + self._loop: asyncio.AbstractEventLoop | None = None + self._running = False + + def start(self, loop: asyncio.AbstractEventLoop) -> None: + """ + Bind to the event loop and spawn one worker per AUTO scanner. + + 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 + self._loop = loop + self._running = True + for scanner in self._manager.async_current_scanners(): + if ( + scanner.requested_mode is BluetoothScanningMode.AUTO + and scanner.source not in self._workers + ): + self._spawn_worker(scanner) + 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 + self._seed_requests(address, requests, now) + + def stop(self) -> None: + """ + Cancel all worker tasks (fire-and-forget). + + Sync to match ``BluetoothManager.async_stop``; + ``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. 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: + """ + Register an AUTO-mode scanner; spawn its worker if running. + + 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 + if self._loop is None or not self._running or scanner.source in self._workers: + return + self._spawn_worker(scanner) + + def remove_scanner(self, scanner: BaseHaScanner) -> None: + """ + Stop the worker for a scanner leaving the manager. + + 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) + 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 + worker = _ScannerWorker(self, scanner, self._manager) + # 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 + worker.start(self._loop, offset) + self._workers[scanner.source] = worker + + def add_request(self, request: ActiveScanRequest) -> None: + """ + Register an active-scan request and start tracking. + + 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: + return + history = self._manager.async_last_service_info(request.address, False) + if history is None: + # 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: + 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.""" + if (bucket := self._requests_by_address.get(request.address)) is not None: + bucket.discard(request) + if not bucket: + 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] + + def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: + """ + 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 + address = service_info.address + requests = self._requests_by_address.get(address) + if requests is None: + return + 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 request not in existing: + existing[request] = now + request.scan_interval + + 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: + """ + 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), + 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 diff --git a/src/habluetooth/base_scanner.py b/src/habluetooth/base_scanner.py index d76561b2..a42bc9fa 100644 --- a/src/habluetooth/base_scanner.py +++ b/src/habluetooth/base_scanner.py @@ -712,6 +712,24 @@ 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 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 + ) + 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..31763342 100644 --- a/src/habluetooth/const.py +++ b/src/habluetooth/const.py @@ -54,6 +54,35 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 +# 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. 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 +# 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 + + FAILED_ADAPTER_MAC = "00:00:00:00:00:00" diff --git a/src/habluetooth/manager.pxd b/src/habluetooth/manager.pxd index 61969b0b..d7e85cf3 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 ActiveScanRequest, AutoScanScheduler from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak @@ -69,6 +70,12 @@ cdef class BluetoothManager: cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl + # _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( @@ -103,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 8a6a8099..f030b4ea 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 @@ -31,12 +32,17 @@ TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) +from .auto_scheduler import ActiveScanRequest, AutoScanScheduler from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl 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, + MIN_ACTIVE_SCAN_DURATION, + MIN_ACTIVE_SCAN_INTERVAL, UNAVAILABLE_TRACK_SECONDS, ) from .models import ( @@ -118,6 +124,7 @@ class BluetoothManager: "_all_history", "_allocations", "_allocations_callbacks", + "_auto_scheduler", "_bleak_callbacks", "_bluetooth_adapters", "_cancel_allocation_callbacks", @@ -209,6 +216,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 +371,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 +398,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: @@ -810,6 +820,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 ( @@ -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,6 +1045,7 @@ 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 @@ -1043,6 +1067,60 @@ def async_register_bleak_callback( return partial(self._bleak_callbacks.remove, callback_entry) + def async_register_active_scan( + self, + address: str, + scan_interval: float | None = None, + scan_duration: float | None = None, + ) -> CALLBACK_TYPE: + """ + Declare an on-demand active-scan need for a specific address. + + 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") + if scan_interval is None: + scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL + if scan_duration is None: + scan_duration = DEFAULT_ACTIVE_SCAN_DURATION + # 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 a finite number >= " + f"{MIN_ACTIVE_SCAN_INTERVAL:.0f}s" + ) + if not math.isfinite(scan_duration) or scan_duration < MIN_ACTIVE_SCAN_DURATION: + raise ValueError( + f"scan_duration must be a finite number >= " + f"{MIN_ACTIVE_SCAN_DURATION:.0f}s" + ) + # 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) + def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" self.slot_manager.release_slot(device) 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..3017c4d6 100644 --- a/src/habluetooth/scanner.py +++ b/src/habluetooth/scanner.py @@ -3,11 +3,13 @@ from __future__ import annotations import asyncio +import contextlib import logging +import math 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 +104,13 @@ 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. + # 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", } # The minimum number of seconds to know @@ -128,6 +137,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], } @@ -136,7 +150,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 @@ -200,7 +224,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 +250,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.""" @@ -354,13 +388,23 @@ 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: + """ + Mode the scanner should actually start in. + + 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 + 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" - self.set_current_mode(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 # 3th attempt - no auto reset @@ -369,7 +413,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 " @@ -459,7 +503,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 @@ -471,8 +515,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)", @@ -626,9 +677,259 @@ async def async_stop(self) -> 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._clear_active_window_state() self._async_stop_scanner_watchdog() await self._async_stop_scanner() + def _clear_active_window_state(self) -> None: + """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 + 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. + + 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 + 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 + ) + + 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 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. + 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: + assert self._loop is not None + if self._active_window_handle is not None: + 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; same extend-only rule as the lockless fast path. + if self.current_mode is BluetoothScanningMode.ACTIVE: + self._arm_active_window_timer_if_extends(duration) + return True + if IS_LINUX: + entered = await self._async_begin_active_window_via_toggle() + else: + 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_begin_active_window_via_toggle(self) -> bool: + """ + Cheap Linux/BlueZ entry via in-place ``_scanning_mode`` flip. + + 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() + except BaseException: + # 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: + return await self._async_abort_active_window() + return True + + async def _async_begin_active_window_via_restart(self) -> bool: + """ + 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 and False is returned. + """ + try: + await self._async_stop_then_start_under_lock() + except ScannerStartError: + return await self._async_abort_active_window() + 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 + + 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 + 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 the window ends.""" + async with self._start_stop_lock: + if self._active_window_handle is not None: + # A new window took over; let it own the override and timer. + return + self._scan_mode_override = None + if not self.scanning: + return + if IS_LINUX and await self._async_toggle_active_window_mode(): + return + # 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: + _LOGGER.warning( + "%s: Failed to restart scanner after active window: %s", + self.name, + ex, + ) + + async def _async_stop_then_start_under_lock(self) -> None: + """ + Stop and restart the BleakScanner; caller holds _start_stop_lock. + + 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() + await self._async_start() + + async def _async_toggle_active_window_mode(self) -> bool: + """ + Toggle the existing BleakScanner between active and passive. + + 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 + effective_mode = self._effective_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, + ) + # 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. + # 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() + except (TimeoutError, BleakError, ScannerStartError) as ex: + _LOGGER.warning( + "%s: Error starting scanner during active-window flip: %s", + 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) + return True + 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..6f9ef0de --- /dev/null +++ b/tests/test_auto_scheduler.py @@ -0,0 +1,2210 @@ +"""Tests for the auto-mode active-window scheduler.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Iterable + +import pytest +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData + +from habluetooth import ( + BaseHaScanner, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + get_manager, +) +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, + AUTO_WINDOW_MIN_DURATION, +) + +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 () + + +def _inject(scanner: _RecordingAutoScanner, address: str) -> None: + """Drive a fake advertisement through the scanner's normal path.""" + adv = generate_advertisement_data(local_name="x") + 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(), + ) + + +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.""" + 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=6.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_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( + "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: + # 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 "AA:AA:AA:AA:AA:AA" not in sched._needs + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +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() + cancel = manager.async_register_active_scan( + "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) + try: + _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 + await _run_worker_tick(sched, scanner.source) + assert scanner.active_window_calls == [5.0] + assert entries[request] > loop.time() + finally: + cancel() + 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.""" + 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=6.0 + ) + cancel2 = manager.async_register_active_scan( + 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) + 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) + assert scanner.active_window_calls == [10.0] + finally: + cancel1() + cancel2() + 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=5.0 + ) + cancel_slow = manager.async_register_active_scan( + 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) + 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 + await _run_worker_tick(sched, scanner.source) + 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 == [5.0, 7.0] + finally: + cancel_fast() + cancel_slow() + register_cancel() + + +@pytest.mark.asyncio +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 + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.ACTIVE) + register_cancel = manager.async_register_scanner(scanner) + try: + assert scanner.source not in sched._workers + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_global_sweep_runs_on_auto_scanner() -> None: + """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: + 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 worker._sweep_last_completed > loop.time() - 1.0 + finally: + register_cancel() + + +@pytest.mark.asyncio +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() + s1 = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + 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: + 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 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: + c1() + c2() + 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.""" + manager = get_manager() + sched = manager._auto_scheduler + 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) + assert address in sched._needs + cancel() + assert address not in sched._needs + assert sched._requests_by_address == {} + finally: + register_cancel() + + +@pytest.mark.asyncio +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 + before = worker._sweep_last_completed + 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. + assert worker._sweep_last_completed > before + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_stop_cancels_worker_tasks() -> None: + """Scheduler.stop cancels every worker task.""" + 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] + task = worker._task + assert task is not None + sched.stop() + await asyncio.sleep(0) + assert task.cancelled() or task.done() + assert sched._workers == {} + finally: + register_cancel() + + +@pytest.mark.asyncio +async def test_dispatch_drops_tracking_for_unseen_address() -> None: + """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} + 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 +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: + worker = sched._workers[scanner.source] + first_sweep_at = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL + now = loop.time() + assert ( + AUTO_INITIAL_SWEEP_DELAY - 1.0 + <= first_sweep_at - now + <= AUTO_INITIAL_SWEEP_DELAY + 1.0 + ) + finally: + register_cancel() + + +@pytest.mark.asyncio +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 scanner.source in sched._workers + worker = sched._workers[scanner.source] + task = worker._task + cancel() + 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_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().""" + manager = get_manager() + sched = manager._auto_scheduler + 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) + assert scanner.source not in sched._workers + manager._sources[scanner.source] = scanner + sched.start(loop) + assert scanner.source in sched._workers + sched._workers[scanner.source].stop() + finally: + manager._sources.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._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.""" + sched = get_manager()._auto_scheduler + + def _req(duration: float) -> ActiveScanRequest: + 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 + 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 + ) + # Empty list falls back to the configured minimum. + assert sched._coalesce_duration([]) == AUTO_WINDOW_MIN_DURATION + + +@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._requests_by_address == {} + finally: + register_cancel() + + +@pytest.mark.asyncio +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(address, scan_interval=120.0) + try: + worker = sched._workers[scanner.source] + # 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 + assert worker._wake.is_set() + finally: + cancel() + register_cancel() + + +@pytest.mark.asyncio +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"): + manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=0) + 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"): + 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"): + 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 +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_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: + """ + 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.""" + 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_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_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() + + +@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. + + 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_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_with_history) + try: + saved_loop = sched._loop + assert saved_loop is not None + sched._loop = None + sched._running = False + try: + # 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_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. 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] == 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] == 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 + finally: + cancel_with_a() + cancel_with_b() + cancel_without() + 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.""" + 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=6.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() + + +@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_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() + 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() + await worker._tick() + 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.""" + 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: + await worker._tick() + finally: + sched._loop = original_loop + assert scanner.active_window_calls == [] + finally: + register_cancel() + + +@pytest.mark.asyncio +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() + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + worker = sched._workers[scanner.source] + # 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() + + +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, 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 + 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_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" + 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) + # 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() + register_cancel() + + +@pytest.mark.asyncio +async def test_on_advertisement_with_all_requests_already_tracked() -> None: + """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" + 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: + 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) + # 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: + 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. 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 + 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=5.0 + ) + c2 = manager.async_register_active_scan( + address, scan_interval=60.0, scan_duration=7.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_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=5.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 == 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 == [5.0] + finally: + c_short() + c_long() + register_cancel() + + +@pytest.mark.asyncio +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() + 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=6.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() + # 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=6.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 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 + 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() + # 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(): + assert due > loop.time() + 250.0 + finally: + for cancel in cancels: + cancel() + 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=6.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: + """ + 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() + + +@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, 10.0)) + 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. + + 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" + scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:42", BluetoothScanningMode.AUTO) + register_cancel = manager.async_register_scanner(scanner) + try: + 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) + 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 +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() + + +@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() + + +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() + + +@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 fa2c7f34..d6f06126 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1,8 +1,11 @@ """Tests for the Bluetooth integration scanners.""" import asyncio +import logging 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 @@ -89,6 +92,22 @@ def disable_stop_discovery(): yield +@pytest.fixture +def force_linux_scanner_mode() -> Generator[None, None, None]: + """ + Force scanner.IS_LINUX=True / IS_MACOS=False for AUTO-flow tests. + + 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_LINUX", True), + patch("habluetooth.scanner.IS_MACOS", False), + ): + yield + + @pytest.fixture(autouse=True, scope="module") def manager(): """Return the BluetoothManager instance.""" @@ -362,6 +381,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 @@ -413,6 +434,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 @@ -495,6 +518,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 @@ -596,6 +621,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 @@ -712,6 +739,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 @@ -784,6 +813,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 @@ -852,6 +883,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 @@ -893,6 +926,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 @@ -1713,6 +1748,979 @@ async def test_on_scanner_start_callback( 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 +@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: + """An AUTO scanner flips to ACTIVE and schedules a return to the prior mode.""" + + class MockBleakScanner: + _backend = types.SimpleNamespace(_scanning_mode="passive") + + 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 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" + + # 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 + 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 +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: + _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 *_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.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.""" + 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.""" + + 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 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") +@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: + """ + _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: + _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 *_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.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.""" + + 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 + + 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 + # 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: + """ + _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. + + 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. + """ + duration = 10.0 + restart_started = asyncio.Event() + gate = asyncio.Event() + + 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 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: 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 + + 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: + """ + 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: + _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 + + 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() + # 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; 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"] + + await scanner.async_stop() + + +@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. + """ + + 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.""" + + 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() + 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.""" + call_count = 0 + fail_until = 0 + + 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 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.usefixtures("force_linux_scanner_mode") +@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: + _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 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, +) -> 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.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.""" + + 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() + 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: + _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() + 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.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.""" + starts = 0 + + 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 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.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.""" + starts = 0 + fail_until = 0 + + 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 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() + 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")]) def test_create_bleak_scanner_wraps_init_error(exc: Exception) -> None: """``create_bleak_scanner`` wraps FileNotFoundError/BleakError as RuntimeError."""