Validate connection window against the window; skip no-op restarts; pack tracker flags

This commit is contained in:
J. Nick Koston
2026-08-21 17:25:44 -05:00
parent 499a0b5924
commit 69fc230055
4 changed files with 69 additions and 12 deletions
@@ -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]))
@@ -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
@@ -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 {
@@ -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: