Compile the connection window out of scan-only builds; review polish

This commit is contained in:
J. Nick Koston
2026-08-21 17:08:08 -05:00
parent cc6876d776
commit 499a0b5924
8 changed files with 81 additions and 26 deletions
@@ -211,23 +211,22 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
windows = [("window", window)]
# Labels name the YAML key the user typed so the errors are greppable.
windows = [("Scan 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})"
f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be "
f"smaller than scan interval ({interval})"
)
windows.append(("connection window", connection_window))
windows.append((CONF_CONNECTION_SCAN_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), *windows):
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
@@ -237,7 +236,7 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
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"{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."
)
@@ -74,8 +74,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:
@@ -301,12 +302,17 @@ async def to_code(config: ConfigType) -> None:
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)
)
)
# active the scanner falls back to it (see start_scan_). Emitted at
# FINAL priority so a scan-only build (no GATT clients registered,
# so 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))
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]))
@@ -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: using_connection_window_ 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.
@@ -145,15 +148,19 @@ void ESP32BLETracker::loop() {
this->handle_scanner_failure_();
}
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// 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_ &&
// mid-scan the 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. Waiting for disconnecting to
// reach zero keeps the stop off the controller while a GATT disconnect is
// in flight, matching the restart gate below.
if (this->using_connection_window_ && !counts.active && !counts.disconnecting && this->scan_continuous_ &&
this->scanner_state_ == ScannerState::RUNNING) {
this->stop_scan_();
}
#endif
/*
Avoid starting the scanner if:
@@ -258,16 +265,18 @@ 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_;
#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->scan_window_reduced_ = this->connection_scan_window_ != 0 && this->count_client_states_().active > 0;
if (this->scan_window_reduced_) {
this->using_connection_window_ = this->connection_scan_window_ != 0 && 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
// 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_;
}
#endif
this->scan_params_.scan_window = window;
// Start timeout monitoring in loop() instead of using scheduler
@@ -429,9 +438,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",
@@ -169,7 +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; }
@@ -314,9 +316,11 @@ 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};
#endif
esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS};
esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS};
@@ -341,9 +345,11 @@ class ESP32BLETracker final : public Component,
#endif
bool ble_was_disabled_{true};
bool parse_advertisements_{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 scan_window_reduced_{false};
bool using_connection_window_{false};
#endif
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_{false};
#endif
@@ -12,3 +12,8 @@ wifi:
esp32_ble_tracker:
scan_parameters:
window: 30ms
bluetooth_proxy:
active: true
api:
@@ -10,3 +10,8 @@ wifi:
ssid: MySSID
esp32_ble_tracker:
bluetooth_proxy:
active: true
api:
@@ -0,0 +1,12 @@
esphome:
name: scan-window-scan-only
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
@@ -158,7 +158,7 @@ def test_connection_scan_window_above_interval_rejected(
) -> None:
stage_esp32("5.5.5", wifi=True)
with pytest.raises(
cv.Invalid, match="Connection scan window .* needs to be smaller"
cv.Invalid, match="connection_scan_window .* needs to be smaller"
):
_scan_params({"scan_parameters": {"connection_scan_window": "400ms"}})
@@ -169,7 +169,7 @@ def test_connection_scan_window_truncation_collapse_rejected(
"""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"):
with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"):
_scan_params(
{
"scan_parameters": {
@@ -196,3 +196,14 @@ def test_codegen_no_connection_window_for_explicit_window(
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
def test_codegen_no_connection_window_without_gatt_clients(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Scan-only builds compile the connection-window path out, so the raised
default must not emit a call to the guarded setter."""
main_cpp = generate_main(component_config_path("scan_window_scan_only.yaml"))
assert "set_scan_window(512)" in main_cpp
assert "set_connection_scan_window" not in main_cpp