[bluetooth_proxy] Retry dropped services-done, disconnect and scanner-state notifications (#18225)

This commit is contained in:
J. Nick Koston
2026-08-11 14:20:39 +12:00
committed by GitHub
parent c2ba2fbfc4
commit eab9a47aa2
6 changed files with 262 additions and 58 deletions
@@ -82,6 +82,16 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
// send_service_ cursor states; >= 0 is the next service index to stream.
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed
// Every sentinel must stay below the >= 0 streaming gate and clear of
// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused.
static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0);
static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED &&
SERVICES_DONE_PENDING != GATT_NOT_CONNECTED);
// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done
// delivered near the client's 30 s timeout could land on a fresh request's
// empty accumulator and cache as an empty database.
static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30;
// ---- Service-streaming size budget, shared by every platform's streamer ----
@@ -407,20 +407,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
return;
}
if (conn.send_service_ >= this->service_total_) {
conn.send_service_ = DONE_SENDING_SERVICES;
conn.proxy_->send_gatt_services_done(conn.address_);
this->release_services();
conn.send_services_done_();
return;
}
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (a resubscribing client gets silence and its 30 s
// timeout, never an authoritative partial list).
// The subscriber vanished mid-stream.
auto *api_conn = conn.proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_);
conn.send_service_ = DONE_SENDING_SERVICES;
this->release_services();
conn.park_service_stream_();
return;
}
@@ -299,25 +299,39 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval,
// ---- Service streaming ----
void BluetoothConnection::send_services_done_() {
if (this->proxy_->send_gatt_services_done(this->address_)) {
// Sent, or subscriber gone (park silently; its timeout arbitrates).
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (this->send_service_ != SERVICES_DONE_PENDING) {
// Warn on the transition only; retries stay silent.
ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_);
this->services_done_retries_ = 0;
this->send_service_ = SERVICES_DONE_PENDING;
} else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) {
// Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates.
ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_);
this->send_service_ = DONE_SENDING_SERVICES;
}
}
void BluetoothConnection::send_service_for_discovery_() {
auto table = this->backend_->get_service_table();
if (this->send_service_ >= table.service_count) {
this->send_service_ = DONE_SENDING_SERVICES;
this->proxy_->send_gatt_services_done(this->address_);
this->backend_->release_services();
this->send_services_done_();
return;
}
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (a resubscribing client gets silence and its 30 s
// timeout, never an authoritative partial list) and free the table; the
// api-gone sweep tears the connection down anyway.
// The subscriber vanished mid-stream; the api-gone sweep tears the
// connection down anyway.
auto *api_conn = this->proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_,
this->address_str_);
this->send_service_ = DONE_SENDING_SERVICES;
this->backend_->release_services();
this->park_service_stream_();
return;
}
@@ -126,7 +126,23 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
this->send_service_for_discovery_();
}
}
/// Park the stream without services-done and free any held table: an
/// interrupted stream must never be declared complete (the client's
/// timeout arbitrates), and an owed done is dropped with it.
void park_service_stream_() {
if (this->send_service_ >= 0) {
this->backend_->release_services();
this->send_service_ = DONE_SENDING_SERVICES;
} else if (this->send_service_ == SERVICES_DONE_PENDING) {
this->send_service_ = DONE_SENDING_SERVICES;
}
}
void send_service_for_discovery_();
/// Send services-done and settle the cursor: DONE when it lands (or no
/// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain
/// retries). Callers release the table first; the message needs only the
/// address.
void send_services_done_();
void reset_connection_(conn_err_t reason);
conn_err_t check_connected_op_(const char *action, const char *type) const;
void log_gatt_operation_error_(const char *operation, uint16_t handle, int status);
@@ -151,10 +167,14 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
static_assert(static_cast<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
static_assert(static_cast<uint8_t>(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2),
"connection_type_ bitfield too narrow");
// Ordered so neither byte's fields straddle a storage unit: 3+5 and
// 4+2+1+1 fill the two tail bytes exactly.
ClientState state_ : 3 {ClientState::IDLE};
bool paired_ : 1 {false};
ConnectionType connection_type_ : 2 {ConnectionType::V1};
static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow");
uint8_t services_done_retries_ : 5 {0};
uint8_t connection_index_ : 4 {0};
ConnectionType connection_type_ : 2 {ConnectionType::V1};
bool paired_ : 1 {false};
bool services_discovered_ : 1 {false};
};
@@ -40,7 +40,7 @@ static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPED) ==
bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) {
if (this->api_connection_ == nullptr)
return false;
return true; // Nobody subscribed: nothing owed
api::BluetoothScannerStateResponse resp;
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
@@ -51,7 +51,12 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState
return this->api_connection_->send_message(resp);
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) {
// False only on a refused frame, so the latch arms only when a retry is owed.
this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state);
}
#else
void BluetoothProxy::send_polled_scanner_state_() {
// One read feeds both the frame and the change detector; the detector only
// advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a
@@ -62,7 +67,7 @@ void BluetoothProxy::send_polled_scanner_state_() {
this->last_scan_running_ = running;
}
}
#endif // !USE_BLE_SCANNER_STATE_CALLBACK
#endif // USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::setup() {
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
@@ -78,7 +83,7 @@ void BluetoothProxy::setup() {
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Only push hubs compile the slot; elsewhere loop() polls scan_running().
this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) {
static_cast<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state);
static_cast<BluetoothProxy *>(self)->send_scanner_state_(state);
}});
#endif
}
@@ -190,8 +195,48 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v
ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value);
}
void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) {
// Match before free entry so one address never occupies two pool slots.
PendingDisconnect *free_entry = nullptr;
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto &owed = this->pending_disconnections_[i];
if (owed.matches(address)) {
owed.set(address, error);
return;
}
if (free_entry == nullptr && owed.empty()) {
free_entry = &owed;
}
}
if (free_entry != nullptr) {
free_entry->set(address, error);
return;
}
// Every entry is owed: evict the first so the newest loss is not silent too.
ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full",
(unsigned long long) this->pending_disconnections_[0].address());
this->pending_disconnections_[0].set(address, error);
}
void BluetoothProxy::clear_pending_disconnection_(uint64_t address) {
// A reconnect supersedes the owed disconnect; a late resend would shadow
// the new connection.
for (uint8_t i = 0; i < this->connection_count_; i++) {
if (this->pending_disconnections_[i].matches(address)) {
this->pending_disconnections_[i].clear();
}
}
}
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
this->send_device_connection(connection->get_address(), false, 0, reason);
if (!this->send_device_connection(connection->get_address(), false, 0, reason)) {
// The client has no other way to learn of an unsolicited disconnect;
// latch and let loop()'s paced drain deliver it. V by design: a louder
// level would ride the same congested link this reports on.
ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(),
connection->address_str());
this->latch_pending_disconnection_(connection->get_address(), reason);
}
connection->set_address(0);
connection->send_service_ = INIT_SENDING_SERVICES;
this->send_connections_free();
@@ -206,14 +251,20 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese
auto *connection = this->connections_[i];
uint64_t conn_addr = connection->get_address();
if (conn_addr == address)
if (conn_addr == address) {
// A connect request supersedes an owed disconnect.
if (reserve) {
this->clear_pending_disconnection_(address);
}
return connection;
}
if (free_slot == nullptr && conn_addr == 0)
free_slot = connection;
}
if (!reserve || free_slot == nullptr)
return nullptr;
this->clear_pending_disconnection_(address);
free_slot->send_service_ = INIT_SENDING_SERVICES;
free_slot->set_address(address);
// All connections must start at INIT
@@ -387,7 +438,30 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer
}
if (!connection->has_gatt_services()) {
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str());
this->send_gatt_services_done(msg.address);
// Through the retrying sender: a drop must not leave discovery hanging.
// Re-entry does not depend on the cursor - this branch is gated on
// has_gatt_services() alone, so no restore is needed.
connection->send_services_done_();
return;
}
if (connection->send_service_ > 0) {
// A request mid-stream restarts from the top so the requester always
// gets the full list. No duplicate risk: the client accumulates batches
// per request, and a same-session re-request only happens after the
// previous request timed out and discarded its partial list.
ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(),
connection->address_str());
connection->send_service_ = 0;
return;
}
if (connection->send_service_ == SERVICES_DONE_PENDING) {
// A new request supersedes an owed done: the client accumulates batches
// per request, so its fresh, empty accumulator plus a bare done would
// cache as an empty database. The table is freed; the client's timeout
// arbitrates.
ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry",
connection->get_connection_index(), connection->address_str());
connection->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet
@@ -515,7 +589,27 @@ void BluetoothProxy::loop() {
return;
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
// Paced retries of owed per-slot notifications; subscriber swaps clear
// stale latches before this runs.
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->send_service_ == SERVICES_DONE_PENDING) {
connection->send_services_done_();
}
auto &owed = this->pending_disconnections_[i];
if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) {
owed.clear();
}
}
#endif
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Resend a dropped scanner-state push (see scanner_state_pending_).
if (this->scanner_state_pending_) {
this->send_scanner_state_(this->hub_->get_scanner_state());
}
#else
// This hub doesn't push scanner-state transitions; poll and report on
// change. A hub gaining push emits the define and drops this poll.
if (this->hub_->scan_running() != this->last_scan_running_) {
@@ -601,24 +695,35 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
#endif // !BLUETOOTH_CONNECTION_HAS_GATT
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
// connection from a client that dropped without a clean disconnect and has
// not yet hit the keepalive timeout; rejecting the new subscriber would
// silently starve it of advertisements until it reconnects, so the newest
// subscriber wins instead.
char old_peername[socket::SOCKADDR_STR_LEN];
char new_peername[socket::SOCKADDR_STR_LEN];
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
this->api_connection_->get_peername_to(old_peername));
if (api_connection != this->api_connection_) {
if (this->api_connection_ != nullptr) {
// A previous subscriber still holds the slot. This is almost always a
// stale connection from a client that dropped without a clean disconnect
// and has not yet hit the keepalive timeout; rejecting the new
// subscriber would silently starve it of advertisements until it
// reconnects, so the newest subscriber wins instead.
char old_peername[socket::SOCKADDR_STR_LEN];
char new_peername[socket::SOCKADDR_STR_LEN];
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
this->api_connection_->get_peername_to(old_peername));
}
// Stale retry latches belong to the previous subscriber's session; a
// re-subscribe by the current one keeps what it is still owed.
this->connections_free_pending_ = false;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
for (uint8_t i = 0; i < this->connection_count_; i++) {
// Neither a partial stream's tail nor an owed done belongs to the new
// session; silence (the client's timeout) arbitrates.
this->connections_[i]->park_service_stream_();
}
this->pending_disconnections_.fill({});
#endif
}
// A stale retry latch belongs to the previous subscriber's session.
this->connections_free_pending_ = false;
this->api_connection_ = api_connection;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// get_scanner_state() is part of the push-hub surface (see BLEHubContract).
this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state());
this->send_scanner_state_(this->hub_->get_scanner_state());
#else
this->send_polled_scanner_state_();
#endif
@@ -631,20 +736,11 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
}
this->api_connection_ = nullptr;
this->connections_free_pending_ = false;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
this->scanner_state_pending_ = false;
#endif
}
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceConnectionResponse call;
call.address = address;
call.connected = connected;
call.mtu = mtu;
call.error = error;
// Fire and forget: a drop is covered by the client's own timeouts and the
// retried connections-free state.
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_connections_free() {
if (this->api_connection_ != nullptr) {
this->send_connections_free(this->api_connection_);
@@ -661,12 +757,23 @@ void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) {
}
}
void BluetoothProxy::send_gatt_services_done(uint64_t address) {
bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
return true; // Nobody subscribed: nothing owed
api::BluetoothDeviceConnectionResponse call;
call.address = address;
call.connected = connected;
call.mtu = mtu;
call.error = error;
return this->api_connection_->send_message(call);
}
bool BluetoothProxy::send_gatt_services_done(uint64_t address) {
if (this->api_connection_ == nullptr)
return true; // Nobody subscribed: nothing is owed, only a refused frame reports false
api::BluetoothGATTGetServicesDoneResponse call;
call.address = address;
this->api_connection_->send_message(call);
return this->api_connection_->send_message(call);
}
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
@@ -24,7 +24,9 @@ namespace esphome::bluetooth_proxy {
using bluetooth_connection::CONN_OK;
using bluetooth_connection::conn_err_t;
using bluetooth_connection::GATT_NOT_CONNECTED;
using bluetooth_connection::DONE_SENDING_SERVICES;
using bluetooth_connection::INIT_SENDING_SERVICES;
using bluetooth_connection::SERVICES_DONE_PENDING;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
@@ -57,6 +59,43 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
/// One owed freed-slot connected=false notification in a single word: the
/// 48-bit address in the low bits, the sign-extending 16-bit reason on top.
/// Every reason that reaches the pool (esp_gatt_status_t,
/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t.
class PendingDisconnect {
public:
constexpr void set(uint64_t address, conn_err_t error) {
// Mask: the address originates from the client, and a stray high bit
// must not corrupt the reason.
this->word_ = (address & ADDRESS_MASK) | (static_cast<uint64_t>(static_cast<uint16_t>(error)) << 48);
}
constexpr void clear() { this->word_ = 0; }
// Whole-word test: set() is only ever given a live (nonzero) address.
constexpr bool empty() const { return this->word_ == 0; }
// Masked like set(), so a stray high bit cannot defeat the pool lookups.
constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); }
constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; }
constexpr conn_err_t error() const { return static_cast<int16_t>(this->word_ >> 48); }
private:
static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL;
uint64_t word_{0};
};
// Pin the packing at compile time: mask and sign round-trip for every
// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit).
constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
PendingDisconnect p;
p.set(address, error);
return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address);
}
static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
static_assert(PendingDisconnect{}.empty());
#endif
class BluetoothProxy final : public Component {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
// Allow the connection to update connections_free_response_
@@ -97,10 +136,14 @@ class BluetoothProxy final : public Component {
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
}
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
/// False only when a subscriber refused the frame; true = delivered or
/// nobody subscribed. Request-answer callers ignore the result (client
/// timeouts cover those); only reset_connection_slot_ latches for retry.
bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
void send_connections_free();
void send_connections_free(api::APIConnection *api_connection);
void send_gatt_services_done(uint64_t address);
/// Same convention as send_device_connection: false only on a refused frame.
bool send_gatt_services_done(uint64_t address);
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK);
void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK);
@@ -172,7 +215,9 @@ class BluetoothProxy final : public Component {
protected:
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void send_scanner_state_(ble_device_base::ScannerState state);
#else
void send_polled_scanner_state_();
#endif
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
@@ -231,6 +276,10 @@ class BluetoothProxy final : public Component {
/// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service
/// discovery and retry, rather than being told a partial list is complete.
void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason);
/// Drop any owed freed-slot notification for this address (client reconnected).
void clear_pending_disconnection_(uint64_t address);
/// Pool a refused freed-slot notification for the paced drain.
void latch_pending_disconnection_(uint64_t address, conn_err_t error);
#endif
// Memory optimized layout for 32-bit systems
@@ -240,6 +289,10 @@ class BluetoothProxy final : public Component {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
// Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
// Address-keyed pool of owed freed-slot notifications; loop() resends.
// Proxy-only state, kept off BluetoothConnection; entries are not tied to
// slot indices.
std::array<PendingDisconnect, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
#endif
ble_device_base::BLEHub *hub_{nullptr};
// Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below
@@ -260,7 +313,11 @@ class BluetoothProxy final : public Component {
bool connections_free_pending_{false};
uint8_t connection_count_{0};
bool configured_scan_active_{false}; // Configured scan mode from YAML
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// A dropped push (full TX buffer) is re-queried from the hub and resent
// from loop(); the hub's current state is idempotent by construction.
bool scanner_state_pending_{false};
#else
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif
};