diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 0d432826d5..c85c3b22bf 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -149,6 +149,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: @@ -177,14 +178,16 @@ 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. Also checks the connection + window against the window here, after the raise, so a fallback below a + raised window validates while one that would widen the scan is rejected. """ + 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]) @@ -197,6 +200,16 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: 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, the exact + # airtime contention the option exists to remove. + raise cv.Invalid( + f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be " + f"smaller than the scan window ({params[CONF_WINDOW]})" + ) return config @@ -311,6 +324,15 @@ async def to_code(config: ConfigType) -> None: 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: + # A user-set value that cannot take effect deserves a heads-up; + # the auto-injected default is dropped 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])) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index cfe7d3172e..2c8bf82e56 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -158,6 +158,10 @@ void ESP32BLETracker::loop() { // in flight, matching the restart gate below. if (this->using_connection_window_ && !counts.active && !counts.disconnecting && this->scan_continuous_ && this->scanner_state_ == ScannerState::RUNNING) { + // The restart continues the same logical scan period, so the listeners + // must not get an extra on_scan_end (ble_rssi would publish NAN for + // beacons not yet seen in the period). + this->skip_next_scan_end_ = true; this->stop_scan_(); } #endif @@ -247,7 +251,15 @@ void ESP32BLETracker::start_scan_(bool first) { } this->set_scanner_state_(ScannerState::STARTING); ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING."); - if (!first) { + bool notify_scan_end = !first; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + if (this->skip_next_scan_end_) { + // Window-change restart: the same logical scan period continues. + this->skip_next_scan_end_ = false; + notify_scan_end = false; + } +#endif + if (notify_scan_end) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_scan_end(); @@ -267,8 +279,12 @@ void ESP32BLETracker::start_scan_(bool first) { uint32_t window = this->scan_window_; #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT // Count fresh rather than reading the loop() cache: an automation can start - // a scan before loop() has refreshed the counts for a new connection. - this->using_connection_window_ = this->connection_scan_window_ != 0 && this->count_client_states_().active > 0; + // a scan before loop() has refreshed the counts for a new connection. An + // equal connection window changes nothing, so it must not arm the + // last-disconnect restart in loop(). + this->using_connection_window_ = this->connection_scan_window_ != 0 && + this->connection_scan_window_ != this->scan_window_ && + this->count_client_states_().active > 0; if (this->using_connection_window_) { // While a GATT connection is active, fall back to the connection scan // window so the connection events get guaranteed airtime instead of diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 5b4206c668..fca31fd7fc 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -338,20 +338,24 @@ 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_; + // Single-bit flags packed into one byte so new flags stop growing the class. + 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_{true}; - bool parse_advertisements_{false}; + bool ble_was_disabled_ : 1 {true}; + bool parse_advertisements_ : 1 {false}; #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT /// The running scan was started with connection_scan_window_; lets loop() /// restart the scan at the configured window when the last connection drops. - bool using_connection_window_{false}; + bool using_connection_window_ : 1 {false}; + /// The next start_scan_ continues the same logical scan period (set by the + /// window-change restart), so the on_scan_end sweep is skipped once. + bool skip_next_scan_end_ : 1 {false}; #endif #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 { 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 806735f184..8a3680d021 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 @@ -163,6 +163,21 @@ def test_connection_scan_window_above_interval_rejected( _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: