diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index f3896f7b9c..e10e85f3c3 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -4,6 +4,10 @@ #include "esphome/core/log.h" +#include + +#include + 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(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(data_len) : static_cast(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 diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index a77b5fc26c..9685b9294e 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -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 +#include +#include + 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 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 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 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}; diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index 55c9254327..fe207d04bf 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -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 #include @@ -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, 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 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 EventPool { } private: - LockFreeQueue 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(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) diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index ce54231137..316d9c7928 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -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") diff --git a/tests/components/core/test_event_pool.cpp b/tests/components/core/test_event_pool.cpp new file mode 100644 index 0000000000..af54ac3e14 --- /dev/null +++ b/tests/components/core/test_event_pool.cpp @@ -0,0 +1,73 @@ +#include "esphome/core/event_pool.h" + +#include + +#include + +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 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 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 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 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 pool; + pool.release(nullptr); + EXPECT_NE(pool.allocate(), nullptr); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_lock_free_queue_single.cpp b/tests/components/core/test_lock_free_queue_single.cpp new file mode 100644 index 0000000000..911674da65 --- /dev/null +++ b/tests/components/core/test_lock_free_queue_single.cpp @@ -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 + +#include +#include + +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, std::atomic>, + "ESPHOME_THREAD_SINGLE must select the PlainAtomic index path"); + +TEST(LockFreeQueueThreadSingle, EmptyPopReturnsNull) { + esphome::LockFreeQueue q; + EXPECT_EQ(q.pop(), nullptr); + EXPECT_TRUE(q.empty()); + EXPECT_FALSE(q.full()); + EXPECT_EQ(q.size(), 0u); +} + +TEST(LockFreeQueueThreadSingle, FifoOrder) { + esphome::LockFreeQueue 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 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 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 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 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 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 diff --git a/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml new file mode 100644 index 0000000000..251c83a92f --- /dev/null +++ b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml @@ -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