Own the platform dispatch in bluetooth_connection and gate the proxy surface

This commit is contained in:
J. Nick Koston
2026-08-08 23:16:58 -05:00
parent 5d42a69d7a
commit 2e8700f090
10 changed files with 132 additions and 95 deletions
@@ -8,14 +8,24 @@ gatt_client_schema() + new_gatt_backend().
"""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.config_helpers import filter_source_files_from_platform
from esphome.const import PLATFORM_RP2, PlatformFramework
import esphome.config_validation as cv
from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework
from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT
from esphome.types import ConfigType
def AUTO_LOAD() -> list[str]:
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
registers with, so consumers stay platform-blind. The platform-less arm
serves tooling that resolves the manifest without a target."""
if CORE.is_esp32:
return ["ble_device_base", "esp32_ble_tracker"]
if CORE.target_platform == PLATFORM_RP2:
return ["ble_device_base", "rp2040_ble"]
if CORE.target_platform is None:
return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"]
return ["ble_device_base"]
@@ -31,6 +41,9 @@ RP2_MAX_CONNECTIONS = 1
# registry of which hub platforms run the connection-capable proxy.
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
# Every platform with a GATT backend; gates dedicated-backend consumers.
GATT_CLIENT_PLATFORMS = [PLATFORM_ESP32, PLATFORM_RP2]
# The hub-platform wrapper and the rp2 BTstack backend codegen classes.
HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection")
RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component)
@@ -65,6 +78,27 @@ def gatt_client_schema() -> cv.Schema:
)
def gatt_client_config_schema(base_schema: cv.Schema, consumer: str) -> cv.All:
"""Wrap a dedicated-backend consumer's schema so the consumer stays
platform-blind: gates on the platforms with a backend, folds in
gatt_client_schema(), and does the esp32 controller-slot bookkeeping.
`consumer` names the component in slot-exhaustion errors."""
def apply(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
# The language-schema dumper runs without a platform; expose the
# consumer's own keys.
return base_schema
config = base_schema.extend(gatt_client_schema())(config)
if CORE.is_esp32:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(1, consumer)(config)
return config
return cv.All(cv.only_on(GATT_CLIENT_PLATFORMS), apply)
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
"""Instantiate the backend declared by gatt_client_schema(), register it
with its platform stack, and claim one neutral GATT client slot.
@@ -77,7 +111,9 @@ async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
ble_device_base.request_gatt_client()
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
await cg.register_component(backend, config)
# The backend has no user-facing component options; an empty config keeps
# the consumer's own keys (update_interval, ...) off it.
await cg.register_component(backend, {})
if CORE.is_esp32:
from esphome.components import esp32_ble_tracker
@@ -16,11 +16,13 @@
#include <esp_err.h>
#endif
// The connection-aware API request handlers are compiled: a GATT backend is
// wired by codegen (one slot per connection). Advertisement-only builds get
// the clean-error handlers; address-scoped maintenance (unpair, cache clear)
// still works there through the per-platform free functions below.
#ifdef USE_BLE_GATT_CLIENT
// The connection-aware API request handlers are compiled: a proxy is present
// and a GATT backend is wired by codegen (one slot per connection).
// Advertisement-only builds get the clean-error handlers; address-scoped
// maintenance (unpair, cache clear) still works there through the
// per-platform free functions below. Backend-only builds (a dedicated-backend
// consumer without bluetooth_proxy) compile none of this API surface.
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
#define BLUETOOTH_CONNECTION_HAS_GATT
#endif
@@ -2,10 +2,14 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef USE_BLUETOOTH_PROXY
#include "bluetooth_connection.h"
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#endif
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/core/hal.h"
@@ -281,9 +285,8 @@ bool BluedroidGattClient::build_service_table_() {
for (uint16_t c = 0; c < svc_chars; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status =
esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) {
break;
}
@@ -335,9 +338,8 @@ bool BluedroidGattClient::build_service_table_() {
for (uint16_t c = 0; char_index < char_total; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status =
esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) {
break;
}
@@ -447,6 +449,7 @@ void BluedroidGattClient::handle_search_cmpl_() {
this->sink_.on_service_discovery_done(0);
}
#ifdef USE_BLUETOOTH_PROXY
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
if (this->services_released_ || conn.send_service_ >= this->service_total_) {
conn.send_service_ = DONE_SENDING_SERVICES;
@@ -576,6 +579,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
conn.send_service_ = batch_start;
}
}
#endif // USE_BLUETOOTH_PROXY
// ---- events ----
@@ -711,27 +715,25 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
if (this->conn_id_ != param->read.conn_id)
return false;
bool ok = param->read.status == ESP_GATT_OK;
this->sink_.on_read_result(param->read.handle, ok ? param->read.value : nullptr,
ok ? param->read.value_len : 0, ok ? 0 : param->read.status);
this->sink_.on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
ok ? 0 : param->read.status);
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
this->sink_.on_write_result(param->write.handle,
param->write.status == ESP_GATT_OK ? 0 : param->write.status);
this->sink_.on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
this->sink_.on_notify_state(param->reg_for_notify.handle, true,
param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status);
param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
this->sink_.on_notify_state(
param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
this->sink_.on_notify_state(param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
@@ -759,8 +761,8 @@ void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_bl
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
break;
this->sink_.on_pairing_result(
param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason);
this->sink_.on_pairing_result(param->ble_security.auth_cmpl.success ? 0
: param->ble_security.auth_cmpl.fail_reason);
break;
}
default:
@@ -22,8 +22,10 @@
namespace esphome::bluetooth_connection {
class BluetoothConnection;
class BluedroidGattClient;
#ifdef USE_BLUETOOTH_PROXY
class BluetoothConnection;
#endif
// The tracker-facing half: owns the ClientState the promote loop reads and
// forwards events/commands to the engine.
@@ -75,10 +77,12 @@ class BluedroidGattClient final : public Component {
ble_device_base::GattServiceTable get_service_table();
void release_services();
/// In-place service streamer (the wrapper detects and prefers it): builds
/// one api response batch directly from Bluedroid's cached database, so the
/// streaming peak is the response itself - the old esp32 model.
#ifdef USE_BLUETOOTH_PROXY
/// In-place service streamer (the proxy wrapper detects and prefers it):
/// builds one api response batch directly from Bluedroid's cached database,
/// so the streaming peak is the response itself - the old esp32 model.
void stream_service_batch(BluetoothConnection &conn);
#endif
void set_connection_type(esp32_ble_tracker::ConnectionType ct) { this->connection_type_ = ct; }
bool disconnect_pending() const { return this->shim_.disconnect_pending(); }
@@ -1,7 +1,7 @@
// Hub-platform connection wrapper (USE_RP2 hub builds today).
#include "bluetooth_connection_hub.h"
#ifdef USE_BLE_GATT_CLIENT
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -433,4 +433,4 @@ void BluetoothConnection::send_service_for_discovery_() {
} // namespace esphome::bluetooth_connection
#endif // USE_BLE_GATT_CLIENT
#endif // USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY
@@ -7,7 +7,10 @@
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
// The wrapper exists to serve the proxy's API surface; dedicated-backend
// consumers (radon_eye_rd200) drive the backend directly, so backend-only
// builds compile this header empty.
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
#include "bluetooth_connection.h"
@@ -151,4 +154,4 @@ static_assert(ble_device_base::GattClientEventSinkContract<BluetoothConnection>,
} // namespace esphome::bluetooth_connection
#endif // USE_BLE_GATT_CLIENT
#endif // USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY
@@ -2,6 +2,7 @@
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cstring>
@@ -24,6 +25,10 @@ static const char *const WRITE_CHARACTERISTIC_UUID_V2 = "00001524-0000-1000-8000
static const char *const READ_CHARACTERISTIC_UUID_V2 = "00001525-0000-1000-8000-00805f9b34fb";
static const uint8_t WRITE_COMMAND_V2 = 0x40;
// Minimum notification payload carrying all three measurements.
static const uint16_t MESSAGE_MIN_LEN_V1 = 20;
static const uint16_t MESSAGE_MIN_LEN_V2 = 68;
// BLE public address type (shared code space with the API/backends).
static const uint8_t BLE_ADDR_TYPE_PUBLIC = 0;
@@ -161,7 +166,8 @@ void RadonEyeRD200::read_sensors_(const uint8_t *value, uint16_t value_len) {
uint8_t command = value[0];
if ((command == WRITE_COMMAND_V1 && value_len < 20) || (command == WRITE_COMMAND_V2 && value_len < 68)) {
if ((command == WRITE_COMMAND_V1 && value_len < MESSAGE_MIN_LEN_V1) ||
(command == WRITE_COMMAND_V2 && value_len < MESSAGE_MIN_LEN_V2)) {
ESP_LOGW(TAG, "Unexpected command 0x%02X message length %d", command, value_len);
return;
}
@@ -170,8 +176,11 @@ void RadonEyeRD200::read_sensors_(const uint8_t *value, uint16_t value_len) {
// 501085EBB9400000000000000000220025000000
// Example data V2:
// 4042323230313033525532303338330652443230304e56322e302e3200014a00060a00080000000300010079300000e01108001c00020000003822005c8f423fa4709d3f
ESP_LOGV(TAG, "radon sensors raw bytes");
ESP_LOG_BUFFER_HEX_LEVEL(TAG, value, value_len, ESP_LOG_VERBOSE);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Sized for the longest supported message; format_hex_to truncates longer.
char hex_buf[format_hex_size(MESSAGE_MIN_LEN_V2)];
ESP_LOGV(TAG, "radon sensors raw bytes: %s", format_hex_to(hex_buf, value, value_len));
#endif
// Convert from pCi/L to Bq/m³
constexpr float convert_to_bwpm3 = 37.0;
+12 -52
View File
@@ -1,5 +1,3 @@
import functools
import esphome.codegen as cg
from esphome.components import bluetooth_connection, sensor
import esphome.config_validation as cv
@@ -9,25 +7,12 @@ from esphome.const import (
CONF_RADON,
CONF_RADON_LONG_TERM,
ICON_RADIOACTIVE,
PLATFORM_ESP32,
PLATFORM_RP2,
STATE_CLASS_MEASUREMENT,
UNIT_BECQUEREL_PER_CUBIC_METER,
)
from esphome.core import CORE
from esphome.types import ConfigType
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
"""The GATT backend plus the platform BLE stack it registers with; the
union arm serves tooling that resolves the manifest without a target
platform (the bluetooth_proxy pattern)."""
if CORE.is_esp32:
return ["bluetooth_connection", "esp32_ble_tracker"]
if CORE.target_platform == PLATFORM_RP2:
return ["bluetooth_connection", "rp2040_ble"]
return ["bluetooth_connection", "esp32_ble_tracker", "rp2040_ble"]
AUTO_LOAD = ["bluetooth_connection"]
radon_eye_rd200_ns = cg.esphome_ns.namespace("radon_eye_rd200")
RadonEyeRD200 = radon_eye_rd200_ns.class_("RadonEyeRD200", cg.PollingComponent)
@@ -39,47 +24,22 @@ _SENSOR_SCHEMA = sensor.sensor_schema(
state_class=STATE_CLASS_MEASUREMENT,
)
@functools.lru_cache(maxsize=None)
def _schema_for_platform(platform: str) -> cv.Schema | cv.All:
"""Built per platform (cached): the backend id's class and the esp32
controller-slot consumption depend on the target."""
schema = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(RadonEyeRD200),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_RADON): _SENSOR_SCHEMA,
cv.Optional(CONF_RADON_LONG_TERM): _SENSOR_SCHEMA,
}
)
.extend(cv.polling_component_schema("5min"))
.extend(bluetooth_connection.gatt_client_schema())
)
if platform == PLATFORM_ESP32:
from esphome.components import esp32_ble
return cv.All(
schema, esp32_ble.consume_connection_slots(1, "radon_eye_rd200")
)
return schema
def _platform_schema(config: ConfigType) -> ConfigType:
return _schema_for_platform(CORE.target_platform)(config)
CONFIG_SCHEMA = cv.All(
cv.only_on([PLATFORM_ESP32, PLATFORM_RP2]),
_platform_schema,
CONFIG_SCHEMA = bluetooth_connection.gatt_client_config_schema(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(RadonEyeRD200),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_RADON): _SENSOR_SCHEMA,
cv.Optional(CONF_RADON_LONG_TERM): _SENSOR_SCHEMA,
}
).extend(cv.polling_component_schema("5min")),
"radon_eye_rd200",
)
async def to_code(config: ConfigType) -> None:
backend = await bluetooth_connection.new_gatt_backend(config)
var = cg.new_Pvariable(
config[CONF_ID], backend, config[CONF_MAC_ADDRESS].as_hex
)
var = cg.new_Pvariable(config[CONF_ID], backend, config[CONF_MAC_ADDRESS].as_hex)
await cg.register_component(var, config)
if (radon := config.get(CONF_RADON)) is not None:
@@ -14,6 +14,17 @@ import voluptuous as vol
from esphome import config_validation as cv
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP32
from esphome.core import CORE
def _esp32_schema_keys() -> dict[str, object]:
# The builder resolves the backend schema through the platform-dispatched
# bluetooth_connection.gatt_client_schema(), so the platform must be set
# (conftest's autouse reset restores CORE after each test).
CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_ESP32
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),
@@ -38,7 +49,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 +62,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()
@@ -178,11 +178,21 @@ def test_rp2_rejects_esp32_only_keys_by_name(
def test_bluetooth_connection_auto_load_covers_its_includes() -> None:
# Every backend builds on ble_device_base alone; the Bluedroid backend
# talks to IDF directly, so esp32_ble_client is no longer in the closure.
for platform in ("esp32", "rp2", None):
_set_platform(platform)
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"]
# The backend registers with its platform BLE stack, so that dependency
# lives here and consumers (proxy, radon_eye_rd200) stay platform-blind;
# the platform-less arm is the union for manifest-resolving tooling.
_set_platform("esp32")
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"]
_set_platform(None)
assert bluetooth_connection.AUTO_LOAD() == [
"ble_device_base",
"esp32_ble_tracker",
"rp2040_ble",
]
def test_every_registered_hub_platform_has_a_schema_arm() -> None: