diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 5052e7eca1..53e319e369 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -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 ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index f24d261c57..d6b815fc2e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -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; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index b913bb9a55..f79669dc32 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -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; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 82d9ae7db4..783a8c466b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -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(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(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}; }; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index af52a25ec0..6b49b28cd6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -40,7 +40,7 @@ static_assert(static_cast(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(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(self)->send_bluetooth_scanner_state_(state); + static_cast(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) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d3e3144831..725429df24 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -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(static_cast(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(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 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 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 };