From a3675dfacb76c91f9194f8416c6b8c71af9717b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Aug 2026 16:35:17 -0500 Subject: [PATCH] [esp32_ble_tracker] Scan at the default window while a GATT connection is active --- .../components/ble_device_base/__init__.py | 20 +++++- .../components/esp32_ble_tracker/__init__.py | 19 +++++- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 11 +++- .../esp32_ble_tracker/esp32_ble_tracker.h | 4 ++ .../config/scan_window_explicit.yaml | 14 +++++ .../config/scan_window_raised.yaml | 12 ++++ .../test_scan_window_default.py | 61 ++++++++++++++++++- 7 files changed, 136 insertions(+), 5 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 diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 15a8b08139..e55d6efe68 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -211,10 +211,19 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: f"Scan window ({window}) needs to be smaller than scan interval ({interval})" ) + windows = [("window", window)] + if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None: + if connection_window > interval: + raise cv.Invalid( + f"Connection scan window ({connection_window}) needs to be smaller " + f"than scan interval ({interval})" + ) + windows.append(("connection window", connection_window)) + # 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 (("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" @@ -247,11 +256,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 +275,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 +286,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 4f6355df70..aa1beeb7c7 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, @@ -186,6 +187,13 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: # 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]) + # 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. + if CONF_CONNECTION_SCAN_WINDOW not in params: + params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period( + ble_device_base.DEFAULT_SCAN_WINDOW + ) return config @@ -194,7 +202,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 @@ -288,6 +296,15 @@ async def to_code(config: ConfigType) -> None: 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: + # Set by the user, or defaulted by _raise_defaulted_scan_window when + # the window was raised to full duty; while a GATT connection is + # active the scanner falls back to it (see start_scan_). + cg.add( + var.set_connection_scan_window( + ble_device_base.to_ble_units(connection_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..a2130003d5 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -247,7 +247,16 @@ 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_; + 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. + ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", this->connection_scan_window_); + window = this->connection_scan_window_; + } + this->scan_params_.scan_window = window; // Start timeout monitoring in loop() instead of using scheduler // This prevents false reboots when the loop is blocked diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 7c3e5538fd..d5d39bf0bf 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -169,6 +169,7 @@ 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; } + void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; } 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; } @@ -313,6 +314,9 @@ 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). + 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}; 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..6ab1575198 --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/config/scan_window_explicit.yaml @@ -0,0 +1,14 @@ +esphome: + name: scan-window-explicit + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + +esp32_ble_tracker: + scan_parameters: + window: 30ms 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..ba57af8750 --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/config/scan_window_raised.yaml @@ -0,0 +1,12 @@ +esphome: + name: scan-window-raised + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + +esp32_ble_tracker: 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..c27db2bc73 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,61 @@ 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_codegen_connection_window_when_raised( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("scan_window_raised.yaml")) + assert "set_scan_window(512)" in main_cpp + assert "set_connection_scan_window(48)" in main_cpp + + +def test_codegen_no_connection_window_for_explicit_window( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("scan_window_explicit.yaml")) + assert "set_scan_window(48)" in main_cpp + assert "set_connection_scan_window" not in main_cpp