diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp new file mode 100644 index 00000000000..bd4e51d9b76 --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -0,0 +1,119 @@ +// Every SDK call the scan reconciler makes. The BDK's own start hardcodes +// passive (the active bit is commented out in both stacks), so +// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field +// the SDK's app_ble_start_scaning() except that prop takes the mode, armed +// through the SDK's own operation bookkeeping. The component pins +// beken-bdk 3.0.78; the static asserts catch a layout change on a bump. + +#include "bdk_scan.h" + +#ifdef USE_BK72XX_BLE + +// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") + +extern "C" { +#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, + // app_ble_actv_state_get, app_ble_env_state_get, + // app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX, + // bk_ble_* (via ble_api_5_x.h) +#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send +#if __has_include("gapm_msg.h") +#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_* +#else +#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name +#endif +} + +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +// Pin the SDK surface this file depends on: a beken-bdk bump that moves these +// must fail the build, not corrupt the kernel message. +static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) && + sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4, + "beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() " + "against the SDK's app_ble_start_scaning()"); +static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX, + "beken-bdk activity sentinel changed; revalidate the scan reconciler"); +static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 && + GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5), + "beken-bdk GAPM report info changed; revalidate the tracker's demux constants"); + +bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; } + +BdkActivityState bdk_scan_state(uint8_t activity_idx) { + if (activity_idx == INVALID_ACTIVITY_IDX) + return BdkActivityState::IDLE; + switch (app_ble_actv_state_get(activity_idx)) { + case ACTV_IDLE: + return BdkActivityState::IDLE; + case ACTV_SCAN_CREATED: + return BdkActivityState::CREATED; + case ACTV_SCAN_STARTED: + return BdkActivityState::STARTED; + default: + return BdkActivityState::OTHER; + } +} + +uint8_t bdk_scan_acquire_activity() { + uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); + if (idx == INVALID_ACTIVITY_IDX) + ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); + return idx; +} + +BdkOpResult bdk_scan_create(uint8_t activity_idx) { + ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + if (ret == ERR_BLE_STATUS) + return BdkOpResult::BUSY; + ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast(ret)); + return BdkOpResult::FAILED; +} + +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) { + app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr); + struct gapm_activity_start_cmd *cmd = + KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd); + if (cmd == nullptr) { + app_ble_reset(); // the SDK's own failure path for an unsent operation + ESP_LOGE(TAG, "Scan start failed: kernel message allocation"); + return BdkOpResult::FAILED; + } + cmd->operation = GAPM_START_ACTIVITY; + cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx; + cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER; + cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0); + cmd->u_param.scan_param.scan_param_1m.scan_intv = interval; + cmd->u_param.scan_param.scan_param_1m.scan_wd = window; + cmd->u_param.scan_param.scan_param_coded.scan_intv = 0; + cmd->u_param.scan_param.scan_param_coded.scan_wd = 0; + cmd->u_param.scan_param.dup_filt_pol = 0; + cmd->u_param.scan_param.rsvd = 0; + cmd->u_param.scan_param.duration = 0; // scan until stopped + cmd->u_param.scan_param.period = 10; // matches the SDK's passive start + kernel_msg_send(cmd); + return BdkOpResult::OK; +} + +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { + ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr); + *err_out = static_cast(ret); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + // DEBUG on purpose: the reconciler WARNs once per streak and the stuck + // ERROR carries this code — a per-retry ERROR would be unbounded. + ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast(ret)); + return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED; +} + +} // namespace esphome::bk72xx_ble + +#endif // !CLANG_TIDY && ble_api.h +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bdk_scan.h b/esphome/components/bk72xx_ble/bdk_scan.h new file mode 100644 index 00000000000..47bce2449d5 --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include + +namespace esphome::bk72xx_ble { + +/// Activity index value marking "no scan activity", the BDK's own convention +/// (asserted against its symbol in bdk_scan.cpp). +inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF; + +/// Scan-relevant controller activity states, read live from the SDK. +enum class BdkActivityState : uint8_t { + IDLE, ///< No activity (or one whose create failed). + CREATED, ///< Created but not started. + STARTED, ///< Scanning. + OTHER, ///< A non-scan or transitional state; settles on a later read. +}; + +/// Outcome of a BDK scan operation request. +enum class BdkOpResult : uint8_t { + OK, ///< Accepted; completion is asynchronous. + BUSY, ///< Another controller operation is in flight; retry later. + FAILED, ///< Rejected. +}; + +/// True when no controller operation is in flight (APP_BLE_READY). +bool bdk_scan_ready(); +/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE. +BdkActivityState bdk_scan_state(uint8_t activity_idx); +/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free. +uint8_t bdk_scan_acquire_activity(); +/// Create the scan activity (asynchronous); started once CREATED is observed. +BdkOpResult bdk_scan_create(uint8_t activity_idx); +/// Start a created activity: the packed GAPM start, taking the scan mode the +/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the +/// kernel message could not be allocated (the armed SDK operation is rolled +/// back). +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active); +/// Release the activity: delete when never started (a stop would be +/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED +/// on any other error; err_out receives the SDK code (0 on success). +/// Teardown is asynchronous — observe IDLE to confirm. +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out); + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 69c7df0b96f..954cb9fe87b 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -5,7 +5,8 @@ // talks to the Beken BDK BLE stack: // - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), // - the controller BLE address, -// - the raw controller scan primitives (bk_ble_scan_start/stop), +// - the scan reconciler (request, pacing, bring-up budget) over the +// bdk_scan surface, // - the scan-report ring: the BDK notice callback (BLE task) takes a report // from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains, // dispatches on the main task and returns reports to the pool — the same @@ -20,10 +21,13 @@ #include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE +#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release) + #ifdef USE_BK72XX_BLE #include +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" // get_mac_address_raw() #include "esphome/core/log.h" @@ -57,9 +61,8 @@ // are C headers consumed from C++ (a standard C-header-from-C++ pattern). // --------------------------------------------------------------------------- extern "C" { -#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb, - // app_ble_get_idle_actv_idx_handle, struct scan_param, - // recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV +#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t, + // BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp) #ifdef BK72XX_BLE_HAS_COMMON_BDADDR #include "common_bt_defines.h" // struct bd_addr // The controller's public BLE address, populated by the BDK during ble_entry(). @@ -76,6 +79,12 @@ namespace esphome::bk72xx_ble { static const char *const TAG = "bk72xx_ble"; +static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops +static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release +static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED +static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence +static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED) + // The BDK notice callback is a plain C function pointer with no user argument, // so it reaches the (single) component instance through a file-static pointer. static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -95,12 +104,12 @@ static void ble_notice_callback(ble_notice_t notice, void *param) { const recv_adv_t *info = reinterpret_cast(param); // rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for // a signed dBm value packed in a uint8_t). - s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, info->data, - info->data_len); + s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, + static_cast(info->evt_type), info->data, info->data_len); } -void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint16_t data_len) { +void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, + const uint8_t *data, uint16_t data_len) { BLEScanReport *report = this->report_pool_.allocate(); if (report == nullptr) { // Pool exhausted — the queue is full; count and drop. @@ -110,6 +119,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add memcpy(report->mac, mac, 6); report->rssi = rssi; report->addr_type = addr_type; + report->evt_type = evt_type; report->data_len = (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); memcpy(report->data, data, report->data_len); @@ -123,6 +133,9 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add void BK72xxBLE::setup() { s_ble = this; + // The report pool grows lazily on purpose: the BDK notice callback runs in + // task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic + // stays far below the pool cap, so not warming contains RAM. // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before // the stack is up (it is re-read once ble_entry() has run). this->resolve_mac_(); @@ -173,6 +186,30 @@ void BK72xxBLE::enable() { } void BK72xxBLE::loop() { + // Keep reconciling toward the requested scan state (e.g. complete a stop + // that arrived while a controller operation was in flight), and re-check a + // settled scan at low frequency: a controller-side drop re-enters the + // bring-up, and the budget's FAILED feeds the tracker's recovery. + // Keep driving until settled: any PENDING, plus a terminal stop whose slot + // must still be freed. A FAILED scan request is the one combination not + // re-driven here — that belongs to the tracker's backoff. + const uint32_t pump_now = App.get_loop_component_start_time(); + if (this->last_result_ == ScanOpResult::PENDING || + (!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) { + const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED) + ? RECONCILE_REJECTED_RETRY_MS + : RECONCILE_RETRY_MS; + if (pump_now - this->last_advance_ms_ >= gate) + this->advance_(); + } else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED && + pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) { + // Re-check a settled scan; scan_start() refills the bring-up budget. + // WARN: the only report of a drop that recovers inside its budget. + if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) != + ScanOpResult::SETTLED) + ESP_LOGW(TAG, "Controller dropped the scan; restarting"); + } + // Drain the lock-free ring filled by the BLE task; all per-report work runs // here on the main task, then the report returns to the pool. BLEScanReport *report = this->report_queue_.pop(); @@ -248,44 +285,228 @@ void BK72xxBLE::resolve_mac_() { } // --------------------------------------------------------------------------- -// Controller scan primitives +// Scan reconciler // --------------------------------------------------------------------------- -bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) { +// Episode boundary: fresh teardown deadline and error bookkeeping. +void BK72xxBLE::reset_teardown_episode_() { + this->teardown_since_ms_ = 0; + this->restarting_ = false; + this->last_release_err_ = 0; +} + +ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) this->enable(); - if (this->scan_actv_idx_ != 0xFF) { - // Already scanning — stop first so this call cleanly restarts with the new - // parameters (the BDK cannot start a second scan on a busy activity). - this->scan_stop(); + const ScanParams params{active, interval, window}; + // A new episode refills the budget and gets a fresh teardown deadline; a + // re-call observing an in-flight bring-up (last result PENDING) must not. + if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) { + this->pending_since_ms_ = App.get_loop_component_start_time(); + this->reset_teardown_episode_(); } + this->scan_wanted_ = true; + this->requested_ = params; + return this->advance_(); +} - struct scan_param sp; - memset(&sp, 0, sizeof(sp)); - sp.channel_map = 7; // advertising channels 37/38/39 - sp.interval = interval; - sp.window = window; +void BK72xxBLE::scan_stop() { + if (this->scan_wanted_) { + // A stamp inherited from a stuck restart would fail the stop on its + // first advance. + this->reset_teardown_episode_(); + } + this->scan_wanted_ = false; + this->advance_(); +} - this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); - if (this->scan_actv_idx_ == 0xFF) { - ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); +bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) { + // millis() on both sides: the loop clock is frozen while this blocks. + const uint32_t start = millis(); + while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) { + if (millis() - start >= timeout_ms) + return false; + delay(RECONCILE_RETRY_MS); + this->advance_(); + } + return this->last_result_ == ScanOpResult::SETTLED; +} + +// Teardown is asynchronous: the handle is kept until an IDLE observation +// confirms the radio is idle. A rejection WARNs once per failure streak and +// widens the pump gate; the epilogue owns the stuck-teardown deadline. +void BK72xxBLE::release_activity_(BdkActivityState state) { + const BdkOpResult result = + bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_); + if (result == BdkOpResult::OK) { + this->release_warned_ = false; + return; + } + if (!this->release_warned_) { + // A hard error carries its code immediately; the 30 s stuck ERROR follows + // if it persists. + if (result == BdkOpResult::FAILED) { + ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_); + } else { + ESP_LOGW(TAG, "Scan activity release rejected; retrying"); + } + this->release_warned_ = true; + } +} + +// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged +// each interval) and report stuck. +bool BK72xxBLE::teardown_stuck_(uint32_t now) { + if (this->teardown_since_ms_ == 0) { + this->teardown_since_ms_ = now; + this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline return false; } - ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr); - if (ret != ERR_SUCCESS) { - ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast(ret)); - this->scan_actv_idx_ = 0xFF; + if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS) return false; + if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) { + if (this->last_release_err_ != 0) { + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_); + } else { + // No rejected release this episode: stuck waiting on the controller. + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)"); + } + this->teardown_stuck_log_ms_ = now; } return true; } -void BK72xxBLE::scan_stop() { - if (this->scan_actv_idx_ != 0xFF) { - bk_ble_scan_stop(this->scan_actv_idx_, nullptr); - this->scan_actv_idx_ = 0xFF; +// One SDK operation per call toward the latched request; controller state is +// read live each time (it changes on the BLE task, so nothing is mirrored). +// The epilogue owns all deadlines and episode bookkeeping. +ScanOpResult BK72xxBLE::advance_() { + if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + // Nothing to do; also keeps SDK reads off the pre-enable() path. + this->last_result_ = ScanOpResult::SETTLED; + return ScanOpResult::SETTLED; } + const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_); + const bool ready = bdk_scan_ready(); + ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready); + + const uint32_t now = App.get_loop_component_start_time(); + this->last_advance_ms_ = now; + if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) { + // Any teardown episode is over (IDLE observed with the controller + // settled, or e.g. a mode flip that settled back without ever reaching + // IDLE). An IDLE read while an operation is in flight proves nothing — + // a stop deferred there must keep its episode running. + this->reset_teardown_episode_(); + this->release_warned_ = false; + } + if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) { + // The mode-change release is observed complete; the rest is a normal + // bring-up on a fresh budget. + this->restarting_ = false; + this->pending_since_ms_ = now; + } + // Not chained to the clear above: a bring-up waiting at IDLE (create still + // in flight) must keep spending its budget. + if (result == ScanOpResult::PENDING) { + if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) { + // A downed radio spends the bring-up budget; exhausting it hands + // recovery to the tracker's backoff. + if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) { + ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start"); + result = ScanOpResult::FAILED; + } + } else { + // A teardown is pending: a stop, or a mode-change release still in + // flight (restarting_); either way the bring-up budget waits. + if (this->scan_wanted_) + this->pending_since_ms_ = now; + if (this->teardown_stuck_(now)) { + // Terminal for stop AND restart: the tracker's backoff owns recovery + // (a stop's release keeps re-driving from loop(); a restart is + // re-requested through scan_start() with a fresh deadline). + result = ScanOpResult::FAILED; + } + } + } + this->last_result_ = result; + return result; +} + +ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::IDLE && ready) { + // Fully torn down (or never created): the radio is idle. IDLE is trusted + // only when the controller is settled — mid-create the slot still reads + // IDLE, and dropping the handle then would leak the activity once the + // create lands. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::SETTLED; + } + if (!ready) { + // Acting mid-operation could delete an activity whose start lands + // afterwards, leaking the slot with the radio on; wait. + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan stop deferred (controller busy)"); + return ScanOpResult::PENDING; + } + // Settled, so CREATED unambiguously means "never started". + this->release_activity_(state); + return ScanOpResult::PENDING; // confirmed once IDLE is observed +} + +ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::STARTED) { + if (this->applied_ == this->requested_) + return ScanOpResult::SETTLED; + // Running with different mode or parameters: tear down (the SDK stop + // chain also deletes the activity) and recreate on a later advance. + if (ready) { + this->release_activity_(state); + // Invalidate so a flip back to the old params cannot SETTLE against the + // activity being deleted (interval 0 never matches a real request). + this->applied_.interval = 0; + this->restarting_ = true; + } + return ScanOpResult::PENDING; + } + if (!ready) { + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan start deferred (controller busy)"); + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::CREATED) { + // Fire-and-forget: SETTLED only once a later advance observes the scan + // running, so a rejected start is retried rather than silently dead. On + // failure the created activity is intact; keep the handle. + if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window, + this->requested_.active) != BdkOpResult::OK) + return ScanOpResult::FAILED; + this->applied_ = this->requested_; + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::OTHER) + return ScanOpResult::PENDING; // transitional; settles on a later read + + // IDLE and ready: acquire a slot and create. A kept index is deliberately + // reused: SDK delete returns the slot to idle and create requires an idle + // slot, so it equals a fresh acquire — while clearing here would orphan a + // create still in flight (the BUSY race below). + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + this->scan_activity_idx_ = bdk_scan_acquire_activity(); + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) + return ScanOpResult::FAILED; + } + switch (bdk_scan_create(this->scan_activity_idx_)) { + case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot + case BdkOpResult::OK: + return ScanOpResult::PENDING; + case BdkOpResult::FAILED: + break; + } + // Safe to clear (unlike BUSY): acquire is a pure search, so a rejected + // create leaves the slot IDLE for re-acquire. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::FAILED; } } // namespace esphome::bk72xx_ble diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 4e615af1595..7646f171613 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -11,6 +11,8 @@ #include +#include "bdk_scan.h" + namespace esphome::bk72xx_ble { enum class BLEComponentState : uint8_t { @@ -19,11 +21,32 @@ enum class BLEComponentState : uint8_t { ACTIVE, }; +/// Outcome of one reconciliation step. +enum class ScanOpResult : uint8_t { + SETTLED, ///< The request is reached: scan observed running, or stopped + ///< with the activity fully released. + PENDING, ///< A step is in flight; loop() keeps advancing — call + ///< scan_start() again to learn the outcome. + FAILED, ///< The controller rejected a step; retry later. +}; + +/// One scan request: mode plus timing, in BLE units (0.625 ms). +struct ScanParams { + bool active; + uint16_t interval; + uint16_t window; + bool operator==(const ScanParams &) const = default; +}; + /// One advertisement report from the controller. struct BLEScanReport { uint8_t mac[6]; // LSB-first, as the controller delivers it int8_t rssi; // signed dBm uint8_t addr_type; + // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type + // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the + // tracker's merger tell the two frames apart. + uint8_t evt_type; uint8_t data_len; // bytes valid in data[] uint8_t data[62]; // legacy advertisement (31) + scan response (31) @@ -69,18 +92,33 @@ class BK72xxBLE final : public Component { void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } #endif - /// Start the controller scan. Interval/window are in BLE units (0.625 ms). - /// Enables the stack first if needed. Returns false on controller failure. - bool scan_start(uint16_t interval, uint16_t window); - /// Stop the controller scan (no-op when not scanning). + /// Request a scan (interval/window in 0.625 ms BLE units); enables the + /// stack first if needed. PENDING until the scan is observed running — + /// loop() keeps advancing, call again to learn the outcome. + ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active); + /// Request the scanner stopped and the activity released; steps that + /// cannot run yet are completed from loop(). void scan_stop(); + /// Drive a requested stop until the radio is observed idle, bounded by + /// timeout_ms (for OTA). Returns false if it still has not settled. + bool flush_pending_stop(uint32_t timeout_ms); + /// Last reconciliation outcome; on FAILED the consumer's retry policy owns + /// recovery. + ScanOpResult last_scan_result() const { return this->last_result_; } /// Internal: buffer one controller report (BDK notice callback, BLE task /// context — bounded copy under the scheduler lock, nothing else). - void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len); + void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data, + uint16_t data_len); protected: void resolve_mac_(); + ScanOpResult advance_(); + ScanOpResult advance_stop_(BdkActivityState state, bool ready); + ScanOpResult advance_start_(BdkActivityState state, bool ready); + bool teardown_stuck_(uint32_t now); + void reset_teardown_episode_(); + void release_activity_(BdkActivityState state); #ifdef BK72XX_BLE_SCAN_LISTENER_COUNT // Codegen-sized: no heap allocation, no std::vector template instantiation — @@ -95,10 +133,24 @@ class BK72xxBLE final : public Component { // allocate() returns nullptr before push() can fail. This prevents leaking a // pool slot on a failed push and keeps release() off the producer path. esphome::EventPool report_pool_; - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) - uint8_t scan_actv_idx_{0xFF}; - BLEComponentState state_{BLEComponentState::STATE_OFF}; + // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. + uint32_t last_advance_ms_{0}; + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; + bool scan_wanted_{false}; // the latched request is to scan (vs stopped) + bool release_warned_{false}; // gates the release WARN; widens the pump gate + bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released bool enable_on_boot_{false}; + // PENDING means advance_() has more to do; loop() drives it, paced and + // (for a bring-up) bounded. + ScanOpResult last_result_{ScanOpResult::SETTLED}; + BLEComponentState state_{BLEComponentState::STATE_OFF}; }; } // namespace esphome::bk72xx_ble diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 7fefb310cd9..96b3536601b 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -25,6 +25,7 @@ from esphome.components.ble_device_base import automation as ble_automation from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv from esphome.const import ( + CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_ID, @@ -146,6 +147,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_BK72XX_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the BDK delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -164,6 +168,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) for conf in config.get(CONF_ON_BLE_ADVERTISE, []): await ble_automation.advertise_trigger_to_code(conf, var) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index a58561f2ded..a312d2496f9 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -9,10 +9,9 @@ #include "bk72xx_ble_tracker.h" -#include #include -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::bk72xx_ble_tracker { @@ -27,6 +26,15 @@ static const char *const TAG = "bk72xx_ble_tracker"; // a single WARN is emitted when the retry interval first saturates. static constexpr uint32_t SCAN_START_RETRY_MS = 1000; static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s +// Stable-run time before the failure streak clears; reset-on-start would keep +// a flapping controller at the 1 s gate. +static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000; + +// Radio-idle deadline for the bounded stop drain at OTA start. +static constexpr uint32_t OTA_STOP_FLUSH_MS = 100; + +// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part. +constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; } // --------------------------------------------------------------------------- // Component lifecycle @@ -36,11 +44,20 @@ void BK72xxBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // BLE task and delivers here on the main task. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; unclaimed + // devices are logged only on one-shot scans (continuous would spam). + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); #ifdef USE_OTA_STATE_LISTENER // Pause scanning while an OTA update is in flight — on the single-core BK72xx the // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. ota::get_global_ota_callback()->add_global_state_listener(this); #endif + // scan_requested_ check: an on_boot start_scan latched before this setup() + // must keep the retry loop running (rp2/ln882h parity). + if (!this->scan_continuous_ && !this->scan_requested_) { + // Nothing to time until an explicit start_scan(); it re-enables the loop. + this->disable_loop(); + } } #ifdef USE_OTA_STATE_LISTENER @@ -50,30 +67,54 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, this->scan_continuous_before_ota_ = this->scan_continuous_; this->scan_requested_before_ota_ = this->scan_requested_; this->stop_scan(); + // The transfer starves the loop; a deferred stop would leave the radio + // scanning for the whole update, so drain it here, bounded. + if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) + ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update"); } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { // On success the device reboots, so restore only on a failed/aborted update; // loop() restarts the scan on its next iteration (continuous idle branch). if (this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() parked it } // A one-shot request that was still pending (latched, retrying) when the // OTA paused scanning is re-latched, not dropped — loop() resumes the retry. if (this->scan_requested_before_ota_) { this->scan_requested_before_ota_ = false; this->scan_requested_ = true; + this->enable_loop(); } } } #endif // USE_OTA_STATE_LISTENER void BK72xxBLETracker::loop() { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); + + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); + + // Before the drop branch: a drop after a stable run starts a fresh streak. + if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS) + this->failed_start_count_ = 0; + + // A terminal failure while we report running recovers via the normal retry + // path; the drop charges the backoff so a flapping controller escalates. + if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) { + ESP_LOGW(TAG, "Controller scan lost; retrying"); + this->scan_requested_ = true; + this->count_failed_start_(); + this->mark_scan_ended_(now); + } + if (this->scan_continuous_) { if (!this->scan_running_) { - // A start that succeeded re-anchored the period timer from a later millis(), - // so the stale `now` below would underflow the comparison and fire - // on_scan_end() for a scan that just began. Resume next iteration. + // One-iteration deferral; all stamps share this iteration's cached + // timestamp, so the period check below cannot underflow. if (this->try_start_with_backoff_(now)) return; } @@ -81,11 +122,7 @@ void BK72xxBLETracker::loop() { // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan // that never came up (start kept failing) does not fire spurious on_scan_end events. if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + this->fire_scan_end_(); this->scan_period_start_ = now; } return; @@ -99,13 +136,14 @@ void BK72xxBLETracker::loop() { // would be silent: the scan never runs, stop_scan_() is never reached and // on_scan_end() never fires, leaving period-keyed consumers waiting forever. if (this->scan_requested_ && !this->scan_running_) { - // Same stale-`now` hazard as the continuous branch: start_scan_() stamps - // scan_start_time_ from a later millis(), so the duration check below would - // underflow and stop the scan in the iteration that started it. + // Same one-iteration deferral as the continuous branch. if (this->try_start_with_backoff_(now)) return; } if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + // A full-duration run proves the controller healthy even when duration is + // shorter than SCAN_STABLE_RESET_MS. + this->failed_start_count_ = 0; this->stop_scan_(); } } @@ -122,32 +160,54 @@ bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) { // even user-initiated attempts respect the backoff, so a start_scan() action // on a short cadence cannot hammer a failing controller; the attempt stays // inside the failure accounting below either way. - const uint8_t doublings = std::min(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS); - if ((!force || this->failed_start_count_ != 0) && - now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings)) + // Mid bring-up, observe instead of re-issuing (the hub self-advances). A + // SETTLED outcome completes immediately; only fresh attempts after FAILED + // are rate-limited. + const auto hub = this->parent_->last_scan_result(); + if (hub == bk72xx_ble::ScanOpResult::PENDING) return false; - this->last_scan_start_attempt_ = now; + if (hub == bk72xx_ble::ScanOpResult::FAILED) { + if (this->start_attempt_open_) { + // Our bring-up gave up asynchronously; charge it to the backoff. + this->start_attempt_open_ = false; + this->count_failed_start_(); + } + if ((!force || this->failed_start_count_ != 0) && + now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_)) + return false; + } this->start_scan_(); - if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { + if (!this->scan_running_) { + if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) { + this->start_attempt_open_ = true; + return false; // the controller is still bringing the scan up; not a failure + } + this->count_failed_start_(); + } + return this->scan_running_; +} + +void BK72xxBLETracker::count_failed_start_() { + if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { ++this->failed_start_count_; if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); } } - return this->scan_running_; } void BK72xxBLETracker::dump_config() { ESP_LOGCONFIG(TAG, "BK72xx BLE Tracker:\n" " Scan Duration: %" PRIu32 " s\n" - " Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" - " Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" - " Scan Type: PASSIVE\n" + " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Type: %s (configured %s)\n" " Continuous Scanning: %s", - this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_, - this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_)); + this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_, + ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); } // --------------------------------------------------------------------------- @@ -156,31 +216,33 @@ void BK72xxBLETracker::dump_config() { // listener dispatch run in main-loop context with no cross-task handling here. // --------------------------------------------------------------------------- -void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { - // Raw callback (the raw-advertisement path). - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), - .data = report.data, - .data_len = report.data_len, - .rssi = report.rssi, - .addr_type = report.addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } +// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type, +// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and +// 5.2 fill it from gapm_ext_adv_report_ind.info). +static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - ble_device_base::ESPBTDevice device; - device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) { - found = true; - } +// Demux advertisements vs scan responses into the shared merger: the BDK +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { + const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK; + if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } // --------------------------------------------------------------------------- @@ -207,7 +269,8 @@ void BK72xxBLETracker::start_scan() { // against a failing controller, repeated start_scan() calls are rate-limited // like any other attempt. this->scan_requested_ = true; - this->try_start_with_backoff_(millis(), /* force= */ true); + this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_() + this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true); } void BK72xxBLETracker::restart_scan_duration() { @@ -218,7 +281,7 @@ void BK72xxBLETracker::restart_scan_duration() { // start_scan action fired more often than scan_duration_ would otherwise // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN // publish) rides on that period. - this->scan_start_time_ = millis(); + this->scan_start_time_ = App.get_loop_component_start_time(); } void BK72xxBLETracker::stop_scan() { @@ -231,24 +294,31 @@ void BK72xxBLETracker::stop_scan() { // Internal scan start / stop // --------------------------------------------------------------------------- +bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() { + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + return this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); +} + void BK72xxBLETracker::start_scan_() { if (this->scan_running_) return; - if (!this->parent_->scan_start(static_cast(this->scan_interval_), - static_cast(this->scan_window_))) + if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->scan_running_ = true; this->scan_requested_ = false; // the latched one-shot request is satisfied - this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too + this->start_attempt_open_ = false; + // failed_start_count_ deliberately not reset here; only a stable run clears it (loop()). this->scan_start_time_ = now; // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and // in non-continuous mode each period is an explicit start, so asymmetric logging // would read as the scanner failing to come back up. - ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f, - this->scan_interval_ * 0.625f); + ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)", + this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_), + ble_units_to_ms(this->scan_interval_)); // Re-anchor the on_scan_end period to every successful start — first start (so the // period counts from the scan, not from boot) and every restart after a stop (so // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous @@ -258,18 +328,48 @@ void BK72xxBLETracker::start_scan_() { this->scan_started_once_ = true; } +// Deliberate logical/physical split: on_scan_end() reports the tracker's +// intent while the hub winds the radio down asynchronously; OTA is the one +// path that must wait, and it flushes explicitly. void BK72xxBLETracker::stop_scan_() { - if (!this->scan_running_) - return; - this->parent_->scan_stop(); + this->start_attempt_open_ = false; // an abandoned bring-up is not charged + this->parent_->scan_stop(); // idempotent: releases whatever the hub holds + if (this->scan_running_) { + ESP_LOGD(TAG, "Scan stopped"); + this->mark_scan_ended_(App.get_loop_component_start_time()); + } + // Park when idle (the hub drives its own teardown); re-check because an + // on_scan_end automation may have restarted the scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_) + this->disable_loop(); +} + +// The period re-anchor keeps on_scan_end from double-firing in one iteration. +void BK72xxBLETracker::mark_scan_ended_(uint32_t now) { this->scan_running_ = false; - ESP_LOGD(TAG, "Scan stopped"); -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif - this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire + this->fire_scan_end_(); + this->scan_period_start_ = now; +} + +void BK72xxBLETracker::fire_scan_end_() { + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); +} + +// true = request latched, not applied: the reconciler applies it +// asynchronously and loop() recovers a failed re-arm (ln882h parity). +bool BK72xxBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // The controller reconciler restarts a running scan itself; the scan stays + // logically running. An idle scanner picks the mode up on its next start. + if (this->scan_running_) + this->controller_scan_start_(); + return true; } } // namespace esphome::bk72xx_ble_tracker diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index cc51918da58..59d17f9b84d 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -21,6 +21,7 @@ // window: 30ms // duration: 5min // continuous: true +// active: true #pragma once @@ -29,6 +30,7 @@ #include "esphome/components/bk72xx_ble/bk72xx_ble.h" #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -69,6 +71,12 @@ class BK72xxBLETracker : public Component, void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.active); runtime mode requests change + /// only the resolved mode. + void set_scan_active(bool scan_active) { + this->scan_active_ = scan_active; + this->scan_active_configured_ = scan_active; + } /// Set from YAML (scan_parameters.continuous); also the value /// configured_continuous() reports and a bare start_scan action restores. void set_configured_continuous(bool scan_continuous) { @@ -93,27 +101,19 @@ class BK72xxBLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // The Beken BDK exposes no active-scan path (passive scanning only), so the - // controller never solicits scan responses and never merges them; consumers - // relying on scan-response fields (device names) get them only where the - // receiver merges per address (Home Assistant does). No GATT client either. - // scan_mode_switch stays false for the same reason: with no active-scan - // path there is no mode to switch to. - return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; - } - bool request_scan_mode(bool active) { - // Passive-only controller: a passive request is already honored, an active - // one cannot be. - return !active; + // Active scanning is driven through bk72xx_ble's reconciler because the BDK + // API itself is passive-only. The controller delivers scan responses as + // separate reports; this tracker merges the pair before delivery (shared + // ScanResponseMerger, Bluedroid semantics). No GATT client. + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; } + bool request_scan_mode(bool active); // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. void get_adapter_mac(uint8_t out[6]) { @@ -123,7 +123,7 @@ class BK72xxBLETracker : public Component, out[i] = mac[5 - i]; } bool scan_running() { return this->scan_running_; } - bool scan_active() { return false; } // BK72xx scan is passive-only + bool scan_active() { return this->scan_active_; } // ---- bk72xx_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the @@ -133,15 +133,20 @@ class BK72xxBLETracker : public Component, protected: void start_scan_(); void stop_scan_(); - /// Attempt a rate-limited (re)start; returns true when the scan is running, - /// which means the caller must not compare its cached millis() against the - /// timestamps start_scan_() just refreshed. force bypasses the rate gate for - /// an explicit user start only while the failure streak is clean; a failing - /// controller rate-limits forced attempts too. Failure accounting always runs. + void fire_scan_end_(); + void mark_scan_ended_(uint32_t now); + /// Stamp-and-start for every controller scan attempt, so the retry rate + /// limit covers all callers. + bk72xx_ble::ScanOpResult controller_scan_start_(); + /// Rate-limited (re)start; true when the scan is running (the caller must + /// not reuse a `now` older than the stamps this refreshed). Force and + /// backoff rules are documented at the definition. bool try_start_with_backoff_(uint32_t now, bool force = false); + void count_failed_start_(); bool scan_running_{false}; - bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once // Defaults: the BK reference — 30 % duty cycle // (interval 100 ms / window 30 ms), in 0.625 ms BLE units. uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms @@ -149,30 +154,27 @@ class BK72xxBLETracker : public Component, uint32_t scan_duration_{300000}; bool scan_continuous_{true}; bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it + bool scan_active_{true}; // resolved mode; see scan_parameters.active + bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure #endif uint32_t scan_start_time_{0}; - uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries - uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success) - uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() + uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries + uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop()) + uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end() bool scan_started_once_{false}; // true after first successful scan start; gates the period timer - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; -#endif - -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks). Merger clock: stash_adv() reads the PARENT's cached loop time + // (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this + // component's — same App.loop() pass, so the delta stays non-negative and + // the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; }; } // namespace esphome::bk72xx_ble_tracker diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index ae03003713a..4da7d488824 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -247,24 +247,23 @@ def scan_parameters_schema( interval_default: str, *, window_default: str = "30ms", - supports_active: bool = False, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when - the tracker supports active scanning; it exposes the `active` option - (whose own default is on, esp32_ble_tracker behavior). + LN882H's SDK recommends 100/50 ms). The `active` option (default on) is + unconditional: active scanning is part of the tracker contract — every + current proxy client assumes it, so a passive-only tracker must not share + this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period, cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period, cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, } - if supports_active: - schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean return cv.All(cv.Schema(schema), validate_scan_parameters) diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 8e4c710bb19..9da63710123 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -83,9 +83,9 @@ struct HubCapabilities { /// Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: - /// a passive-only controller (bk72xx) can never switch, and a hub may - /// support active scanning yet still refuse the runtime switch - /// (esp32_ble_tracker drives its mode through its own tracker API). + /// a passive-only controller can never switch, and a hub may support + /// active scanning yet still refuse the runtime switch (esp32_ble_tracker + /// drives its mode through its own tracker API). bool scan_mode_switch; }; diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 8c218c09541..ee46f85a38e 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -164,6 +164,7 @@ SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { "bluetooth_connection_hub.cpp": { PlatformFramework.RP2_ARDUINO, PlatformFramework.LN882X_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index b1c684fcc33..ffa942f27bd 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -7,6 +7,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE, CONF_ID, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, @@ -47,15 +48,13 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # 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). -# Coupled to bluetooth_connection: platforms here are also listed in its -# _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and FILTER_SOURCE_FILES -# hub entry. -_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) +# supports active scanning — every current client (aioesphomeapi, bleak-esphome, +# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only +# hub must not be admitted (it would be misdriven). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and +# FILTER_SOURCE_FILES hub entry. +_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] @@ -264,11 +263,15 @@ def _validate_platform(config: ConfigType) -> ConfigType: # 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. + full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)]) + adv_only = ", ".join( + sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS)) + ) 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 and rp2 (full proxy) and the ln882x " - "family (advertisement-only)." + f"platform. It runs on {full} (full proxy) and {adv_only} " + "(advertisement-only)." ) if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 84f43fb54bf..634b8c3bef0 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -128,9 +128,7 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "320ms", supports_active=True -) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 84437991446..4bfaa93ab7a 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -47,7 +47,7 @@ BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger # LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "100ms", window_default="50ms", supports_active=True + "100ms", window_default="50ms" ) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 99262babcec..7709df9899b 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -41,9 +41,7 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( # to_code(). `active` defaults on for esp32_ble_tracker parity; it adds scan # request TX and roughly doubles the reports through the queue, so # `active: false` is the lighter choice when scan response data is not needed. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "100ms", supports_active=True -) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") CONFIG_SCHEMA = cv.Schema( { diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml index 994855b7827..123d4296dbf 100644 --- a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -15,6 +15,7 @@ bk72xx: bk72xx_ble_tracker: scan_parameters: continuous: false + active: false on_ble_advertise: - mac_address: - AC:37:43:77:5F:4C diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py index 3a03f98adf3..777ae76b4f0 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -48,6 +48,8 @@ def test_trigger_codegen( # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp + # active: false (non-default) flows through to the setter. + assert "->set_scan_active(false)" in main_cpp # Constructor call, not just the declaration: the parent argument is what # registers the trigger as a listener. assert re.search( diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index bd41a9476ae..2549125a432 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -16,8 +16,8 @@ from esphome.components.ln882h_ble_tracker import ( from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA -def _validate(**kwargs: str) -> dict: - """Run a scan_parameters config through a passive tracker's real schema.""" +def _validate(**kwargs: str | bool) -> dict: + """Run a scan_parameters config through the bk72xx tracker's real schema.""" return BK72XX_SCHEMA(kwargs) @@ -48,11 +48,12 @@ def test_to_ble_units_truncates() -> None: def test_bk72xx_defaults_are_valid() -> None: - """bk72xx pins the BK reference rate: 100 ms interval, shared 30 ms window.""" + """bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window — + and exposes active (default on, like every active-capable tracker).""" config = _validate() assert to_ble_units(config["interval"]) == 160 assert to_ble_units(config["window"]) == 48 - assert "active" not in config + assert config["active"] is True def test_esp32_defaults_are_valid() -> None: @@ -86,10 +87,9 @@ def test_esp32_active_can_disable() -> None: assert config["active"] is False -def test_passive_schema_rejects_active_key() -> None: - """Trackers without active scan support must not silently accept the option.""" - with pytest.raises(cv.Invalid): - _validate(active="true") +def test_bk72xx_active_can_disable() -> None: + config = _validate(active=False) + assert config["active"] is False # --- accepted configurations --- diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index a47dfd53fa6..16a3850d469 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -15,6 +15,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, @@ -27,18 +28,20 @@ from ..types import SetCoreConfigCallable # Advertisement-only hub platforms; rp2 runs the full proxy and has its own # tests below. HUB_PLATFORM_FRAMEWORKS = [ + PlatformFramework.BK72XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, ] HUB_TRACKERS = { + PLATFORM_BK72XX: "bk72xx_ble_tracker", PLATFORM_LN882X: "ln882h_ble_tracker", PLATFORM_RP2: "rp2_ble_tracker", } def test_hub_platform_list_covers_every_hub_platform() -> None: - # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise - # get no gate coverage at all; GATT platforms have their own tests. + # A platform added to _HUB_PLATFORMS would otherwise get no gate coverage + # at all; GATT platforms have their own tests. advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set( bluetooth_connection.HUB_MAX_CONNECTIONS ) diff --git a/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml new file mode 100644 index 00000000000..ad25fd6b647 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Passive scanning variant: the package merge keeps the shared parameters from +# common.yaml and overrides only the mode. +packages: + bk72xx_ble_tracker: !include common.yaml + +bk72xx_ble_tracker: + scan_parameters: + active: false diff --git a/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml new file mode 100644 index 00000000000..331d6795107 --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml @@ -0,0 +1,11 @@ +# Advertisement-only proxy on the bk72xx BLE hub (active-scan-capable since the +# tracker's packed-command start). Config-only: the CI base board generic-bk7252 +# is BLE 4.2 and cannot compile the BLE 5.x tracker. Same bare-hub arrangement +# as test.ln882x-ard.yaml: no explicit ble_hub_id so a grouped build cannot +# collide with bk72xx_ble_tracker's own fixture id. +packages: + common: !include common.yaml + +bk72xx_ble_tracker: + +bluetooth_proxy: