[rp2040_ble] Add controller scan primitives (#18001)

This commit is contained in:
J. Nick Koston
2026-08-03 11:14:34 -05:00
committed by GitHub
parent 4d9c514a1a
commit 4f058585bb
7 changed files with 434 additions and 17 deletions
+122 -2
View File
@@ -4,6 +4,10 @@
#include "esphome/core/log.h"
#include <BluetoothLock.h>
#include <cstring>
namespace esphome::rp2040_ble {
static const char *const TAG = "rp2040_ble";
@@ -11,15 +15,41 @@ static const char *const TAG = "rp2040_ble";
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
RP2040BLE *global_ble = nullptr;
// The analyzer cannot see that release() always retains the pointer here: the
// pool's free list is sized SIZE + 1, so its push cannot hit the ring-full
// drop branch for at most SIZE releases.
// NOLINTBEGIN(clang-analyzer-unix.Malloc)
void RP2040BLE::setup() {
global_ble = this;
// Pre-create every pool entry so the packet handler's allocate() is always a
// free-list pop — the IRQ path must never reach malloc() (heap allocation
// after setup is forbidden, and the newlib malloc lock is not IRQ-safe).
// Deliberately unconditional: warming lazily on the first scan would move
// the allocations after setup, and doing it here keeps the pool's RAM cost
// visible at startup instead of appearing once scanning begins.
BLEScanReport *warm[MAX_SCAN_REPORT_QUEUE_SIZE - 1];
size_t warmed = 0;
while (warmed < MAX_SCAN_REPORT_QUEUE_SIZE - 1 && (warm[warmed] = this->report_pool_.allocate()) != nullptr)
warmed++;
for (size_t i = 0; i < warmed; i++)
this->report_pool_.release(warm[i]);
if (warmed != MAX_SCAN_REPORT_QUEUE_SIZE - 1) {
// An incomplete warm would silently put malloc() back on the IRQ path once
// the free list runs dry; refuse to run instead (the stack is never
// enabled, so the packet handler cannot fire).
ESP_LOGE(TAG, "Scan report pool warm-up failed");
this->mark_failed();
return;
}
if (this->enable_on_boot_) {
this->enable();
} else {
this->state_ = BLEComponentState::DISABLED;
}
}
// NOLINTEND(clang-analyzer-unix.Malloc)
void RP2040BLE::enable() {
if (this->state_ == BLEComponentState::ACTIVE || this->state_ == BLEComponentState::ENABLING) {
@@ -31,6 +61,10 @@ void RP2040BLE::enable() {
this->active_logged_ = false;
if (!this->btstack_initialized_) {
// Serialize with the BTstack background worker while wiring the stack up
// (arduino-pico's BluetoothHCI::install() takes the same lock here).
BluetoothLock lock;
// BTstack init functions are not idempotent — only call once
l2cap_init();
sm_init();
@@ -44,6 +78,7 @@ void RP2040BLE::enable() {
this->btstack_initialized_ = true;
}
BluetoothLock lock;
hci_power_control(HCI_POWER_ON);
}
@@ -55,7 +90,10 @@ void RP2040BLE::disable() {
ESP_LOGD(TAG, "Disabling BLE...");
this->state_ = BLEComponentState::DISABLING;
hci_power_control(HCI_POWER_OFF);
{
BluetoothLock lock;
hci_power_control(HCI_POWER_OFF);
}
this->state_ = BLEComponentState::DISABLED;
ESP_LOGD(TAG, "BLE disabled");
@@ -64,7 +102,29 @@ void RP2040BLE::disable() {
void RP2040BLE::loop() {
if (this->state_ == BLEComponentState::ACTIVE && !this->active_logged_) {
this->active_logged_ = true;
ESP_LOGI(TAG, "BLE active");
// The controller address becomes readable once HCI reaches WORKING.
// bd_addr_to_str() formats into a BTstack-internal static buffer, so both
// calls stay under the lock like every other BTstack call from the loop.
BluetoothLock lock;
gap_local_bd_addr(this->ble_mac_);
ESP_LOGI(TAG, "BLE active (MAC %s)", bd_addr_to_str(this->ble_mac_));
}
// Drain the lock-free ring filled by the BTstack packet handler; all
// per-report work runs here on the main loop, then the report returns to
// the pool.
BLEScanReport *report = this->report_queue_.pop();
if (report == nullptr)
return;
do {
for (auto *listener : this->scan_listeners_)
listener->on_scan_report(*report);
this->report_pool_.release(report);
} while ((report = this->report_queue_.pop()) != nullptr);
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
if (dropped > 0) {
ESP_LOGW(TAG, "Dropped %u scan reports (queue full)", dropped);
}
}
@@ -114,11 +174,71 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet,
}
break;
}
case GAP_EVENT_ADVERTISING_REPORT: {
// Runs in the CYW43 async-context worker (low-priority IRQ), NOT the
// ESPHome main loop: bounded copy into the lock-free queue only.
bd_addr_t addr; // accessor returns printable (MSB-first) order
gap_event_advertising_report_get_address(packet, addr);
uint8_t mac_lsb[6];
reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect
global_ble->enqueue_scan_report_(mac_lsb, static_cast<int8_t>(gap_event_advertising_report_get_rssi(packet)),
gap_event_advertising_report_get_address_type(packet),
gap_event_advertising_report_get_data(packet),
gap_event_advertising_report_get_data_length(packet));
break;
}
default:
break;
}
}
// The analyzer traces a leak on the failed-push path, which cannot happen: the
// pool is sized to the queue capacity (SIZE-1), so allocate() returns nullptr
// before push() can find the ring full.
// NOLINTBEGIN(clang-analyzer-unix.Malloc)
void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_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.
this->report_queue_.increment_dropped_count();
return;
}
memcpy(report->mac, mac_lsb_first, 6);
report->rssi = rssi;
report->addr_type = addr_type;
report->data_len =
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
memcpy(report->data, data, report->data_len);
this->report_queue_.push(report);
}
// NOLINTEND(clang-analyzer-unix.Malloc)
void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); }
bool RP2040BLE::scan_start(uint16_t interval, uint16_t window) {
if (!this->is_active()) {
// Power control stays with the user (enable_on_boot or an explicit
// enable() call) — auto-enabling here would defeat enable_on_boot: false
// the moment a tracker retries. Callers retry until the stack is up.
return false;
}
// Serialize with the BTstack background worker (arduino-pico's BluetoothHCI
// takes the same lock around its gap_* calls).
BluetoothLock lock;
gap_set_scan_params(0 /* passive */, interval, window, 0 /* accept all */);
gap_start_scan();
return true;
}
void RP2040BLE::scan_stop() {
if (!this->is_active()) {
return; // nothing can be scanning on a stack that is not up
}
BluetoothLock lock;
gap_stop_scan();
}
} // namespace esphome::rp2040_ble
#endif // USE_RP2040_BLE
@@ -5,9 +5,14 @@
#ifdef USE_RP2040_BLE
#include "esphome/core/component.h"
#include "esphome/core/event_pool.h"
#include "esphome/core/lock_free_queue.h"
#include <btstack.h>
#include <cstdint>
#include <vector>
namespace esphome::rp2040_ble {
enum class BLEComponentState : uint8_t {
@@ -18,6 +23,39 @@ enum class BLEComponentState : uint8_t {
DISABLED,
};
/// 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;
uint8_t data_len; // bytes valid in data[]
// Legacy advertisement (31) + scan response (31): passive scans fill at most
// 31 bytes today, but bluetooth_proxy support will flip to active scanning
// in a future PR and the API raw-advertisement contract carries 62.
uint8_t data[62];
// EventPool contract: nothing is heap-allocated inside a report.
void release() {}
};
/// Consumer interface for controller scan reports. on_scan_report() always
/// runs on the ESPHome main loop: reports are queued from the BTstack packet
/// handler (CYW43 async-context IRQ) and drained by the controller's loop(),
/// so consumers never deal with cross-context state (the esp32_ble
/// event-queue pattern).
class BLEScanListener {
public:
virtual void on_scan_report(const BLEScanReport &report) = 0;
protected:
~BLEScanListener() = default; // deletion via this interface is not part of the contract
};
// Maximum reports buffered between the packet handler and loop(). The producer
// is a same-core IRQ and loop() drains the ring every iteration, so only the
// advertisements of a single loop period can accumulate.
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 32;
class RP2040BLE final : public Component {
public:
void setup() override;
@@ -31,12 +69,49 @@ class RP2040BLE final : public Component {
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
/// Controller BLE address in printable (MSB-first) order, as
/// gap_local_bd_addr() delivers it — note BLEScanReport::mac is the opposite
/// (LSB-first) order, hence the explicit names. All zeros until the stack
/// reports ACTIVE (BTstack reads the address from the controller during
/// power-up).
void get_mac_msb_first(uint8_t out[6]) const;
/// Register a consumer for scan reports (delivered on the main loop via loop()).
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
/// Start a passive controller scan. Interval/window are in BLE units
/// (0.625 ms). Returns false until the stack is ACTIVE (callers retry — the
/// tracker's rate-limited retry loop); powering the stack on stays with the
/// user (enable_on_boot or an explicit enable() call). The controller keeps
/// no scan state: a disable()/enable() power cycle ends the scan, and the
/// caller must call scan_start() again once the stack is back to ACTIVE
/// (the tracker's loop() reconciliation does exactly that).
bool scan_start(uint16_t interval, uint16_t window);
/// Stop the controller scan (no-op when not scanning).
void scan_stop();
protected:
static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size);
/// Buffer one controller report (BTstack packet handler, CYW43 async-context
/// IRQ — bounded copy into the lock-free queue, nothing else).
void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len);
std::vector<BLEScanListener *> scan_listeners_;
// Report ring: the BTstack packet handler (async-context IRQ) allocates a
// report from the pool, fills it and pushes the pointer; loop() pops,
// dispatches and releases. Lock-free SPSC — the esp32_ble/bk72xx_ble pattern.
esphome::LockFreeQueue<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE> report_queue_;
// Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so
// 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<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
btstack_packet_callback_registration_t hci_event_callback_registration_{};
btstack_packet_callback_registration_t sm_event_callback_registration_{};
uint8_t ble_mac_[6]{0}; // printable (MSB-first) order; zeros until ACTIVE
BLEComponentState state_{BLEComponentState::STATE_OFF};
bool enable_on_boot_{true};
bool btstack_initialized_{false};
+13 -5
View File
@@ -1,6 +1,6 @@
#pragma once
#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY)
#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST)
#include <atomic>
#include <cstddef>
@@ -12,7 +12,7 @@ namespace esphome {
// Event Pool - On-demand pool of objects to avoid heap fragmentation
// Events are allocated on first use and reused thereafter, growing to peak usage
// @tparam T The type of objects managed by the pool (must have a release() method)
// @tparam SIZE The maximum number of objects in the pool (1-255, limited by uint8_t)
// @tparam SIZE The maximum number of objects in the pool (1-254, limited by uint8_t and the +1 free-list slot)
//
// SIZING: When paired with a LockFreeQueue<T, Q_SIZE>, the pool SIZE should be
// Q_SIZE - 1 (the queue's actual capacity, since the ring buffer reserves one slot).
@@ -22,6 +22,11 @@ namespace esphome {
// - Avoids needing release() on the producer path after a failed push(),
// preserving the SPSC contract on the internal free list
template<class T, uint8_t SIZE> class EventPool {
// The free list ring must hold all SIZE objects at once (a fully drained
// pool), and LockFreeQueue reserves one slot — so it is sized SIZE + 1,
// which caps SIZE at 254.
static_assert(SIZE < 255, "EventPool SIZE must be at most 254");
public:
EventPool() : total_created_(0) {}
@@ -80,10 +85,13 @@ template<class T, uint8_t SIZE> class EventPool {
}
private:
LockFreeQueue<T, SIZE> free_list_; // Free events ready for reuse
uint8_t total_created_; // Total events created (high water mark, max 255)
// SIZE + 1 slots so all SIZE objects fit when the pool is fully drained
// (the ring reserves one slot); otherwise the last release() of a
// completely returned pool would drop, permanently orphaning one object.
LockFreeQueue<T, static_cast<uint8_t>(SIZE + 1)> free_list_; // Free events ready for reuse
uint8_t total_created_; // Total events created (high water mark, max 254)
};
} // namespace esphome
#endif // defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY)
#endif // defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST)
+22 -10
View File
@@ -16,7 +16,9 @@
* blocking each other.
*
* This is a Single-Producer Single-Consumer (SPSC) lock-free ring buffer.
* Available on platforms with FreeRTOS support (ESP32, LibreTiny).
* Available on multi-threaded platforms (ESP32, LibreTiny) where another task
* produces or consumes, and on single-threaded platforms (RP2) where the
* producer runs in interrupt context.
*
* Common use cases:
* - BLE events: BLE task produces, main loop consumes
@@ -29,15 +31,25 @@
namespace esphome {
namespace lockfree_internal {
#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS
// Platforms whose cores lack atomic read-modify-write instructions (currently
// the ARMv5TE BK72xx SoCs — no LDREX/STREX, no libatomic; other LibreTiny
// chips such as LN882x/RTL87xx are ARMv7-M and keep std::atomic). For this
// queue's SPSC contract RMW atomics are not needed: aligned 8/16-bit loads and
// stores are single instructions on these cores, so torn reads cannot occur,
// and on a single in-order core a compiler barrier supplies all the
// acquire/release ordering the algorithm requires. Each index has exactly one
// writer (head_: consumer, tail_: producer). The dropped counter's
#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) || defined(ESPHOME_THREAD_SINGLE)
// Platforms where std::atomic RMW operations are unavailable or unnecessary:
// - ESPHOME_THREAD_MULTI_NO_ATOMICS: cores lacking atomic read-modify-write
// instructions (currently the ARMv5TE BK72xx SoCs — no LDREX/STREX, no
// libatomic; other LibreTiny chips such as LN882x/RTL87xx are ARMv7-M and
// keep std::atomic).
// - ESPHOME_THREAD_SINGLE: every platform on this model (ESP8266, RP2,
// nRF52) runs everything on one core (the chip may have more — RP2 is
// dual-core, but ESPHome and its interrupt producers stay on core 0), so
// the only possible concurrency is same-core interrupt preemption (on RP2
// the BTstack packet handler runs in the CYW43 async-context low-priority
// IRQ on the core that initialized it, core 0). Using plain accesses here
// also avoids __atomic_* library calls on RP2040 (Cortex-M0+, no
// LDREX/STREX).
// For this queue's SPSC contract RMW atomics are not needed: aligned 8/16-bit
// loads and stores are single instructions on these cores, so torn reads
// cannot occur, and on a single in-order core a compiler barrier supplies all
// the acquire/release ordering the algorithm requires. Each index has exactly
// one writer (head_: consumer, tail_: producer). The dropped counter's
// increment/exchange pair is not atomic here — a concurrent reset can lose
// counts — which is acceptable for a diagnostic drop counter.
#define ESPHOME_LFQ_COMPILER_BARRIER() __asm__ __volatile__("" ::: "memory")
+73
View File
@@ -0,0 +1,73 @@
#include "esphome/core/event_pool.h"
#include <gtest/gtest.h>
#include <set>
namespace esphome::core::testing {
struct PoolItem {
int value{0};
// EventPool contract: release() cleans up per-object state; nothing here.
void release() {}
};
TEST(EventPool, AllocateUpToCapacityThenNull) {
esphome::EventPool<PoolItem, 4> pool;
PoolItem *items[4];
for (auto *&item : items) {
item = pool.allocate();
ASSERT_NE(item, nullptr);
}
// At capacity: the pool refuses rather than growing past SIZE.
EXPECT_EQ(pool.allocate(), nullptr);
}
TEST(EventPool, FullDrainRetainsEveryObject) {
// Pins the SIZE + 1 free-list sizing: a fully returned pool must hold all
// SIZE objects. With a SIZE-slot ring (capacity SIZE - 1) the last release()
// of a full drain was dropped, permanently orphaning one object.
esphome::EventPool<PoolItem, 4> pool;
PoolItem *items[4];
for (auto *&item : items)
item = pool.allocate();
for (auto *item : items)
pool.release(item);
// Every object must be allocatable again — no orphan, no new creation
// (total_created_ is already at SIZE, so a lost object would surface as a
// nullptr on the fourth allocation).
std::set<PoolItem *> seen;
for (int i = 0; i < 4; i++) {
PoolItem *item = pool.allocate();
ASSERT_NE(item, nullptr);
seen.insert(item);
}
// And they are the same four objects, recycled rather than re-created.
for (auto *item : items)
EXPECT_TRUE(seen.count(item) == 1);
EXPECT_EQ(pool.allocate(), nullptr);
}
TEST(EventPool, RepeatedDrainCyclesAreStable) {
esphome::EventPool<PoolItem, 3> pool;
// Several full allocate/release cycles: capacity must not shrink over time.
for (int cycle = 0; cycle < 10; cycle++) {
PoolItem *items[3];
for (auto *&item : items) {
item = pool.allocate();
ASSERT_NE(item, nullptr);
}
EXPECT_EQ(pool.allocate(), nullptr);
for (auto *item : items)
pool.release(item);
}
}
TEST(EventPool, ReleaseNullptrIsSafe) {
esphome::EventPool<PoolItem, 2> pool;
pool.release(nullptr);
EXPECT_NE(pool.allocate(), nullptr);
}
} // namespace esphome::core::testing
@@ -0,0 +1,115 @@
// Exercises the LockFreeQueue PlainAtomic path under ESPHOME_THREAD_SINGLE —
// the gate added for single-threaded platforms whose only concurrency is
// same-core interrupt preemption (RP2: BTstack packet handler in the CYW43
// async-context IRQ). The define is forced before the include so this TU
// deterministically compiles that path regardless of the host's default
// thread model. The instantiations here deliberately differ from
// test_lock_free_queue.cpp's (uint32_t elements, non-power-of-2 sizes): no
// template instantiation is shared between the two TUs, so the differing
// AtomicIndex definitions can never collide under the one-definition rule,
// and the non-power-of-2 sizes cover next_index()'s comparison branch, which
// the other TU's power-of-2 sizes never reach.
#define ESPHOME_THREAD_SINGLE
#include "esphome/core/lock_free_queue.h"
#include <gtest/gtest.h>
#include <atomic>
#include <type_traits>
namespace esphome::core::testing {
// Pin the gate itself: under ESPHOME_THREAD_SINGLE the index type must be the
// PlainAtomic fallback, not std::atomic — otherwise the RP2040 build silently
// pulls __atomic_* library calls back in.
static_assert(!std::is_same_v<esphome::lockfree_internal::AtomicIndex<uint8_t>, std::atomic<uint8_t>>,
"ESPHOME_THREAD_SINGLE must select the PlainAtomic index path");
TEST(LockFreeQueueThreadSingle, EmptyPopReturnsNull) {
esphome::LockFreeQueue<uint32_t, 5> q;
EXPECT_EQ(q.pop(), nullptr);
EXPECT_TRUE(q.empty());
EXPECT_FALSE(q.full());
EXPECT_EQ(q.size(), 0u);
}
TEST(LockFreeQueueThreadSingle, FifoOrder) {
esphome::LockFreeQueue<uint32_t, 5> q;
uint32_t a = 1, b = 2, c = 3, d = 4;
EXPECT_TRUE(q.push(&a));
EXPECT_TRUE(q.push(&b));
EXPECT_TRUE(q.push(&c));
EXPECT_TRUE(q.push(&d));
EXPECT_EQ(q.size(), 4u);
EXPECT_EQ(q.pop(), &a);
EXPECT_EQ(q.pop(), &b);
EXPECT_EQ(q.pop(), &c);
EXPECT_EQ(q.pop(), &d);
EXPECT_EQ(q.pop(), nullptr);
}
TEST(LockFreeQueueThreadSingle, CapacityIsSizeMinusOne) {
esphome::LockFreeQueue<uint32_t, 5> q;
uint32_t v[5] = {0, 1, 2, 3, 4};
EXPECT_TRUE(q.push(&v[0]));
EXPECT_TRUE(q.push(&v[1]));
EXPECT_TRUE(q.push(&v[2]));
EXPECT_TRUE(q.push(&v[3]));
EXPECT_TRUE(q.full());
// Ring reserves one slot: the SIZEth push fails and is counted as dropped.
EXPECT_FALSE(q.push(&v[4]));
EXPECT_EQ(q.get_and_reset_dropped_count(), 1u);
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky
}
TEST(LockFreeQueueThreadSingle, NullPushRejected) {
esphome::LockFreeQueue<uint32_t, 5> q;
EXPECT_FALSE(q.push(nullptr));
EXPECT_TRUE(q.empty());
}
TEST(LockFreeQueueThreadSingle, WrapAround) {
// Non-power-of-2 SIZE: next_index() wraps via the comparison branch here.
esphome::LockFreeQueue<uint32_t, 5> q;
uint32_t v[4] = {10, 20, 30, 40};
// Cycle several times the ring size to cross the wrap boundary repeatedly.
for (int cycle = 0; cycle < 10; cycle++) {
for (auto &value : v)
ASSERT_TRUE(q.push(&value));
EXPECT_TRUE(q.full());
for (auto &value : v)
ASSERT_EQ(q.pop(), &value);
EXPECT_TRUE(q.empty());
}
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u);
}
TEST(LockFreeQueueThreadSingle, IncrementDroppedCount) {
esphome::LockFreeQueue<uint32_t, 5> q;
// Producer-side external drop accounting (pool exhausted before push).
q.increment_dropped_count();
q.increment_dropped_count();
EXPECT_EQ(q.get_and_reset_dropped_count(), 2u);
}
TEST(LockFreeQueueThreadSingle, InterleavedPushPop) {
esphome::LockFreeQueue<uint32_t, 7> q;
uint32_t v[64];
uint32_t popped = 0;
for (uint32_t i = 0; i < 64; i++) {
v[i] = i;
ASSERT_TRUE(q.push(&v[i]));
if (i % 2 == 1) {
uint32_t *first = q.pop();
ASSERT_NE(first, nullptr);
EXPECT_EQ(*first, popped++);
uint32_t *second = q.pop();
ASSERT_NE(second, nullptr);
EXPECT_EQ(*second, popped++);
}
}
EXPECT_TRUE(q.empty());
EXPECT_EQ(popped, 64u);
}
} // namespace esphome::core::testing
@@ -0,0 +1,14 @@
# Exercises the controller scan API from a lambda: passive scan start with
# interval/window in 0.625 ms BLE units, stop, and the adapter MAC accessor.
esphome:
on_boot:
then:
- lambda: |-
uint8_t mac[6];
id(ble).get_mac_msb_first(mac);
if (id(ble).scan_start(160, 48)) {
id(ble).scan_stop();
}
rp2040_ble:
id: ble