mirror of
https://github.com/esphome/esphome.git
synced 2026-09-29 16:00:23 +00:00
[bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198)
This commit is contained in:
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user