mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 10:52:56 +08:00
[rp2040_ble] Add controller scan primitives (#18001)
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user