[bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198)

This commit is contained in:
J. Nick Koston
2026-08-10 10:47:56 -05:00
committed by GitHub
parent c8c929d487
commit f3d1fc0d64
26 changed files with 1518 additions and 976 deletions
@@ -109,6 +109,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only(
generate_main(component_config_path("esp32_bluetooth_proxy.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3"
# One neutral GATT backend slot per connection (the hub-model flip).
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
def test_counts_reset_between_compiles(
@@ -15,6 +15,13 @@ import voluptuous as vol
from esphome import config_validation as cv
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema
def _esp32_schema_keys() -> dict[str, object]:
# The builder names its platform explicitly, so no CORE state is needed
# (this also mirrors how the language-schema dumper calls it).
return _keys(_schema_of(_esp32_config_schema()))
# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA
# plumbing (derived, so a future core key does not fail this component's test),
# generated IDs (not user-walkable options), and connections (must validate
@@ -38,7 +45,7 @@ def _keys(schema: vol.Schema) -> dict[str, object]:
def test_outer_scalar_keys_exist_in_esp32_schema() -> None:
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _keys(_schema_of(_esp32_config_schema()))
esp32 = _esp32_schema_keys()
missing = set(outer) - set(esp32)
assert not missing, (
f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema "
@@ -51,7 +58,7 @@ def test_esp32_scalars_all_walkable() -> None:
"""Every non-generated esp32 scalar option must appear in the outer schema
(connections is deliberately excluded — it must validate exactly once)."""
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _keys(_schema_of(_esp32_config_schema()))
esp32 = _esp32_schema_keys()
scalar = {
name
for name, key in esp32.items()
@@ -9,11 +9,13 @@ import pytest
from esphome import config_validation as cv
from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy
from esphome.config_helpers import frameworks_for_platforms
from esphome.const import (
CONF_ACTIVE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_LN882X,
PLATFORM_RP2,
PlatformFramework,
@@ -177,28 +179,49 @@ def test_rp2_rejects_esp32_only_keys_by_name(
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
def test_hub_source_filter_covers_every_hub_platform() -> None:
# bluetooth_connection cannot import this module to derive the hub.cpp
# framework set, so pin it here: a platform admitted to the proxy but
# missing from the filter would validate, then fail at link.
expected = frameworks_for_platforms(
[*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32]
)
hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[
"bluetooth_connection_hub.cpp"
]
assert expected == hub_frameworks
def test_bluetooth_connection_auto_load_covers_its_includes() -> None:
# The esp32 connection header includes esp32_ble_client; the auto load
# must satisfy that closure itself (regression: it once relied on the
# consumer's auto loads).
# The backend registers with its platform BLE stack (and the Bluedroid
# header includes the tracker's), so that closure lives here and
# consumers stay platform-blind; the platform-less arm is the union for
# manifest-resolving tooling.
_set_platform("esp32")
assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD()
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"]
_set_platform("rp2")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"]
_set_platform("ln882x")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"]
# No target platform (tooling resolving the manifest): the union, so
# dependency closures stay complete for build_codeowners and friends.
_set_platform(None)
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"]
assert bluetooth_connection.AUTO_LOAD() == [
"ble_device_base",
"esp32_ble_tracker",
"rp2040_ble",
]
def test_every_registered_hub_platform_has_a_schema_arm() -> None:
# A platform added to HUB_MAX_CONNECTIONS without a schema builder,
# codegen arm, or _HUB_PLATFORMS entry would only fail when a config for
# it is validated (or not even then); pin all three couplings here.
# A platform added to HUB_MAX_CONNECTIONS without a schema builder or
# _HUB_PLATFORMS entry would only fail when a config for it is validated
# (or not even then); pin both couplings here. Connection codegen is
# shared (bluetooth_connection.new_gatt_backend), so it needs no arm.
registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS)
assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS)
assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE)
assert registered <= set(bluetooth_proxy._HUB_PLATFORMS)
# Hub platforms must also be in the backend registry the shared codegen
# helpers dispatch on.
assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS)
# The outer walkable schema's bound must stay the loosest platform cap.
assert (
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
@@ -220,9 +243,14 @@ def test_defines_h_mirrors_the_rp2_slot_cap() -> None:
assert int(match.group(1)) == cap, (
f"defines.h rp2 arm carries {match.group(1)}, expected {cap}"
)
# The static-analysis client count scales with the same cap.
match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines)
assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h"
# The static-analysis client count scales with the same cap. Scoped to
# the USE_RP2 block: the esp32 arm carries its own count.
rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines)
assert rp2_block is not None, "no USE_RP2 platform block in defines.h"
match = re.search(
r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1)
)
assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block"
assert int(match.group(1)) == cap, (
f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}"
f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}"
)
@@ -2,7 +2,7 @@
// configured; this TU pins it on the host so the header cannot rot unseen.
// The contract is a concept (BLEGattConnection is a per-platform alias), so
// the minimal backend here proves the concept stays satisfiable and routes
// events through the duck-typed sink the way a real backend does.
// events through the GattClientListener interface the way a real backend does.
#define USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
@@ -11,37 +11,37 @@
namespace esphome::ble_device_base::testing {
struct RecordingSink {
void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; }
void on_service_discovery_done(int error) { this->discovery_error_ = error; }
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
void on_write_result(uint16_t handle, int error) {}
void on_notify_state(uint16_t handle, bool enabled, int error) {}
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {}
void on_pairing_result(int status) {}
// Overrides only what it records; the interface's defaults cover the rest.
class RecordingListener : public GattClientListener {
public:
void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; }
void on_service_discovery_done(int error) override { this->discovery_error_ = error; }
void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; }
bool connected_{false};
int discovery_error_{0};
uint16_t write_handle_{0};
};
static_assert(GattClientEventSinkContract<RecordingSink>, "the recording sink must cover the full event-sink surface");
class MinimalConnection {
public:
void set_listener(RecordingSink *listener) { this->listener_ = listener; }
void set_listener(GattClientListener *listener) { this->listener_ = listener; }
int connect(uint64_t address, uint8_t addr_type) {
if (this->listener_ != nullptr)
this->listener_->on_connection_state(true, 517, 0);
this->listener_->on_connection_state(true, 517, 0);
return 0;
}
int disconnect() { return 0; }
bool cancel_gatt_disconnect() { return false; }
int gatt_disconnect() { return 0; }
int discover_services() {
if (this->listener_ != nullptr)
this->listener_->on_service_discovery_done(0);
this->listener_->on_service_discovery_done(0);
return 0;
}
int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
this->listener_->on_write_result(handle, 0);
return 0;
}
int read_descriptor(uint16_t handle) { return 0; }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; }
int notify_characteristic(uint16_t handle, bool enable) { return 0; }
@@ -51,23 +51,26 @@ class MinimalConnection {
}
GattServiceTable get_service_table() { return {}; }
void release_services() {}
void set_connection_type(ConnectionType ct) {}
protected:
RecordingSink *listener_{nullptr};
GattClientListener *listener_{nullptr};
};
static_assert(BLEGattConnectionContract<MinimalConnection, RecordingSink>,
static_assert(BLEGattConnectionContract<MinimalConnection>,
"a minimal backend must satisfy the contract the alias asserts");
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
MinimalConnection connection;
RecordingSink listener;
RecordingListener listener;
connection.set_listener(&listener);
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
EXPECT_TRUE(listener.connected_);
EXPECT_EQ(connection.discover_services(), 0);
EXPECT_EQ(listener.discovery_error_, 0);
EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED);
EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0);
EXPECT_EQ(listener.write_handle_, 7);
// A default table is empty and safe to walk.
GattServiceTable table = connection.get_service_table();
@@ -0,0 +1,12 @@
# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is
# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the
# address-scoped maintenance path that a connections build never exercises.
# Under batch grouping the active default build is what runs; the standalone
# compile of this fixture is what exercises the passive gating.
packages:
common: !include common.yaml
esp32_ble_tracker:
bluetooth_proxy:
active: false
+16 -1
View File
@@ -3,7 +3,13 @@
from collections.abc import Callable
from unittest.mock import patch
from esphome.config_helpers import filter_source_files_from_platform, get_logger_level
import pytest
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
get_logger_level,
)
from esphome.const import (
CONF_LEVEL,
CONF_LOGGER,
@@ -133,3 +139,12 @@ def test_get_logger_level() -> None:
mock_config = {CONF_LOGGER: {}}
with patch("esphome.config_helpers.CORE.config", mock_config):
assert get_logger_level() == "DEBUG"
def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None:
assert frameworks_for_platforms(["esp32"]) == {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
}
with pytest.raises(ValueError, match="unknown platform"):
frameworks_for_platforms(["esp32", "not_a_platform"])