mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[bluetooth_proxy] Platform-neutral advertisement proxy via ble_device_base (#17880)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
J. Nick Koston
parent
17f3c9d780
commit
563b6ade0f
@@ -1,14 +1,50 @@
|
||||
import functools
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
from esphome.components import ble_device_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ACTIVE, CONF_ID
|
||||
from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2
|
||||
from esphome.core import CORE
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"]
|
||||
DEPENDENCIES = ["api", "esp32"]
|
||||
# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is
|
||||
# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing
|
||||
# those modules registers esp32-only automations (ble.enable, ble.disable, ...)
|
||||
# as a side effect, and a module-scope import would leak them into every
|
||||
# platform's registry the moment a config declares `bluetooth_proxy:` —
|
||||
# degrading "Unable to find action" config errors into C++ compile failures.
|
||||
|
||||
|
||||
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
|
||||
"""Components to auto-load for the platform being compiled.
|
||||
|
||||
Callable with no argument so tooling that resolves AUTO_LOAD without a
|
||||
target platform (the device-builder catalog sync does exactly this) gets
|
||||
the union of every arm instead of an empty list — which is what lets it
|
||||
keep cross-referencing the esp32 BLE stack. A real build always has a
|
||||
target platform set, so it takes one of the concrete branches.
|
||||
"""
|
||||
if CORE.is_esp32:
|
||||
return ["esp32_ble_client", "esp32_ble_tracker"]
|
||||
if CORE.target_platform in _HUB_PLATFORMS:
|
||||
return ["ble_device_base"]
|
||||
# No target platform, or one this component does not support: tooling
|
||||
# resolving the manifest (including the host-pinned dependency resolver) —
|
||||
# expose every arm so the closure keeps the esp32 BLE stack.
|
||||
return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"]
|
||||
|
||||
|
||||
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
|
||||
# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT
|
||||
# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home
|
||||
# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
|
||||
# proxy would be misdriven — bk72xx follows once the API carries a feature
|
||||
# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs).
|
||||
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
|
||||
|
||||
DEPENDENCIES = ["api"]
|
||||
CODEOWNERS = ["@jesserockz", "@bdraco"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -20,21 +56,37 @@ DEFAULT_CONNECTION_SLOTS = 3
|
||||
|
||||
bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
|
||||
|
||||
BluetoothProxy = bluetooth_proxy_ns.class_(
|
||||
"BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
|
||||
)
|
||||
BluetoothConnection = bluetooth_proxy_ns.class_(
|
||||
"BluetoothConnection", esp32_ble_client.BLEClientBase
|
||||
)
|
||||
BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
|
||||
|
||||
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
|
||||
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable
|
||||
# CONFIG_SCHEMA below can state the connection_slots range without importing the
|
||||
# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together.
|
||||
_IDF_MAX_CONNECTIONS = 9
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _esp32_config_schema() -> cv.All:
|
||||
"""Build the esp32 schema, importing the esp32 BLE stack only when used."""
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
|
||||
if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS:
|
||||
raise cv.Invalid(
|
||||
f"bluetooth_proxy's connection-slot limit mirror "
|
||||
f"({_IDF_MAX_CONNECTIONS}) is out of sync with "
|
||||
f"esp32_ble.IDF_MAX_CONNECTIONS ({esp32_ble.IDF_MAX_CONNECTIONS}); "
|
||||
f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py"
|
||||
)
|
||||
|
||||
BluetoothConnection = bluetooth_proxy_ns.class_(
|
||||
"BluetoothConnection", esp32_ble_client.BLEClientBase
|
||||
)
|
||||
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BluetoothConnection),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def validate_connections(config):
|
||||
def validate_connections(config):
|
||||
if CONF_CONNECTIONS in config:
|
||||
if not config[CONF_ACTIVE]:
|
||||
raise cv.Invalid(
|
||||
@@ -42,20 +94,23 @@ def validate_connections(config):
|
||||
)
|
||||
elif config[CONF_ACTIVE]:
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||||
config
|
||||
)
|
||||
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
|
||||
CONF_CONNECTIONS: [
|
||||
CONNECTION_SCHEMA({}) for _ in range(connection_slots)
|
||||
],
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
return cv.All(
|
||||
(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BluetoothProxy),
|
||||
**_COMMON_SCHEMA_KEYS,
|
||||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean,
|
||||
cv.Optional(
|
||||
@@ -75,10 +130,135 @@ CONFIG_SCHEMA = cv.All(
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
),
|
||||
validate_connections,
|
||||
)
|
||||
|
||||
|
||||
def _validate_no_active(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_ACTIVE]:
|
||||
raise cv.Invalid(
|
||||
"Active connections are not supported on this platform; the proxy "
|
||||
"forwards advertisements only (set active: false)"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
|
||||
# callback feeds the same API batching. GATT/active connections are excluded at
|
||||
# compile time — only the esp32 build compiles the connection stack; nothing
|
||||
# reads HubCapabilities::gatt at runtime for this today.
|
||||
# Keys both platform schemas must declare identically; each arm spreads this
|
||||
# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays
|
||||
# per-arm: its default differs (esp32 True, hub arms False — no GATT).
|
||||
_COMMON_SCHEMA_KEYS = {
|
||||
cv.GenerateID(): cv.declare_id(BluetoothProxy),
|
||||
}
|
||||
|
||||
_BLE_HUB_CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
**_COMMON_SCHEMA_KEYS,
|
||||
# Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator
|
||||
# after a strict schema rejects an explicit `ble_hub_id` before it
|
||||
# runs, and that key is the documented way to disambiguate once a
|
||||
# platform has two trackers.
|
||||
cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id(
|
||||
ble_device_base.BLEHub
|
||||
),
|
||||
cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
_validate_no_active,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
@schema_extractor("schema")
|
||||
def _validate_platform(config: ConfigType) -> ConfigType:
|
||||
"""Apply the schema for the platform actually being compiled.
|
||||
|
||||
esp32 keeps the full GATT proxy; every other platform gets the
|
||||
advertisement-only shape, which rejects the connection-oriented options
|
||||
above because its schema does not define them.
|
||||
"""
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# The language-schema dumper runs without a platform. Expose the esp32
|
||||
# shape so `connections`, the ids and every default stay in the
|
||||
# generated schema the editor and dashboard consume.
|
||||
return _esp32_config_schema()
|
||||
if CORE.is_esp32:
|
||||
return _esp32_config_schema()(config)
|
||||
if CORE.target_platform not in _HUB_PLATFORMS:
|
||||
# Fail here with the actual reason. Without this gate the error surfaces
|
||||
# later as an unresolvable hub ID ("Are you missing a hub declaration?")
|
||||
# on platforms where no hub component can be declared.
|
||||
raise cv.Invalid(
|
||||
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
|
||||
"active-scan-capable BLE tracker hub is available for this "
|
||||
"platform. It runs on esp32 (full proxy), and the ln882x and rp2 "
|
||||
"families (advertisement-only)."
|
||||
)
|
||||
return _BLE_HUB_CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
|
||||
"""Reject connection-oriented options by name on hub-only platforms.
|
||||
|
||||
Runs before the walkable schema below so the user gets "this option does
|
||||
not exist here" instead of the option's esp32 value range (which would
|
||||
imply a smaller number is accepted).
|
||||
"""
|
||||
if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None:
|
||||
return config
|
||||
if CORE.target_platform not in _HUB_PLATFORMS:
|
||||
# No proxy of any kind exists here: fall through so _validate_platform
|
||||
# reports "not supported on {platform}" instead of a key-level message
|
||||
# implying an advertisement-only proxy is available.
|
||||
return config
|
||||
for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS):
|
||||
if key in config:
|
||||
raise cv.Invalid(
|
||||
f"'{key}' requires active connection support, which needs the "
|
||||
"esp32 GATT stack; this platform runs the advertisement-only "
|
||||
"proxy and has no such option",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# CONFIG_SCHEMA stays a statically walkable schema: tooling (the dashboard's
|
||||
# field-range extractor among others) introspects it to discover options and
|
||||
# their bounds, which a bare dispatch function would hide. It carries the scalar
|
||||
# keys with no defaults; _validate_platform then runs the real per-platform
|
||||
# schema, which applies the defaults and rejects options the platform does not
|
||||
# support.
|
||||
#
|
||||
# It deliberately does NOT declare `connections`: this outer schema runs before
|
||||
# the per-platform one, so any key it transforms is transformed twice. Running
|
||||
# CONNECTION_SCHEMA twice re-validates an already-generated ID through
|
||||
# declare_id(), which (unlike use_id) has no guard for an ID instance and
|
||||
# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched
|
||||
# for _ESP32_CONFIG_SCHEMA to validate exactly once.
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
_reject_connection_keys_off_esp32,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ACTIVE): cv.boolean,
|
||||
cv.Optional(CONF_CACHE_SERVICES): cv.boolean,
|
||||
cv.Optional(CONF_CONNECTION_SLOTS): cv.All(
|
||||
cv.positive_int,
|
||||
cv.Range(min=1, max=_IDF_MAX_CONNECTIONS),
|
||||
),
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
),
|
||||
_validate_platform,
|
||||
)
|
||||
|
||||
|
||||
async def _to_code_esp32(config: ConfigType) -> None:
|
||||
from esphome.components import esp32_ble, esp32_ble_tracker
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
|
||||
# Register the loggers this component needs
|
||||
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP)
|
||||
|
||||
@@ -93,12 +273,6 @@ async def to_code(config):
|
||||
connection_count = len(config.get(CONF_CONNECTIONS, []))
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
|
||||
|
||||
# Define batch size for BLE advertisements
|
||||
# Each advertisement is up to 80 bytes when packaged (including protocol overhead)
|
||||
# 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
|
||||
# This achieves ~97% WiFi MTU utilization while staying under the limit
|
||||
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
|
||||
|
||||
for connection_conf in config.get(CONF_CONNECTIONS, []):
|
||||
connection_var = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
await cg.register_component(connection_var, connection_conf)
|
||||
@@ -108,4 +282,30 @@ async def to_code(config):
|
||||
if config.get(CONF_CACHE_SERVICES):
|
||||
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
|
||||
|
||||
|
||||
async def _to_code_ble_hub(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_active(config[CONF_ACTIVE]))
|
||||
hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID])
|
||||
cg.add(var.set_ble_hub(hub))
|
||||
|
||||
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
|
||||
# this define whenever a proxy is present; no connections off-esp32.
|
||||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if CORE.is_esp32:
|
||||
await _to_code_esp32(config)
|
||||
else:
|
||||
await _to_code_ble_hub(config)
|
||||
|
||||
# Define batch size for BLE advertisements
|
||||
# Each advertisement is up to 80 bytes when packaged (including protocol overhead)
|
||||
# 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
|
||||
# This achieves ~97% WiFi MTU utilization while staying under the limit
|
||||
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
|
||||
|
||||
cg.add_define("USE_BLUETOOTH_PROXY")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "bluetooth_proxy.h"
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/macros.h"
|
||||
#include "esphome/core/application.h"
|
||||
@@ -8,8 +11,6 @@
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::bluetooth_proxy {
|
||||
|
||||
static const char *const TAG = "bluetooth_proxy";
|
||||
@@ -23,6 +24,8 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62
|
||||
|
||||
BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
void BluetoothProxy::setup() {
|
||||
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
|
||||
@@ -48,6 +51,62 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#else // !USE_ESP32
|
||||
|
||||
void BluetoothProxy::setup() {
|
||||
this->connections_free_response_.limit = 0;
|
||||
this->connections_free_response_.free = 0;
|
||||
|
||||
// Capture the configured scan mode from YAML before any API changes
|
||||
this->configured_scan_active_ = this->hub_->scan_active();
|
||||
this->last_scan_running_ = this->hub_->scan_running();
|
||||
|
||||
// The hub delivers raw advertisements on the ESPHome main loop:
|
||||
// mac is least-significant octet first (BLE controller convention).
|
||||
this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
|
||||
static_cast<BluetoothProxy *>(self)->on_raw_advertisement_(adv);
|
||||
}});
|
||||
}
|
||||
|
||||
void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) {
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
|
||||
return;
|
||||
|
||||
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
|
||||
// raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends.
|
||||
adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac);
|
||||
adv.rssi = raw.rssi;
|
||||
adv.address_type = raw.addr_type;
|
||||
uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast<uint8_t>(raw.data_len);
|
||||
adv.data_len = length;
|
||||
std::memcpy(adv.data, raw.data, length);
|
||||
|
||||
this->response_.advertisements_len++;
|
||||
|
||||
ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4],
|
||||
raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi);
|
||||
|
||||
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
|
||||
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_bluetooth_scanner_state_() {
|
||||
api::BluetoothScannerStateResponse resp;
|
||||
resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING
|
||||
: api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE;
|
||||
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
|
||||
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
|
||||
resp.configured_mode = this->configured_scan_active_
|
||||
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
|
||||
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_ESP32
|
||||
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
|
||||
connection->address_str(), espbt::client_state_to_string(state));
|
||||
@@ -56,6 +115,7 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
|
||||
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
|
||||
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
|
||||
}
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
|
||||
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
|
||||
@@ -67,6 +127,8 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl
|
||||
this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED);
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
// This method should never be called since bluetooth_proxy always uses raw advertisements
|
||||
@@ -107,18 +169,38 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results,
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::log_advertisement_flush_() {
|
||||
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
|
||||
}
|
||||
|
||||
void BluetoothProxy::dump_config() {
|
||||
#ifdef USE_ESP32
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Bluetooth Proxy:\n"
|
||||
" Active: %s\n"
|
||||
" Connections: %d",
|
||||
YESNO(this->active_), this->connection_count_);
|
||||
#else
|
||||
// Advertisement-only: print configured facts. dump_config runs right after
|
||||
// setup, before the radio is up, so live scan state would always read
|
||||
// "stopped" here — the loop's BluetoothScannerStateResponse carries the
|
||||
// changing value instead.
|
||||
char mac_str[18];
|
||||
this->get_bluetooth_mac_address_pretty(mac_str);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Bluetooth Proxy:\n"
|
||||
" Mode: advertisement-only (no GATT connections)\n"
|
||||
" Configured scan: %s\n"
|
||||
" Adapter MAC: %s",
|
||||
this->configured_scan_active_ ? "active" : "passive",
|
||||
mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)");
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
// Run advertisement flush / connection cleanup every 100ms
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
@@ -252,13 +334,8 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
esp_bd_addr_t address;
|
||||
uint64_to_bd_addr(msg.address, address);
|
||||
esp_err_t ret = esp_ble_gattc_cache_clean(address);
|
||||
api::BluetoothDeviceClearCacheResponse call;
|
||||
call.address = msg.address;
|
||||
call.success = ret == ESP_OK;
|
||||
call.error = ret;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
|
||||
// Shares the sender with the neutral path, which also null-checks api_connection_.
|
||||
this->send_device_clear_cache(msg.address, ret == ESP_OK, ret);
|
||||
break;
|
||||
}
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
|
||||
@@ -376,6 +453,120 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
if (this->parent_->get_scan_active() == active) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
|
||||
this->parent_->set_scan_active(active);
|
||||
this->parent_->stop_scan();
|
||||
this->parent_->set_scan_continuous(
|
||||
true); // Set this to true to automatically start scanning again when it has cleaned up.
|
||||
}
|
||||
|
||||
#else // !USE_ESP32
|
||||
|
||||
// Advertisement-only proxy. GATT client connections are excluded at compile
|
||||
// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults
|
||||
// HubCapabilities at runtime today — so every connection-oriented request is
|
||||
// answered with a clean error instead of silence, and Home Assistant treats
|
||||
// the proxy as passive.
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
// Run advertisement flush / scanner-state poll every 100ms
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_advertisement_flush_time_ < 100)
|
||||
return;
|
||||
this->last_advertisement_flush_time_ = now;
|
||||
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
|
||||
return;
|
||||
|
||||
// The hub has no scanner-state listener interface; poll and report on change.
|
||||
bool running = this->hub_->scan_running();
|
||||
if (running != this->last_scan_running_) {
|
||||
this->last_scan_running_ = running;
|
||||
this->send_bluetooth_scanner_state_();
|
||||
}
|
||||
|
||||
this->flush_pending_advertisements_();
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
|
||||
switch (msg.request_type) {
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
|
||||
ESP_LOGW(TAG, "Active connections are not supported on this platform");
|
||||
this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
|
||||
// Not an error: the device is already disconnected, which is the requested state.
|
||||
this->send_device_connection(msg.address, false);
|
||||
this->send_connections_free();
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
|
||||
this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR:
|
||||
this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE:
|
||||
this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {
|
||||
this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic");
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothSetConnectionParamsResponse resp;
|
||||
resp.address = msg.address;
|
||||
resp.error = ESP_GATT_NOT_CONNECTED;
|
||||
this->api_connection_->send_message(resp);
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
if (this->hub_->scan_active() != active) {
|
||||
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
|
||||
if (!this->hub_->request_scan_mode(active)) {
|
||||
// Passive-only controller asked for active scanning; the state report
|
||||
// below carries the real, unchanged mode so the subscriber does not
|
||||
// assume the change happened.
|
||||
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
|
||||
}
|
||||
}
|
||||
if (this->api_connection_ != nullptr) {
|
||||
this->send_bluetooth_scanner_state_();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
|
||||
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
|
||||
// A previous subscriber still holds the slot. This is almost always a stale
|
||||
@@ -390,9 +581,13 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
|
||||
this->api_connection_->get_peername_to(old_peername));
|
||||
}
|
||||
this->api_connection_ = api_connection;
|
||||
#ifdef USE_ESP32
|
||||
this->parent_->recalculate_advertisement_parser_types();
|
||||
|
||||
this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state());
|
||||
#else
|
||||
this->last_scan_running_ = this->hub_->scan_running();
|
||||
this->send_bluetooth_scanner_state_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connection) {
|
||||
@@ -401,10 +596,12 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
|
||||
return;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
#ifdef USE_ESP32
|
||||
this->parent_->recalculate_advertisement_parser_types();
|
||||
#endif
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, esp_err_t error) {
|
||||
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothDeviceConnectionResponse call;
|
||||
@@ -432,7 +629,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) {
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) {
|
||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothGATTErrorResponse call;
|
||||
@@ -442,7 +639,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) {
|
||||
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothDevicePairingResponse call;
|
||||
@@ -453,7 +650,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) {
|
||||
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
api::BluetoothDeviceUnpairingResponse call;
|
||||
@@ -464,19 +661,21 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
|
||||
if (this->parent_->get_scan_active() == active) {
|
||||
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
|
||||
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
|
||||
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) {
|
||||
if (this->api_connection_ == nullptr)
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
|
||||
this->parent_->set_scan_active(active);
|
||||
this->parent_->stop_scan();
|
||||
this->parent_->set_scan_continuous(
|
||||
true); // Set this to true to automatically start scanning again when it has cleaned up.
|
||||
api::BluetoothDeviceClearCacheResponse call;
|
||||
call.address = address;
|
||||
call.success = success;
|
||||
call.error = error;
|
||||
|
||||
this->api_connection_->send_message(call);
|
||||
}
|
||||
|
||||
BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::bluetooth_proxy
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_BLUETOOTH_PROXY
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
@@ -8,11 +10,12 @@
|
||||
|
||||
#include "esphome/components/api/api_connection.h"
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
@@ -20,14 +23,31 @@
|
||||
#include <esp_bt.h>
|
||||
#endif
|
||||
#include <esp_bt_device.h>
|
||||
#else
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#endif // USE_ESP32
|
||||
|
||||
namespace esphome::bluetooth_proxy {
|
||||
|
||||
static constexpr esp_err_t ESP_GATT_NOT_CONNECTED = -1;
|
||||
// Proxy-owned error type for the API error fields, which are plain integers on
|
||||
// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls);
|
||||
// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps
|
||||
// the header independent of how a hub platform's SDK spells its error type.
|
||||
#ifdef USE_ESP32
|
||||
using proxy_err_t = esp_err_t;
|
||||
static constexpr proxy_err_t PROXY_OK = ESP_OK;
|
||||
#else
|
||||
using proxy_err_t = int;
|
||||
static constexpr proxy_err_t PROXY_OK = 0;
|
||||
#endif
|
||||
|
||||
static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = -1;
|
||||
static constexpr int DONE_SENDING_SERVICES = -2;
|
||||
static constexpr int INIT_SENDING_SERVICES = -3;
|
||||
|
||||
#ifdef USE_ESP32
|
||||
using namespace esp32_ble_client;
|
||||
#endif
|
||||
|
||||
// Legacy versions:
|
||||
// Version 1: Initial version without active connections
|
||||
@@ -53,21 +73,28 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
|
||||
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
|
||||
};
|
||||
|
||||
#ifdef USE_ESP32
|
||||
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
public esp32_ble_tracker::BLEScannerStateListener,
|
||||
public Component {
|
||||
friend class BluetoothConnection; // Allow connection to update connections_free_response_
|
||||
#else
|
||||
class BluetoothProxy final : public Component {
|
||||
#endif
|
||||
public:
|
||||
BluetoothProxy();
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP32_BLE_DEVICE
|
||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
|
||||
#endif
|
||||
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
|
||||
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
|
||||
#endif // USE_ESP32
|
||||
void dump_config() override;
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
|
||||
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
|
||||
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
|
||||
@@ -78,6 +105,14 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
|
||||
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
|
||||
// snapshots scan_active()/scan_running() and installs the raw callback, and
|
||||
// the BLEHub contract does not promise those are settled any earlier than
|
||||
// the hub's own setup().
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
|
||||
#endif // USE_ESP32
|
||||
|
||||
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
|
||||
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
|
||||
@@ -92,17 +127,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
void unsubscribe_api_connection(api::APIConnection *api_connection);
|
||||
api::APIConnection *get_api_connection() { return this->api_connection_; }
|
||||
|
||||
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK);
|
||||
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_OK);
|
||||
void send_connections_free();
|
||||
void send_connections_free(api::APIConnection *api_connection);
|
||||
void send_gatt_services_done(uint64_t address);
|
||||
void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error);
|
||||
void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK);
|
||||
void send_device_unpairing(uint64_t address, bool success, esp_err_t error = ESP_OK);
|
||||
void send_device_clear_cache(uint64_t address, bool success, esp_err_t error = ESP_OK);
|
||||
void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error);
|
||||
void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK);
|
||||
void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
|
||||
void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
|
||||
|
||||
void bluetooth_scanner_set_mode(bool active);
|
||||
|
||||
#ifdef USE_ESP32
|
||||
static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) {
|
||||
bd_addr[0] = (address >> 40) & 0xff;
|
||||
bd_addr[1] = (address >> 32) & 0xff;
|
||||
@@ -111,12 +147,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
bd_addr[4] = (address >> 8) & 0xff;
|
||||
bd_addr[5] = (address >> 0) & 0xff;
|
||||
}
|
||||
#endif
|
||||
|
||||
void set_active(bool active) { this->active_ = active; }
|
||||
bool has_active() { return this->active_; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// BLEScannerStateListener interface
|
||||
void on_scanner_state(esp32_ble_tracker::ScannerState state) override;
|
||||
#endif
|
||||
|
||||
uint32_t get_legacy_version() const {
|
||||
if (this->active_) {
|
||||
@@ -129,7 +168,17 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
uint32_t flags = 0;
|
||||
flags |= BluetoothProxyFeature::FEATURE_PASSIVE_SCAN;
|
||||
flags |= BluetoothProxyFeature::FEATURE_RAW_ADVERTISEMENTS;
|
||||
#ifdef USE_ESP32
|
||||
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
|
||||
#else
|
||||
// Advertise mode switching only where the hub honors request_scan_mode();
|
||||
// scan_mode_switch is the capability bit for exactly that (#18079) —
|
||||
// active_scan alone is not enough, a hub may support active scanning yet
|
||||
// refuse the runtime switch.
|
||||
if (this->hub_->get_capabilities().scan_mode_switch) {
|
||||
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
|
||||
}
|
||||
#endif
|
||||
if (this->active_) {
|
||||
flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS;
|
||||
flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING;
|
||||
@@ -142,16 +191,37 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
}
|
||||
|
||||
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
|
||||
#ifdef USE_ESP32
|
||||
const uint8_t *mac = esp_bt_dev_get_address();
|
||||
if (mac != nullptr) {
|
||||
format_mac_addr_upper(mac, output.data());
|
||||
} else {
|
||||
output[0] = '\0';
|
||||
}
|
||||
#else
|
||||
uint8_t mac[6] = {};
|
||||
this->hub_->get_adapter_mac(mac);
|
||||
// Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs
|
||||
// (rp2040's BTstack) only learn the address once the link layer is up, and
|
||||
// report all-zero until then.
|
||||
bool nonzero = false;
|
||||
for (uint8_t b : mac)
|
||||
nonzero |= b != 0;
|
||||
if (nonzero) {
|
||||
format_mac_addr_upper(mac, output.data());
|
||||
} else {
|
||||
output[0] = '\0';
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
protected:
|
||||
#ifdef USE_ESP32
|
||||
void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state);
|
||||
#else
|
||||
void send_bluetooth_scanner_state_();
|
||||
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
|
||||
#endif
|
||||
|
||||
/// Caller must ensure api_connection_ is non-null and API server is connected.
|
||||
void flush_pending_advertisements_() {
|
||||
@@ -165,9 +235,11 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
}
|
||||
void log_advertisement_flush_();
|
||||
|
||||
#ifdef USE_ESP32
|
||||
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
|
||||
void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state);
|
||||
void log_connection_info_(BluetoothConnection *connection, const char *message);
|
||||
#endif
|
||||
void log_not_connected_gatt_(const char *action, const char *type);
|
||||
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
|
||||
|
||||
@@ -175,8 +247,12 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
// Group 1: Pointers (4 bytes each, naturally aligned)
|
||||
api::APIConnection *api_connection_{nullptr};
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// Group 2: Fixed-size array of connection pointers
|
||||
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
|
||||
#else
|
||||
ble_device_base::BLEHub *hub_{nullptr};
|
||||
#endif
|
||||
|
||||
// BLE advertisement batching
|
||||
api::BluetoothLERawAdvertisementsResponse response_;
|
||||
@@ -191,11 +267,13 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
bool active_;
|
||||
uint8_t connection_count_{0};
|
||||
bool configured_scan_active_{false}; // Configured scan mode from YAML
|
||||
// 3 bytes used, 1 byte padding
|
||||
#ifndef USE_ESP32
|
||||
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
|
||||
#endif
|
||||
};
|
||||
|
||||
extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::bluetooth_proxy
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_BLUETOOTH_PROXY
|
||||
|
||||
+21
-3
@@ -242,6 +242,27 @@
|
||||
#define USE_NATIVE_64BIT_TIME
|
||||
#endif
|
||||
|
||||
// bluetooth_proxy runs on any platform with a BLE hub (advertisement-only off
|
||||
// esp32). Declared here per analysis ENVIRONMENT, not per hub platform —
|
||||
// USE_LIBRETINY also covers chips with no hub, e.g. rtl87xx (the authoritative
|
||||
// gate is _HUB_PLATFORMS in bluetooth_proxy/__init__.py) — so the neutral
|
||||
// declarations in bluetooth_proxy.h are parsed under LibreTiny static analysis
|
||||
// (the header is included by api_connection.cpp, which the tidy filter selects;
|
||||
// the proxy's own .cpp is not a selected translation unit). Not declared for
|
||||
// platforms whose API/network types the proxy header cannot assume.
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2)
|
||||
#define USE_BLUETOOTH_PROXY
|
||||
// Mirror the codegen values per platform: _to_code_esp32() emits the connection
|
||||
// count (default 3), _to_code_ble_hub() emits 0 — so static analysis checks the
|
||||
// same std::array<uint64_t, N> instantiation a real build produces.
|
||||
#ifdef USE_ESP32
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
|
||||
#else
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0
|
||||
#endif
|
||||
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
|
||||
#endif
|
||||
|
||||
// ESP32-specific feature flags
|
||||
#ifdef USE_ESP32
|
||||
#define USE_ESP32_CRASH_HANDLER
|
||||
@@ -264,9 +285,6 @@
|
||||
#define USE_ESPNOW
|
||||
#define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470
|
||||
|
||||
#define USE_BLUETOOTH_PROXY
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
|
||||
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
|
||||
#define USE_CAPTIVE_PORTAL
|
||||
#define USE_WIFI_SCAN_RESULTS_LOCK
|
||||
#define USE_ESP32_BLE
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together.
|
||||
|
||||
The mirror exists so the statically walkable CONFIG_SCHEMA can express the
|
||||
connection_slots range without importing the esp32 BLE stack (that import
|
||||
registers esp32-only automations on every platform). The runtime check in
|
||||
_esp32_config_schema() only fires while validating an esp32 config, so this
|
||||
test is what actually catches drift when the upstream constant changes.
|
||||
"""
|
||||
|
||||
from esphome.components import esp32_ble
|
||||
from esphome.components.bluetooth_proxy import _IDF_MAX_CONNECTIONS
|
||||
|
||||
|
||||
def test_mirror_matches_esp32_ble() -> None:
|
||||
assert _IDF_MAX_CONNECTIONS == esp32_ble.IDF_MAX_CONNECTIONS, (
|
||||
"bluetooth_proxy._IDF_MAX_CONNECTIONS is out of sync with "
|
||||
"esp32_ble.IDF_MAX_CONNECTIONS; update the mirror in "
|
||||
"esphome/components/bluetooth_proxy/__init__.py"
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""The outer CONFIG_SCHEMA re-declares the esp32 scalar keys so tooling can walk
|
||||
them without importing the esp32 BLE stack; pin the two declarations together.
|
||||
|
||||
The outer schema carries no defaults (the per-platform schema applies them), so
|
||||
drift cannot surface in validation output — a key renamed or re-bounded in
|
||||
_esp32_config_schema() but not here would silently vanish from the dashboard's
|
||||
field extractor. This test is what catches that.
|
||||
"""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _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
|
||||
# exactly once — see the comment above CONFIG_SCHEMA).
|
||||
_NOT_MIRRORED = {str(key.schema) for key in cv.COMPONENT_SCHEMA.schema} | {
|
||||
"connections"
|
||||
}
|
||||
|
||||
|
||||
def _schema_of(validator: cv.All) -> vol.Schema:
|
||||
"""The vol.Schema stage of a cv.All chain, found by type rather than by
|
||||
position so reordering the chain cannot silently break these tests."""
|
||||
schemas = [v for v in validator.validators if isinstance(v, vol.Schema)]
|
||||
assert len(schemas) == 1, f"expected exactly one vol.Schema stage, got {schemas}"
|
||||
return schemas[0]
|
||||
|
||||
|
||||
def _keys(schema: vol.Schema) -> dict[str, object]:
|
||||
return {str(key.schema): key for key in schema.schema}
|
||||
|
||||
|
||||
def test_outer_scalar_keys_exist_in_esp32_schema() -> None:
|
||||
outer = _keys(_schema_of(CONFIG_SCHEMA))
|
||||
esp32 = _keys(_schema_of(_esp32_config_schema()))
|
||||
missing = set(outer) - set(esp32)
|
||||
assert not missing, (
|
||||
f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema "
|
||||
"does not; update one of them in "
|
||||
"esphome/components/bluetooth_proxy/__init__.py"
|
||||
)
|
||||
|
||||
|
||||
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()))
|
||||
scalar = {
|
||||
name
|
||||
for name, key in esp32.items()
|
||||
if isinstance(key, vol.Optional)
|
||||
and not isinstance(key, cv.GenerateID)
|
||||
and name not in _NOT_MIRRORED
|
||||
}
|
||||
missing = scalar - set(outer)
|
||||
assert not missing, (
|
||||
f"esp32 scalar options {sorted(missing)} are missing from the outer "
|
||||
"CONFIG_SCHEMA and invisible to schema tooling; update "
|
||||
"esphome/components/bluetooth_proxy/__init__.py"
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
# Advertisement-only proxy on the ln882x BLE hub (active-scan-capable, in-tree
|
||||
# since #16691) — a target CI fully compiles. Same bare-hub arrangement as
|
||||
# test.rp2040-ard.yaml: no explicit ble_hub_id so a grouped build cannot
|
||||
# collide with ln882h_ble_tracker's own fixture id.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
ln882h_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,13 @@
|
||||
# Advertisement-only proxy on the rp2 BLE hub — the one non-esp32 platform the
|
||||
# proxy admits today (active-scan-capable), and a target CI fully compiles.
|
||||
# No explicit ble_hub_id: the generated binding resolves the single declared
|
||||
# hub, and an inline id here would collide with rp2_ble_tracker's own fixture
|
||||
# once CI merges both components into one grouped rp2040-ard build (grouped
|
||||
# component dicts collapse; only one id survives). The explicit-key form is
|
||||
# covered by validate.rp2040-ard.yaml, which never participates in grouping.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,13 @@
|
||||
# Connections given as a bare list, with no explicit per-entry id. The ids are
|
||||
# generated during validation, so this config breaks if the schema validates the
|
||||
# connections list more than once.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
connections:
|
||||
- {}
|
||||
- {}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Explicit ble_hub_id on the rp2 hub — the documented disambiguator once a
|
||||
# platform has more than one tracker. Validate-only: never merged into grouped
|
||||
# builds, so the inline id cannot collide with rp2_ble_tracker's own fixture.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
id: ble_hub
|
||||
|
||||
bluetooth_proxy:
|
||||
ble_hub_id: ble_hub
|
||||
Reference in New Issue
Block a user