diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index d5089ae7e5..77031ea253 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index c7bda55047..70c3ff4617 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,11 +16,13 @@ #include #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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 4e8a2f17db..0cffb0dabf 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -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: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index f7ee9b5403..94f2483c38 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -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(); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index db110706fa..6b1b146745 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 433f034f05..e6f81e1f0c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -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, } // namespace esphome::bluetooth_connection -#endif // USE_BLE_GATT_CLIENT +#endif // USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index 5aad76ea21..83ab0885d6 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -2,6 +2,7 @@ #ifdef USE_BLE_GATT_CLIENT +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -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; diff --git a/esphome/components/radon_eye_rd200/sensor.py b/esphome/components/radon_eye_rd200/sensor.py index 67a22634b9..2f019184f3 100644 --- a/esphome/components/radon_eye_rd200/sensor.py +++ b/esphome/components/radon_eye_rd200/sensor.py @@ -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: diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 32e5daf4bb..0054b2226c 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -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() diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 8310bd6ded..ec059bf588 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -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: