From 662bf7d7f05d7bbd585b9258af034f179c8a60ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 16:16:01 -0500 Subject: [PATCH] [esp32_ble_tracker] Scan at the default window while a GATT connection is active (#18609) --- .../components/ble_device_base/__init__.py | 43 +++++--- .../components/esp32_ble_tracker/__init__.py | 47 +++++++- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 73 ++++++++++--- .../esp32_ble_tracker/esp32_ble_tracker.h | 36 ++++-- .../config/scan_window_explicit.yaml | 19 ++++ .../config/scan_window_raised.yaml | 17 +++ .../config/scan_window_scan_only.yaml | 12 ++ .../scan_window_user_set_scan_only.yaml | 14 +++ .../test_scan_window_default.py | 103 +++++++++++++++++- 9 files changed, 320 insertions(+), 44 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/config/scan_window_explicit.yaml create mode 100644 tests/component_tests/esp32_ble_tracker/config/scan_window_raised.yaml create mode 100644 tests/component_tests/esp32_ble_tracker/config/scan_window_scan_only.yaml create mode 100644 tests/component_tests/esp32_ble_tracker/config/scan_window_user_set_scan_only.yaml diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 15a8b08139..43ec736727 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -206,32 +206,36 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: interval = config[CONF_INTERVAL] window = config[CONF_WINDOW] - if window > interval: - raise cv.Invalid( - f"Scan window ({window}) needs to be smaller than scan interval ({interval})" - ) + # Labels are reused in every error below; the optional one names its key. + windows = [("Scan window", window)] + if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None: + windows.append((CONF_CONNECTION_SCAN_WINDOW, connection_window)) + + for name, value in windows: + if value > interval: + raise cv.Invalid( + f"{name} ({value}) needs to be smaller than scan interval ({interval})" + ) # BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the # controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range # values here instead of letting the unit conversion silently overflow. - for name, value in (("interval", interval), ("window", window)): + for name, value in (("Scan interval", interval), *windows): if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000: - raise cv.Invalid( - f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms" - ) + raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms") # Validate what actually reaches the controller: both values are truncated to # whole 0.625 ms units, so a window/interval pair that differs by less than one # unit collapses to the same value — silently programming a 100 % duty cycle # (radio permanently on) from a config that asked for less. interval_units = to_ble_units(interval) - window_units = to_ble_units(window) - if window_units == interval_units and window < interval: - raise cv.Invalid( - f"Scan window ({window}) and interval ({interval}) both truncate to " - f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty " - f"cycle. Separate them by at least 0.625 ms." - ) + for name, value in windows: + if to_ble_units(value) == interval_units and value < interval: + raise cv.Invalid( + f"{name} ({value}) and interval ({interval}) both truncate to " + f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty " + f"cycle. Separate them by at least 0.625 ms." + ) if interval.total_microseconds * 3 > duration.total_microseconds: raise cv.Invalid( @@ -247,11 +251,14 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: # their own; also the fallback for esp32's conditional default. DEFAULT_SCAN_WINDOW = "30ms" +CONF_CONNECTION_SCAN_WINDOW = "connection_scan_window" + def scan_parameters_schema( interval_default: str, *, window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, + connection_window: bool = False, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. @@ -263,7 +270,9 @@ def scan_parameters_schema( can adjust it once sibling keys are resolved). The `active` option (default on) is unconditional: active scanning is part of the tracker contract — every current proxy client assumes it, so a passive-only - tracker must not share this schema. + tracker must not share this schema. connection_window opts in to the + `connection_scan_window` option for trackers that can fall back to a + smaller window while a GATT connection is active. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, @@ -272,6 +281,8 @@ def scan_parameters_schema( cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, cv.Optional(CONF_ACTIVE, default=True): cv.boolean, } + if connection_window: + schema[cv.Optional(CONF_CONNECTION_SCAN_WINDOW)] = cv.positive_time_period return cv.All(cv.Schema(schema), validate_scan_parameters) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 28c8c7fcf1..55aa67c96d 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -7,6 +7,7 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, esp32_ble, ota +from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, @@ -72,8 +73,9 @@ def _get_required_features() -> set[BLEFeatures]: # Slot counters sizing the tracker's StaticVector storage; one request per # registered listener or client. +CLIENT_COUNT_DEFINE = "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT" _request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") -_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") +_request_client_slot = cg.slot_counter(CLIENT_COUNT_DEFINE) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -146,6 +148,7 @@ class TrackerData: """Per-run validation state, namespaced under DOMAIN in CORE.data.""" scan_window_defaulted: bool = False + connection_window_injected: bool = False def _get_data() -> TrackerData: @@ -174,17 +177,34 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: honors the window strictly (>= 5.5.5); without the arbiter a full-duty scan would starve wifi outright, and a user-set window is never touched. Raising to the interval cannot invalidate the already-validated - parameters, so no re-validation is needed. + parameters, so no re-validation is needed. The connection window is + checked against the window here, after the raise. """ + params = config[CONF_SCAN_PARAMETERS] if ( _get_data().scan_window_defaulted and config.get(CONF_SOFTWARE_COEXISTENCE) and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION ): - params = config[CONF_SCAN_PARAMETERS] # Copy so the config dump shows a plain value instead of a YAML # anchor/alias pair pointing at the interval. params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + # Arm the connection-time fallback unless the user set one. Injected + # after validation; safe because it equals the validated window default. + if CONF_CONNECTION_SCAN_WINDOW not in params: + params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period( + ble_device_base.DEFAULT_SCAN_WINDOW + ) + _get_data().connection_window_injected = True + if ( + connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW) + ) is not None and connection_window > params[CONF_WINDOW]: + # A larger value would widen the scan during connections. + raise cv.Invalid( + f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be " + f"smaller than the scan window ({params[CONF_WINDOW]})", + path=[CONF_SCAN_PARAMETERS, CONF_CONNECTION_SCAN_WINDOW], + ) return config @@ -193,7 +213,7 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: # window/interval pairs that collapse to the same 0.625 ms unit count. # The window default is conditional (see _scan_window_default above). SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "320ms", window_default=_scan_window_default + "320ms", window_default=_scan_window_default, connection_window=True ) # Codegen helpers are owned by ble_device_base; kept under the historical names @@ -287,6 +307,25 @@ async def to_code(config): cg.add(var.set_scan_duration(params[CONF_DURATION])) cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL]))) cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW]))) + if (connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)) is not None: + # Emitted at FINAL so a scan-only build, where the guarded C++ path + # compiles out, skips the call entirely. + window_units = ble_device_base.to_ble_units(connection_window) + + @coroutine_with_priority(CoroPriority.FINAL) + async def _emit_connection_scan_window() -> None: + if cg.get_slot_count(CLIENT_COUNT_DEFINE): + cg.add(var.set_connection_scan_window(window_units)) + elif not _get_data().connection_window_injected: + # Warn only for a user-set value; the injected default drops silently. + _LOGGER.warning( + "'%s' has no effect because this build has no BLE client " + "components (for example bluetooth_proxy with active " + "connections, or ble_client)", + CONF_CONNECTION_SCAN_WINDOW, + ) + + CORE.add_job(_emit_connection_scan_window) cg.add(var.set_scan_active(params[CONF_ACTIVE])) cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS])) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 798fd6e0ca..5339565a32 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -122,6 +122,9 @@ void ESP32BLETracker::loop() { // - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_() // - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or // connecting client finishes (state change), or scanner reaches RUNNING/IDLE + // - connection-window restart: scan_params_ is only written in start_scan_() + // (which changes scanner state via set_scanner_state_()), and + // counts.active/disconnecting only change on client state changes // // All conditions that affect the logic below are tied to state changes that increment // state_version_, so the fast path is safe. @@ -144,6 +147,19 @@ void ESP32BLETracker::loop() { (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->handle_scanner_failure_(); } + +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // The programmed window no longer matches the connection state (typically + // the last connection dropped): restart so the right window applies now + // instead of at the end of the scan period. Continuous only (a user-started + // scan would not restart); !disconnecting matches the restart gate below. + if (this->scanner_state_ == ScannerState::RUNNING && this->scan_continuous_ && !counts.disconnecting && + this->scan_params_.scan_window != this->desired_scan_window_(counts.active)) { + // Same logical scan period continues: no on_scan_end sweeps for this + // restart. Only armed when the stop was issued. + this->skip_next_scan_end_ = this->stop_scan_(); + } +#endif /* Avoid starting the scanner if: @@ -195,19 +211,23 @@ void ESP32BLETracker::stop_scan() { // reason at D themselves, and the user-facing stop action is deliberate. ESP_LOGV(TAG, "Stopping scan."); this->scan_continuous_ = false; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // The window-change restart is abandoned with continuous scanning. + this->skip_next_scan_end_ = false; +#endif this->stop_scan_(); } void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } -void ESP32BLETracker::stop_scan_() { +bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { // IDLE means there is nothing to stop; STOPPING means a stop is already in // flight and will finish on its own. Neither is an error. if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); } - return; + return false; } // Reset timeout state machine when stopping scan this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; @@ -215,8 +235,9 @@ void ESP32BLETracker::stop_scan_() { esp_err_t err = esp_ble_gap_stop_scanning(); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gap_stop_scanning failed: %d", err); - return; + return false; } + return true; } void ESP32BLETracker::start_scan_(bool first) { @@ -230,16 +251,11 @@ void ESP32BLETracker::start_scan_(bool first) { } this->set_scanner_state_(ScannerState::STARTING); ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING."); - if (!first) { -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); + if (!first) + this->notify_scan_end_(); +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + this->skip_next_scan_end_ = false; #endif -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->neutral_listeners_) - listener->on_scan_end(); -#endif - } #ifdef USE_ESP32_BLE_DEVICE this->discovered_log_.clear(); #endif @@ -247,7 +263,17 @@ void ESP32BLETracker::start_scan_(bool first) { this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC; this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL; this->scan_params_.scan_interval = this->scan_interval_; - this->scan_params_.scan_window = this->scan_window_; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // Count fresh: an automation can start a scan before loop() refreshes the counts. + const uint32_t window = this->desired_scan_window_(this->count_client_states_().active); + if (window != this->scan_window_) { + // Guarantee the connection airtime instead of scanning wall to wall. + ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", window); + } +#else + const uint32_t window = this->scan_window_; +#endif + this->scan_params_.scan_window = window; // Start timeout monitoring in loop() instead of using scheduler // This prevents false reboots when the loop is blocked @@ -408,6 +434,11 @@ void ESP32BLETracker::dump_config() { " Continuous Scanning: %s", this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f, this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + if (this->connection_scan_window_ != 0) { + ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f); + } +#endif ESP_LOGCONFIG(TAG, " Scanner State: %s\n" " Connecting: %d, discovered: %d, disconnecting: %d, active: %d", @@ -487,6 +518,18 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { // Reset timeout state machine instead of cancelling scheduler timeout this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; + this->notify_scan_end_(); + + this->set_scanner_state_(ScannerState::IDLE); +} + +void ESP32BLETracker::notify_scan_end_() { +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // Window-change restart continues the same scan period; the flag stays set + // across the stop and is cleared by the restart in start_scan_. + if (this->skip_next_scan_end_) + return; +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_scan_end(); @@ -495,8 +538,6 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { for (auto *listener : this->neutral_listeners_) listener->on_scan_end(); #endif - - this->set_scanner_state_(ScannerState::IDLE); } void ESP32BLETracker::handle_scanner_failure_() { @@ -534,6 +575,8 @@ void ESP32BLETracker::try_promote_discovered_clients_() { } ESP_LOGD(TAG, "Promoting client to connect"); + // A connect ends the scan period a window-change restart was continuing. + this->skip_next_scan_end_ = false; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE this->update_coex_preference_(true); #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 7c3e5538fd..618444e626 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -169,6 +169,9 @@ class ESP32BLETracker final : public Component, void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; } void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; } void set_scan_window(uint32_t scan_window) { scan_window_ = scan_window; } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; } +#endif void set_scan_active(bool scan_active) { scan_active_ = scan_active; } bool get_scan_active() const { return scan_active_; } void set_scan_continuous(bool scan_continuous) { scan_continuous_ = scan_continuous; } @@ -226,7 +229,10 @@ class ESP32BLETracker final : public Component, ScannerState get_scanner_state() const { return this->scanner_state_; } protected: - void stop_scan_(); + /// Returns true when a stop was issued to the controller. + bool stop_scan_(); + /// Fire on_scan_end on every listener unless a window-change restart suppressed it. + void notify_scan_end_(); /// Start a single scan by setting up the parameters and doing some esp-idf calls. void start_scan_(bool first); /// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received. @@ -313,6 +319,15 @@ class ESP32BLETracker final : public Component, uint32_t scan_duration_; uint32_t scan_interval_; uint32_t scan_window_; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + /// Window used while a GATT connection is active; set by the user, or + /// defaulted when the window was raised to full duty (0 = no fallback). + uint32_t connection_scan_window_{0}; + /// The window to scan at for the given number of active GATT connections. + uint32_t desired_scan_window_(uint8_t active) const { + return (this->connection_scan_window_ != 0 && active > 0) ? this->connection_scan_window_ : this->scan_window_; + } +#endif esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; @@ -330,15 +345,20 @@ class ESP32BLETracker final : public Component, /// state_version_ to detect if any state changed since last iteration. uint8_t last_processed_version_{0}; ScannerState scanner_state_{ScannerState::IDLE}; - bool scan_continuous_; - bool scan_active_; + // Packed 1-bit flags. + bool scan_continuous_ : 1; + bool scan_active_ : 1; #ifdef USE_OTA_STATE_LISTENER - bool scan_continuous_before_ota_{false}; + bool scan_continuous_before_ota_ : 1 {false}; +#endif + bool ble_was_disabled_ : 1 {true}; + bool parse_advertisements_ : 1 {false}; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + /// Suppress the window-change restart's on_scan_end sweeps (stop and start). + bool skip_next_scan_end_ : 1 {false}; #endif - bool ble_was_disabled_{true}; - bool parse_advertisements_{false}; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - bool coex_prefer_ble_{false}; + bool coex_prefer_ble_ : 1 {false}; #endif // Scan timeout state machine enum class ScanTimeoutState : uint8_t { @@ -346,10 +366,10 @@ class ESP32BLETracker final : public Component, MONITORING, // Actively monitoring for timeout EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot }; + ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE}; uint32_t scan_start_time_{0}; /// Precomputed timeout value: scan_duration_ * 2000 uint32_t scan_timeout_ms_{0}; - ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE}; }; // NOLINTNEXTLINE diff --git a/tests/component_tests/esp32_ble_tracker/config/scan_window_explicit.yaml b/tests/component_tests/esp32_ble_tracker/config/scan_window_explicit.yaml new file mode 100644 index 0000000000..70760fdea6 --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/config/scan_window_explicit.yaml @@ -0,0 +1,19 @@ +esphome: + name: scan-window-explicit + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + +esp32_ble_tracker: + scan_parameters: + window: 30ms + +bluetooth_proxy: + active: true + +api: diff --git a/tests/component_tests/esp32_ble_tracker/config/scan_window_raised.yaml b/tests/component_tests/esp32_ble_tracker/config/scan_window_raised.yaml new file mode 100644 index 0000000000..4febbfcf3b --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/config/scan_window_raised.yaml @@ -0,0 +1,17 @@ +esphome: + name: scan-window-raised + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + +esp32_ble_tracker: + +bluetooth_proxy: + active: true + +api: diff --git a/tests/component_tests/esp32_ble_tracker/config/scan_window_scan_only.yaml b/tests/component_tests/esp32_ble_tracker/config/scan_window_scan_only.yaml new file mode 100644 index 0000000000..5da5601388 --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/config/scan_window_scan_only.yaml @@ -0,0 +1,12 @@ +esphome: + name: scan-window-scan-only + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + +esp32_ble_tracker: diff --git a/tests/component_tests/esp32_ble_tracker/config/scan_window_user_set_scan_only.yaml b/tests/component_tests/esp32_ble_tracker/config/scan_window_user_set_scan_only.yaml new file mode 100644 index 0000000000..2b9093164f --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/config/scan_window_user_set_scan_only.yaml @@ -0,0 +1,14 @@ +esphome: + name: scan-window-user-scan-only + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + +esp32_ble_tracker: + scan_parameters: + connection_scan_window: 20ms diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py index 8a25f488fa..8612ac6732 100644 --- a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -12,11 +12,12 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. from __future__ import annotations from collections.abc import Callable +from pathlib import Path import pytest from esphome import config_validation as cv -from esphome.components.ble_device_base import to_ble_units +from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW, to_ble_units from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import KEY_IDF_VERSION from esphome.components.esp32_ble_tracker import ( @@ -120,3 +121,103 @@ def test_short_interval_without_window_still_rejected( stage_esp32("5.5.5", wifi=True) with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): _scan_params({"scan_parameters": {"interval": "20ms"}}) + + +# The connection-time fallback window: while a GATT connection is active the +# scanner drops from a raised full-duty window back to this value so the +# connection gets guaranteed airtime. + + +def test_raise_arms_connection_scan_window_default( + stage_esp32: Callable[..., None], +) -> None: + stage_esp32("5.5.5", wifi=True) + params = _scan_params({}) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 48 + + +def test_user_connection_scan_window_survives_raise( + stage_esp32: Callable[..., None], +) -> None: + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"connection_scan_window": "60ms"}}) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 96 + + +def test_unraised_window_gets_no_connection_scan_window_default( + stage_esp32: Callable[..., None], +) -> None: + stage_esp32("5.5.4", wifi=True) + assert CONF_CONNECTION_SCAN_WINDOW not in _scan_params({}) + + +def test_connection_scan_window_above_interval_rejected( + stage_esp32: Callable[..., None], +) -> None: + stage_esp32("5.5.5", wifi=True) + with pytest.raises( + cv.Invalid, match="connection_scan_window .* needs to be smaller" + ): + _scan_params({"scan_parameters": {"connection_scan_window": "400ms"}}) + + +def test_connection_scan_window_above_window_rejected( + stage_esp32: Callable[..., None], +) -> None: + """A connection window above the (post-raise) window would widen the scan + during connections; the reject runs after the raise so a fallback below a + raised window still validates (covered by the survives-raise test).""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises( + cv.Invalid, match="connection_scan_window .* needs to be smaller" + ): + _scan_params( + {"scan_parameters": {"window": "30ms", "connection_scan_window": "300ms"}} + ) + + +def test_connection_scan_window_truncation_collapse_rejected( + stage_esp32: Callable[..., None], +) -> None: + """A connection window that truncates into the interval's 0.625 ms unit + would silently program a full-duty scan during connections.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"): + _scan_params( + { + "scan_parameters": { + "interval": "320.5ms", + "connection_scan_window": "320.2ms", + } + } + ) + + +@pytest.mark.parametrize( + ("config_file", "window_call", "connection_call", "warns"), + [ + # Raised window with GATT clients: the injected fallback is emitted. + ("scan_window_raised.yaml", "set_scan_window(512)", True, False), + # Explicit window: nothing injected. + ("scan_window_explicit.yaml", "set_scan_window(48)", False, False), + # Scan-only build compiles the path out: the injected default is + # dropped silently, a user-set value warns. + ("scan_window_scan_only.yaml", "set_scan_window(512)", False, False), + ("scan_window_user_set_scan_only.yaml", "set_scan_window(512)", False, True), + ], +) +def test_connection_scan_window_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, + config_file: str, + window_call: str, + connection_call: bool, + warns: bool, +) -> None: + main_cpp = generate_main(component_config_path(config_file)) + assert window_call in main_cpp + assert ("set_connection_scan_window(48)" in main_cpp) == connection_call + assert ("'connection_scan_window' has no effect" in caplog.text) == warns