Address review: fan-out guard, refusal warning, sighting-less connect hint

- The connected fan-out stops if a node tears the link down mid-loop
  (latent until a backend settles a teardown synchronously)
- A synchronously refused disconnect logs its own warning - a backend/
  client state divergence is no longer the most quietly logged outcome
- An action-initiated connect before any sighting warns that the public
  address type is assumed (an opaque repeated failure becomes
  self-diagnosing); the connect() doc no longer mentions a config key
  that does not exist
- The explicit-connections ledger test asserts the exact charged count;
  the host-test override comment names the current gate macro
This commit is contained in:
J. Nick Koston
2026-08-09 10:43:21 -05:00
parent 073d9a72a0
commit fa17215053
4 changed files with 23 additions and 8 deletions
@@ -42,6 +42,7 @@ bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
return false;
// The sighting is the source of truth for the address type.
this->address_type_ = device.get_address_type();
this->address_type_known_ = true;
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
return true;
if (this->hold_off_ms_ != 0 && millis() - this->hold_off_start_ < this->hold_off_ms_)
@@ -56,6 +57,11 @@ void BLEClient::connect() {
// An absent peer can inhibit scanning for the backend's full connect
// timeout, so this is worth a breadcrumb - but it is a supported action.
ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_);
if (!this->address_type_known_) {
// Legacy parity: without a sighting the address type defaults to
// public, which never matches a random-static peer.
ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_);
}
this->attempt_connect_();
}
@@ -81,9 +87,10 @@ void BLEClient::disconnect() {
this->cancel_requested_ = true;
int err = this->backend_->gatt_disconnect();
if (err != 0) {
// Refused synchronously (states diverged; the backend is already down):
// settle through the normal path, carrying the real code so the log is
// distinguishable from a clean teardown.
// Refused synchronously: the backend and the client disagreed about the
// link state. Worth a warning of its own - the settle below then runs
// the same deliberate-cancel path as a clean teardown.
ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err);
this->on_connection_state(false, 0, err);
}
}
@@ -149,6 +156,9 @@ void BLEClient::on_service_discovery_done(int error) {
auto table = this->backend_->get_service_table();
for (auto *node : this->nodes_) {
node->on_connected(table);
if (this->state_ != State::CONNECTED) {
return; // A node tore the link down mid-fan-out; the teardown settled.
}
}
this->backend_->release_services();
this->consecutive_failures_ = 0;
@@ -73,8 +73,8 @@ class BLEClient : public Component,
bool connected() const { return this->state_ == State::CONNECTED; }
bool idle() const { return this->state_ == State::IDLE; }
/// Action-initiated connect (no sighting needed; uses the last captured or
/// configured address type). No-op unless idle.
/// Action-initiated connect (no sighting needed; uses the last captured
/// address type, public until a sighting arrives). No-op unless idle.
void connect();
void disconnect();
@@ -147,6 +147,8 @@ class BLEClient : public Component,
// Group 6: 1-byte types
State state_{State::IDLE};
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
// Distinguishes a captured public type from the never-sighted default.
bool address_type_known_{false};
bool auto_connect_{true};
// A user-initiated teardown in flight; its failure report is not a
// connect failure and must not feed the backoff.
@@ -186,7 +186,9 @@ def test_esp32_explicit_connections_claim_gatt_slots(
# generated ones; dev historically let them evade the budget.
set_core_config(PlatformFramework.ESP32_IDF)
bluetooth_proxy.CONFIG_SCHEMA({"active": True, "connections": [{}, {}]})
assert "bluetooth_proxy" in bluetooth_connection._ledger().consumers
# Exact match (one entry per slot): catches a missed charge and a
# double charge alike.
assert bluetooth_connection._ledger().consumers == ["bluetooth_proxy"] * 2
def test_hub_source_filter_covers_every_hub_platform() -> None:
@@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT;
# emit the backend define so the host build exercises it.
# close_service_batch compiles only under BLUETOOTH_CONNECTION_SERVES_PROXY
# (= USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY); emit both so the host
# build exercises it.
async def to_code_testing(config):
# These defines are global to the merged host test binary; safe
# because no co-compiled test observes them.