[bluetooth_proxy] Add an advertisement filter hook (#19220)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
David Coulson
2026-09-13 16:25:27 +00:00
committed by GitHub
co-authored by Claude Opus 5 pre-commit-ci-lite[bot]
parent eea66fc32b
commit 6b08aa60e6
6 changed files with 82 additions and 0 deletions
@@ -395,6 +395,17 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
await _connections_to_code(var, config)
def enable_advertisement_filter() -> None:
"""Compile the advertisement filter hook into bluetooth_proxy.
Called by external filtering components from to_code(). The define behind
this is an implementation detail; do not emit it directly.
Public API for external components. Do not remove.
"""
cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER")
async def to_code(config: ConfigType) -> None:
if CORE.is_esp32:
await _to_code_esp32(config)
@@ -94,6 +94,15 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
// Ask the filter before the packet is queued, so a dropped advertisement never
// reaches the batch or the network.
if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) {
ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address);
return;
}
#endif
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
adv.address = raw.address;
adv.rssi = raw.rssi;
@@ -184,6 +193,9 @@ void BluetoothProxy::dump_config() {
" Adapter MAC: %s",
scan_mode, mac_out);
#endif
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set()));
#endif
}
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
@@ -97,6 +97,29 @@ static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x000011223344556
static_assert(PendingReply{}.empty());
#endif
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
/// Predicate slot letting an external component drop advertisements before they
/// are queued for the API. Same shape as
/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot
/// path, so it must be cheap and must not block.
///
/// Usage:
/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
/// return static_cast<MyFilter *>(self)->should_forward(adv);
/// }});
///
/// Returning false drops the advertisement. Not called at all while the API is
/// disconnected, which matters to a stateful filter. Compiled in only when an
/// external component calls bluetooth_proxy.enable_advertisement_filter().
struct AdvertisementFilter {
void *instance{nullptr};
bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr};
/// A default-constructed slot is "no filter"; the proxy guards on this.
bool is_set() const { return this->fn != nullptr; }
bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); }
};
#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
class BluetoothProxy final : public Component {
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Allow the connection to update connections_free_response_
@@ -162,6 +185,11 @@ class BluetoothProxy final : public Component {
void set_active(bool active) { this->active_ = active; }
bool has_active() { return this->active_; }
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
/// One subscriber; a later call replaces an earlier one.
void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; }
#endif
uint32_t get_legacy_version() const {
if (!this->active_) {
return LEGACY_PASSIVE_ONLY_VERSION;
@@ -330,6 +358,10 @@ class BluetoothProxy final : public Component {
// start on an even word, closing two alignment holes.
uint32_t last_advertisement_flush_time_{0};
#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
AdvertisementFilter advertisement_filter_{};
#endif
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
+2
View File
@@ -329,6 +329,8 @@
#else
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0
#endif
// Defined here so static analysis parses the slot and its call site.
#define USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
#endif
@@ -0,0 +1,13 @@
"""The codegen hook external filtering components use to turn on the filter slot."""
from esphome.components import bluetooth_proxy
from esphome.core import CORE
def test_enable_advertisement_filter_emits_define() -> None:
"""External components call this rather than emitting the define."""
bluetooth_proxy.enable_advertisement_filter()
assert "USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" in {
define.name for define in CORE.defines
}
@@ -0,0 +1,12 @@
# Compile the gated filter path; no external component is in-tree to call
# enable_advertisement_filter(), so the define is forced here.
<<: !include common.yaml
esphome:
build_flags:
- "-DUSE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER"
esp32_ble_tracker:
bluetooth_proxy:
active: true