From fe3b7ed84c19c5174f1c9b52788a66fac9156497 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Aug 2026 16:49:20 -0500 Subject: [PATCH] Return to the configured window when the last connection drops; review fixes --- .../components/ble_device_base/__init__.py | 14 +++++------ .../components/esp32_ble_tracker/__init__.py | 4 +++- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 23 +++++++++++++++---- .../esp32_ble_tracker/esp32_ble_tracker.h | 7 ++++-- .../test_scan_window_default.py | 17 ++++++++++++++ 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index e55d6efe68..14be6c4d29 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -234,13 +234,13 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: # 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"Scan {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( diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index aa1beeb7c7..8d8688b8f9 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -189,7 +189,9 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) # A full-duty scan must not compete with an active GATT connection # for airtime, so arm the connection-time fallback window as well - # unless the user picked one themselves. + # unless the user picked one themselves. Injected after validation; + # safe only because it equals the window default, which already + # passed the <= interval and controller range checks. if CONF_CONNECTION_SCAN_WINDOW not in params: params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period( ble_device_base.DEFAULT_SCAN_WINDOW diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index a2130003d5..f0f7393ad2 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -144,6 +144,16 @@ void ESP32BLETracker::loop() { (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->handle_scanner_failure_(); } + + // The window is chosen at scan start, so when the last connection drops + // mid-scan the reduced connection-time window would persist for the rest + // of the scan period (up to scan_duration_); stop the scan so the restart + // below returns to the configured window. Only for continuous scanning: + // a user-started scan would not be restarted. + if (this->scan_window_reduced_ && !counts.active && this->scan_continuous_ && + this->scanner_state_ == ScannerState::RUNNING) { + this->stop_scan_(); + } /* Avoid starting the scanner if: @@ -248,11 +258,11 @@ void ESP32BLETracker::start_scan_(bool first) { this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL; this->scan_params_.scan_interval = this->scan_interval_; uint32_t window = this->scan_window_; - if (this->connection_scan_window_ != 0 && this->client_state_counts_.active > 0) { - // The defaulted window was raised to full duty for advertisement - // throughput; while a GATT connection is active, fall back so the - // connection events get guaranteed airtime instead of competing with - // a wall-to-wall scan on the shared radio. + this->scan_window_reduced_ = this->connection_scan_window_ != 0 && this->client_state_counts_.active > 0; + if (this->scan_window_reduced_) { + // While a GATT connection is active, fall back to the connection scan + // window so the connection events get guaranteed airtime instead of + // competing with a wall-to-wall scan on the shared radio. ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", this->connection_scan_window_); window = this->connection_scan_window_; } @@ -417,6 +427,9 @@ 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_)); + if (this->connection_scan_window_ != 0) { + ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f); + } ESP_LOGCONFIG(TAG, " Scanner State: %s\n" " Connecting: %d, discovered: %d, disconnecting: %d, active: %d", diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index d5d39bf0bf..2a761172f7 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -314,8 +314,8 @@ class ESP32BLETracker final : public Component, uint32_t scan_duration_; uint32_t scan_interval_; uint32_t scan_window_; - /// Window used while a GATT connection is active; only set when the - /// defaulted window was raised to full duty (0 = no fallback). + /// 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}; esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; @@ -341,6 +341,9 @@ class ESP32BLETracker final : public Component, #endif bool ble_was_disabled_{true}; bool parse_advertisements_{false}; + /// The running scan was started with connection_scan_window_; lets loop() + /// restart the scan at the configured window when the last connection drops. + bool scan_window_reduced_{false}; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; #endif 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 c27db2bc73..a441a8337c 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,23 @@ def test_connection_scan_window_above_interval_rejected( _scan_params({"scan_parameters": {"connection_scan_window": "400ms"}}) +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 window .* both truncate"): + _scan_params( + { + "scan_parameters": { + "interval": "320.5ms", + "connection_scan_window": "320.2ms", + } + } + ) + + def test_codegen_connection_window_when_raised( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path],