[esp32_ble_tracker] Scan at the default window while a GATT connection is active

This commit is contained in:
J. Nick Koston
2026-08-21 16:35:17 -05:00
parent 11ea819bc7
commit a3675dfacb
7 changed files with 136 additions and 5 deletions
+18 -2
View File
@@ -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)
@@ -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]))
@@ -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
@@ -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};
@@ -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
@@ -0,0 +1,12 @@
esphome:
name: scan-window-raised
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
@@ -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