mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 10:52:56 +08:00
[esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356)
This commit is contained in:
@@ -37,7 +37,7 @@ from esphome.const import (
|
|||||||
CONF_INTERVAL,
|
CONF_INTERVAL,
|
||||||
KEY_TARGET_PLATFORM,
|
KEY_TARGET_PLATFORM,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, ID, KEY_CORE
|
from esphome.core import CORE, ID, KEY_CORE, TimePeriod
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
CODEOWNERS = ["@Bl00d-B0b"]
|
CODEOWNERS = ["@Bl00d-B0b"]
|
||||||
@@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# The historical scan window default shared by the trackers that do not pin
|
||||||
|
# their own; also the fallback for esp32's conditional default.
|
||||||
|
DEFAULT_SCAN_WINDOW = "30ms"
|
||||||
|
|
||||||
|
|
||||||
def scan_parameters_schema(
|
def scan_parameters_schema(
|
||||||
interval_default: str,
|
interval_default: str,
|
||||||
*,
|
*,
|
||||||
window_default: str = "30ms",
|
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
|
||||||
) -> cv.All:
|
) -> cv.All:
|
||||||
"""Build the scan_parameters value schema shared by all BLE trackers.
|
"""Build the scan_parameters value schema shared by all BLE trackers.
|
||||||
|
|
||||||
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
|
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
|
||||||
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
|
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
|
||||||
LN882H's SDK recommends 100/50 ms). The `active` option (default on) is
|
LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg
|
||||||
unconditional: active scanning is part of the tracker contract — every
|
callable evaluated per validation when the user omits the key (esp32 uses
|
||||||
current proxy client assumes it, so a passive-only tracker must not share
|
this to record that the window was defaulted, so a later validation step
|
||||||
this 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.
|
||||||
"""
|
"""
|
||||||
schema = {
|
schema = {
|
||||||
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from esphome import automation
|
from esphome import automation
|
||||||
@@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota
|
|||||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||||
from esphome.components.esp32 import (
|
from esphome.components.esp32 import (
|
||||||
add_idf_sdkconfig_option,
|
add_idf_sdkconfig_option,
|
||||||
|
idf_version,
|
||||||
request_bluetooth,
|
request_bluetooth,
|
||||||
request_software_coexistence,
|
request_software_coexistence,
|
||||||
)
|
)
|
||||||
@@ -35,10 +38,12 @@ from esphome.const import (
|
|||||||
CONF_SERVICE_UUID,
|
CONF_SERVICE_UUID,
|
||||||
CONF_TRIGGER_ID,
|
CONF_TRIGGER_ID,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority
|
||||||
from esphome.enum import StrEnum
|
from esphome.enum import StrEnum
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
DOMAIN = "esp32_ble_tracker"
|
||||||
|
|
||||||
AUTO_LOAD = ["ble_device_base", "esp32_ble"]
|
AUTO_LOAD = ["ble_device_base", "esp32_ble"]
|
||||||
DEPENDENCIES = ["esp32"]
|
DEPENDENCIES = ["esp32"]
|
||||||
CODEOWNERS = ["@bdraco"]
|
CODEOWNERS = ["@bdraco"]
|
||||||
@@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far
|
||||||
|
# longer than the configured window (espressif/esp-idf#18931). Before the fix,
|
||||||
|
# the default 30 ms window in a 320 ms interval effectively scanned at a much
|
||||||
|
# higher duty cycle than requested; with the fix, that same default only
|
||||||
|
# listens 9.4 % of the time and misses most advertisements when wifi shares
|
||||||
|
# the radio. Espressif recommends setting the window equal to the interval in
|
||||||
|
# that case: the coexistence arbiter still shares the radio with wifi, and
|
||||||
|
# BLE uses the airtime wifi does not claim.
|
||||||
|
IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackerData:
|
||||||
|
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
|
||||||
|
|
||||||
|
scan_window_defaulted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_data() -> TrackerData:
|
||||||
|
if DOMAIN not in CORE.data:
|
||||||
|
CORE.data[DOMAIN] = TrackerData()
|
||||||
|
return CORE.data[DOMAIN]
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_window_default() -> TimePeriod:
|
||||||
|
"""Schema default for the scan window.
|
||||||
|
|
||||||
|
Records that the user did not set a window, so _raise_defaulted_scan_window
|
||||||
|
can tell a defaulted 30 ms from an explicit one; the raise itself must wait
|
||||||
|
for the outer schema because it depends on software_coexistence, a sibling
|
||||||
|
key not yet resolved here.
|
||||||
|
"""
|
||||||
|
_get_data().scan_window_defaulted = True
|
||||||
|
return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
|
||||||
|
"""Raise a defaulted scan window to the interval where that is safe.
|
||||||
|
|
||||||
|
Only when the coexistence arbiter is compiled in (software_coexistence,
|
||||||
|
present iff wifi is configured and not disabled by the user) and the IDF
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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])
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
|
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
|
||||||
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
|
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
|
||||||
# window/interval pairs that collapse to the same 0.625 ms unit count.
|
# window/interval pairs that collapse to the same 0.625 ms unit count.
|
||||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms")
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
# Codegen helpers are owned by ble_device_base; kept under the historical names
|
# Codegen helpers are owned by ble_device_base; kept under the historical names
|
||||||
# here for the components that import them from this module.
|
# here for the components that import them from this module.
|
||||||
@@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
}
|
}
|
||||||
).extend(cv.COMPONENT_SCHEMA),
|
).extend(cv.COMPONENT_SCHEMA),
|
||||||
validate_max_connections_deprecated,
|
validate_max_connections_deprecated,
|
||||||
|
_raise_defaulted_scan_window,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_esp32_defaults_are_valid() -> None:
|
def test_esp32_defaults_are_valid() -> None:
|
||||||
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
|
"""esp32 pins the ESP-IDF reference rate and exposes active (default on).
|
||||||
|
|
||||||
|
Without wifi loaded, the conditional window default falls back to the
|
||||||
|
historical 30 ms; the wifi-aware resolution is covered by the
|
||||||
|
esp32_ble_tracker component tests.
|
||||||
|
"""
|
||||||
config = ESP32_SCHEMA({})
|
config = ESP32_SCHEMA({})
|
||||||
assert to_ble_units(config["interval"]) == 512
|
assert to_ble_units(config["interval"]) == 512
|
||||||
assert to_ble_units(config["window"]) == 48
|
assert to_ble_units(config["window"]) == 48
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Tests for the esp32_ble_tracker conditional scan window default.
|
||||||
|
|
||||||
|
The scan window default depends on wifi coexistence and the IDF version:
|
||||||
|
IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the
|
||||||
|
configured window (espressif/esp-idf#18931), so on fixed versions the
|
||||||
|
historical 30 ms default would only listen 9.4 % of the time and miss most
|
||||||
|
advertisements. With the coexistence arbiter compiled in on a fixed IDF, the
|
||||||
|
window instead defaults to the interval, as Espressif recommends; without the
|
||||||
|
arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome import config_validation as cv
|
||||||
|
from esphome.components.ble_device_base import 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 (
|
||||||
|
CONF_SOFTWARE_COEXISTENCE,
|
||||||
|
CONFIG_SCHEMA,
|
||||||
|
)
|
||||||
|
from esphome.const import CONF_INTERVAL, PlatformFramework
|
||||||
|
from esphome.core import CORE
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
from ..types import SetCoreConfigCallable
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stage_esp32(
|
||||||
|
set_core_config: SetCoreConfigCallable,
|
||||||
|
) -> Callable[..., None]:
|
||||||
|
"""Stage an esp32 build with a given IDF version and wifi presence."""
|
||||||
|
|
||||||
|
def stage(idf: str, *, wifi: bool) -> None:
|
||||||
|
set_core_config(
|
||||||
|
PlatformFramework.ESP32_IDF,
|
||||||
|
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
|
||||||
|
)
|
||||||
|
if wifi:
|
||||||
|
# Makes cv.OnlyWith default software_coexistence to True, exactly
|
||||||
|
# as a real config with wifi: does.
|
||||||
|
CORE.loaded_integrations.add("wifi")
|
||||||
|
|
||||||
|
return stage
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_params(config: ConfigType) -> ConfigType:
|
||||||
|
return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("idf", "config", "expected_units"),
|
||||||
|
[
|
||||||
|
("5.5.5", {}, 512), # first fixed version, default 320 ms interval
|
||||||
|
("6.0.1", {}, 512), # any newer version behaves the same
|
||||||
|
# Follows a user-set interval.
|
||||||
|
("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_wifi_on_fixed_idf_defaults_window_to_interval(
|
||||||
|
stage_esp32: Callable[..., None],
|
||||||
|
idf: str,
|
||||||
|
config: ConfigType,
|
||||||
|
expected_units: int,
|
||||||
|
) -> None:
|
||||||
|
"""With wifi coexistence on a fixed IDF, the window defaults to the interval."""
|
||||||
|
stage_esp32(idf, wifi=True)
|
||||||
|
params = _scan_params(config)
|
||||||
|
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
|
||||||
|
assert to_ble_units(params[CONF_WINDOW]) == expected_units
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("idf", "wifi", "config"),
|
||||||
|
[
|
||||||
|
# Buggy IDF over-scans anyway; keep the 30 ms default.
|
||||||
|
("5.5.4", True, {}),
|
||||||
|
# No wifi (e.g. ethernet) means no radio contention.
|
||||||
|
("5.5.5", False, {}),
|
||||||
|
# Coexistence disabled: no arbiter, so a full-duty scan would starve
|
||||||
|
# wifi outright.
|
||||||
|
("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_30ms_default_kept(
|
||||||
|
stage_esp32: Callable[..., None],
|
||||||
|
idf: str,
|
||||||
|
wifi: bool,
|
||||||
|
config: ConfigType,
|
||||||
|
) -> None:
|
||||||
|
stage_esp32(idf, wifi=wifi)
|
||||||
|
assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("window", ["60ms", "30ms"])
|
||||||
|
def test_explicit_window_is_never_touched(
|
||||||
|
stage_esp32: Callable[..., None], window: str
|
||||||
|
) -> None:
|
||||||
|
"""A user-set window wins over the conditional default.
|
||||||
|
|
||||||
|
The explicit 30 ms case matters: it is indistinguishable from the
|
||||||
|
defaulted value by inspection, so the defaulted flag must separate them.
|
||||||
|
"""
|
||||||
|
stage_esp32("5.5.5", wifi=True)
|
||||||
|
params = _scan_params({"scan_parameters": {"window": window}})
|
||||||
|
assert to_ble_units(params[CONF_WINDOW]) == to_ble_units(
|
||||||
|
cv.positive_time_period(window)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_interval_without_window_still_rejected(
|
||||||
|
stage_esp32: Callable[..., None],
|
||||||
|
) -> None:
|
||||||
|
"""The provisional 30 ms default validates against the interval as before."""
|
||||||
|
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"}})
|
||||||
Reference in New Issue
Block a user