From 56b1c59eb2dc8dda54d2446ecc13a2cf27d8e053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 4 Aug 2026 05:54:07 +0300 Subject: [PATCH] [ln882h_ble] Scan primitives and main-task scan report queue (#17835) --- esphome/components/ln882h_ble/__init__.py | 20 ++ esphome/components/ln882h_ble/ln882h_ble.cpp | 223 +++++++++++++++++-- esphome/components/ln882h_ble/ln882h_ble.h | 87 ++++++++ esphome/core/defines.h | 1 + 4 files changed, 315 insertions(+), 16 deletions(-) diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py index f499dbc6fd..4299c7f006 100644 --- a/esphome/components/ln882h_ble/__init__.py +++ b/esphome/components/ln882h_ble/__init__.py @@ -12,6 +12,7 @@ component does (LibreTiny v1.13.0+). import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType DEPENDENCIES = ["ln882x"] @@ -31,6 +32,23 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) +KEY_SCAN_LISTENER_COUNT = "ln882h_ble_scan_listener_count" + + +def request_scan_listener_slot() -> None: + """Called from a consumer's codegen once per registered scan listener; sizes + the controller's StaticVector listener storage (heap-free, mirrors the + tracker's ble_device_base listener storage).""" + CORE.data[KEY_SCAN_LISTENER_COUNT] = CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0) + 1 + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_listener_count() -> None: + # FINAL: every consumer's to_code has requested its slot by now. + if count := CORE.data.get(KEY_SCAN_LISTENER_COUNT, 0): + cg.add_define("LN882H_BLE_SCAN_LISTENER_COUNT", count) + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -46,3 +64,5 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"]) cg.add_define("USE_LN882H_BLE") + + CORE.add_job(_add_listener_count) diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index dd8fee390d..c3b8982a92 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -1,11 +1,17 @@ // ln882h_ble.cpp // // BLE controller support for the LN882H (LibreTiny lightning-ln882h family) — -// the platform analog of esp32_ble / rp2040_ble. Owns the one-time stack -// bring-up (rw_init + the ln_* app init sequence) and the controller BLE -// address (persistent KV entry, WiFi-MAC-derived once). Consumers -// (ln882h_ble_tracker) build on this component and contain no SDK calls of -// their own. +// the platform analog of esp32_ble / rp2040_ble. Owns everything that talks to +// the LN882H BLE SDK: +// - one-time stack bring-up (rw_init + the ln_* app init sequence), +// - the controller BLE address (persistent KV entry, WiFi-MAC-derived once), +// - the raw controller scan primitives (ln_ble_scan_start/stop), +// - the scan-report ring: the SDK's rw-task event callback decodes each +// report (including the controller's RSSI sign quirk) into 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 EventPool + +// LockFreeQueue handoff esp32_ble uses, zero allocation at steady state. +// Consumers contain no SDK calls of their own. // // BLE stack init and scan lifecycle mirror the SDK's ble_app usage. The BLE // stack itself is compiled and linked by the LibreTiny lightning-ln882h builder @@ -51,6 +57,9 @@ void ln_ble_scan_actv_creat(void); void ln_ble_scan_start(void *scan_param); void ln_ble_scan_stop(void); +using ble_evt_cb_t = void (*)(void *param); +void ln_ble_evt_mgr_reg_evt(int evt_id, ble_evt_cb_t cb); + } // extern "C" // ln_bd_addr_v_t mirrors the SDK's ln_bd_addr_t (ln_ble_app_defines.h) and is @@ -64,9 +73,10 @@ static_assert(alignof(struct ln_bd_addr_v_t) == 1, "ln_bd_addr_v_t must stay byt // CLK_G_BLE — hal/hal_clock.h clock gate bit for the BLE block // BLE_EVT_ID_SCAN_REPORT — ble/ble_evt.h event id for scan reports // GAPM_* — ble/mac/ble/hl/api/gapm_task.h, enums gapm_scan_type / -// gapm_dup_filter_pol / gapm_scan_prop +// gapm_dup_filter_pol / gapm_scan_prop / gapm_adv_report_info // --------------------------------------------------------------------------- static constexpr uint32_t CLK_G_BLE = 1u << 0; +static constexpr int BLE_EVT_ID_SCAN_REPORT = 3; // WiFi/BLE packet-traffic-indication (PTI) arbitration register. The LN882H SDK // exposes no symbolic name for this register; the address and value replicate @@ -93,6 +103,20 @@ static constexpr uint8_t GAPM_SCAN_TYPE_OBSERVER = 2; static constexpr uint8_t GAPM_DUP_FILT_DIS = 0; // gapm_scan_prop bits: PHY_1M = 1<<0, PHY_CODED = 1<<1, ACTIVE_1M = 1<<2, ACTIVE_CODED = 1<<3. static constexpr uint8_t GAPM_SCAN_PROP_PHY_1M_BIT = 1 << 0; +static constexpr uint8_t GAPM_SCAN_PROP_ACTIVE_1M_BIT = 1 << 2; + +// GAPM extended-advertising report types (bits 2:0 of ble_scan_report_t::info). +// 0 = ADV_EXT (extended advertisement), 1 = ADV_LEG (legacy advertisement), +// 2 = SCAN_RSP_EXT (scan response to extended adv), 3 = SCAN_RSP_LEG (scan response to legacy adv). +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +// Bit 5 of ble_scan_report_t::info: the advertisement is scannable, i.e. a scan +// response may follow (enum gapm_adv_report_info, GAPM_REPORT_INFO_SCAN_ADV_BIT). +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1u << 5; + +// --------------------------------------------------------------------------- +// SDK struct layouts +// --------------------------------------------------------------------------- // Scan parameter block passed to ln_ble_scan_start(); mirrors the SDK layout, // with the pad byte explicit so the whole block zero-initialises. @@ -110,6 +134,29 @@ struct le_scan_parameters_t { // NOLINT(readability-identifier-naming) - mirror static_assert(sizeof(le_scan_parameters_t) == 8, "le_scan_parameters_t must match the SDK layout"); static_assert(offsetof(le_scan_parameters_t, scan_intv) == 4, "unexpected padding in le_scan_parameters_t"); +// Scan report delivered by the BLE_EVT_ID_SCAN_REPORT event. Layout verified on +// hardware against the prebuilt BLE stack LibreTiny links: its report carries no +// PHY fields and stores the advertisement data inline (flexible array), unlike +// the newer upstream SDK header (which adds phy_prim/phy_second and a data pointer). +struct ble_scan_report_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t actv_idx; + uint8_t info; + uint8_t trans_addr_type; + uint8_t trans_addr[6]; + uint8_t target_addr_type; + uint8_t target_addr[6]; + int8_t tx_pwr; + int8_t rssi; // signed dBm, range -127..+20 (ble_evt_scan_report_t from ln_ble_event_manager.h) + uint16_t length; + uint8_t data[0]; +}; +// Pin the layout of the hand-mirrored report struct too: the comment above +// notes a newer SDK header uses a different layout (PHY fields + data pointer), +// so silent drift here would corrupt every decoded advertisement. +static_assert(sizeof(ble_scan_report_t) == 20, "ble_scan_report_t must match the linked BLE stack's layout"); +static_assert(offsetof(ble_scan_report_t, length) == 18, "unexpected padding in ble_scan_report_t"); +static_assert(offsetof(ble_scan_report_t, data) == 20, "advertisement data must follow the header inline"); + // --------------------------------------------------------------------------- // __sprintf weak stub // @@ -133,11 +180,86 @@ namespace esphome::ln882h_ble { static const char *const TAG = "ln882h_ble"; +// The SDK event callback is a plain C function pointer with no user argument, +// so it reaches the (single) component instance through a file-static pointer. +static LN882HBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Scan parameter blocks handed to ln_ble_scan_start(void *). static storage: +// the SDK may retain the pointer past the call (the block travels into a GAPM +// message consumed later by the rw task), so a stack-local would leave the +// controller reading a dead frame. Double-buffered: consecutive starts (the +// enable() probe followed by the first real scan, or a parameter restart) +// alternate blocks, so a rewrite can never race a previous block that is still +// in flight — correct under either reading of SDK retention. All writers run +// on the main task. +static le_scan_parameters_t s_scan_params[2]{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static uint8_t s_scan_params_idx = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static le_scan_parameters_t *next_scan_params() { + s_scan_params_idx ^= 1; + return &s_scan_params[s_scan_params_idx]; +} + +// --------------------------------------------------------------------------- +// Scan-report event callback — runs in the SDK's rw task context. +// Decode the report (hardware-verified struct layout + the RSSI sign fix), +// copy it into the queue and return; all dispatch happens in loop() on the +// main task. +// --------------------------------------------------------------------------- +static void ble_scan_callback(void *param) { + if (s_ble == nullptr || param == nullptr) + return; + const auto *info = reinterpret_cast(param); + + // Fill the pool slot in place (the bk72xx_ble shape): no report on the rw + // task's stack — its size is fixed by the prebuilt stack — one copy of the + // payload instead of two, and only data_len bytes ever leave this frame. + BLEScanReport *slot = s_ble->allocate_scan_report(); + if (slot == nullptr) + return; // pool exhausted — counted as dropped in allocate_scan_report() + + const uint8_t report_type = info->info & 0x07; + + // BLE RSSI sign fix. The LN882H controller intermittently reports the RSSI with + // a flipped sign: a real -58 dBm arrives as +58, above the SDK's documented + // -127..+20 dBm maximum. Recover it by negating any value above +20 (verified + // on-device: the out-of-range positives cluster at the magnitude of each + // device's real readings). This is the ONLY LN882H-specific RSSI handling — + // downstream the value is used exactly like on ESP32. + const int8_t raw = info->rssi; + + memcpy(slot->mac, info->trans_addr, 6); + slot->rssi = (raw > 20) ? static_cast(-raw) : raw; + slot->addr_type = info->trans_addr_type; + slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_EXT || report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; + slot->scannable = (info->info & GAPM_REPORT_INFO_SCAN_ADV_BIT) != 0; + slot->data_len = (info->length <= sizeof(slot->data)) ? static_cast(info->length) + : static_cast(sizeof(slot->data)); + memcpy(slot->data, info->data, slot->data_len); + + s_ble->push_scan_report(slot); +} + +BLEScanReport *LN882HBLE::allocate_scan_report() { + BLEScanReport *slot = this->report_pool_.allocate(); + if (slot == nullptr) { + // Pool exhausted — the queue is full; count and drop. + this->report_queue_.increment_dropped_count(); + } + return slot; +} + +void LN882HBLE::push_scan_report(BLEScanReport *report) { + // Cannot fail: the pool is sized to the queue capacity. + this->report_queue_.push(report); +} + // --------------------------------------------------------------------------- // Component lifecycle // --------------------------------------------------------------------------- void LN882HBLE::setup() { + s_ble = this; // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before // the stack is up. The KV load also happens here (no stack dependency). this->resolve_mac_(); @@ -175,23 +297,48 @@ void LN882HBLE::enable() { delay(10); // Prime the scan activity with a short probe start/stop — the SDK's scan - // manager completes activity creation on the first start. - // static: its address is handed to ln_ble_scan_start(void *), which may - // retain it past this call. - static le_scan_parameters_t probe_p{}; - probe_p.type = GAPM_SCAN_TYPE_OBSERVER; - probe_p.prop = GAPM_SCAN_PROP_PHY_1M_BIT; - probe_p.dup_filt_pol = GAPM_DUP_FILT_DIS; - probe_p.scan_intv = 160; - probe_p.scan_wd = 16; - ln_ble_scan_start(&probe_p); + // manager completes activity creation on the first start. Uses the shared + // static parameter block (see s_scan_params for the lifetime rationale). + le_scan_parameters_t *probe = next_scan_params(); + probe->type = GAPM_SCAN_TYPE_OBSERVER; + probe->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + probe->dup_filt_pol = GAPM_DUP_FILT_DIS; + probe->scan_intv = 160; + probe->scan_wd = 16; + ln_ble_scan_start(probe); delay(10); ln_ble_scan_stop(); + // Register the scan-report event exactly once, after the event manager is up. + // Repeated registration corrupts the SDK's event registry (verified on + // hardware), which is why this lives here and not in scan_start(). + ln_ble_evt_mgr_reg_evt(BLE_EVT_ID_SCAN_REPORT, ble_scan_callback); + this->state_ = BLEComponentState::ACTIVE; ESP_LOGD(TAG, "BLE stack initialised"); } +void LN882HBLE::loop() { + // Drain the lock-free ring filled by the rw task; all per-report work runs + // here on the main task, then the report returns to the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report == nullptr) + return; + do { +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); +#endif + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + + // Log dropped reports — only reachable when reports were processed; drops can + // only occur while the queue is full, and only this loop drains it. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); +} + void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); } void LN882HBLE::dump_config() { @@ -246,6 +393,50 @@ void LN882HBLE::resolve_mac_() { memcpy(this->ble_mac_, bt_addr.addr, 6); } +// --------------------------------------------------------------------------- +// Controller scan primitives +// --------------------------------------------------------------------------- + +void LN882HBLE::scan_start(uint16_t interval, uint16_t window, bool active) { + if (!this->is_active()) + this->enable(); + + if (this->scanning_) { + // Already scanning - stop first so this call cleanly restarts with the new + // parameters (re-entry guard). Give the GAPM stop the same settle time + // enable() grants between consecutive GAPM operations before restarting. + this->scan_stop(); + delay(10); // NOLINT — restart-only, mirrors enable()'s inter-operation settle + } + + // Double-buffered static block — see s_scan_params for the lifetime rationale. + le_scan_parameters_t *p = next_scan_params(); + p->dup_filt_pol = GAPM_DUP_FILT_DIS; + p->type = GAPM_SCAN_TYPE_OBSERVER; + p->scan_intv = interval; + p->scan_wd = window; + // Legacy 1M PHY only: consumers size their buffers for legacy advertisements + // (62 B); coded/extended PHY (up to 255 B) would be silently truncated. + p->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + if (active) + p->prop |= GAPM_SCAN_PROP_ACTIVE_1M_BIT; + + ln_ble_scan_start(p); + // ln_ble_scan_start() returns void, so this tracks the requested state, not a + // confirmed one — a controller-side failure surfaces as an idle scanner (no + // reports), which the consumer's start retry/backoff owns. + this->scanning_ = true; +} + +void LN882HBLE::scan_stop() { + // No-op when idle, as documented: the guard keeps a redundant SDK stop off + // the GAPM path (scan_start()'s re-entry guard calls this while scanning). + if (!this->scanning_) + return; + ln_ble_scan_stop(); + this->scanning_ = false; +} + } // namespace esphome::ln882h_ble #endif // USE_LN882H_BLE diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index 0e3e311341..e957cdd41e 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -5,6 +5,9 @@ #ifdef USE_LN882H_BLE #include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" #include @@ -16,9 +19,52 @@ enum class BLEComponentState : uint8_t { ACTIVE, }; +/// One scan report from the controller, decoded from the SDK's rw-task event +/// (RSSI already sign-corrected). +struct BLEScanReport { + uint8_t mac[6]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) + uint8_t addr_type; + bool is_scan_response; // report is a scan response (active scan) + bool scannable; // advertisement may be followed by a scan response + uint8_t data_len; // bytes valid in data[] (<= 62) + // Each report carries ONE frame — a legacy advertisement (<=31 B) or a scan + // response (<=31 B) — delivered split, exactly as the SDK reports them. The + // TRACKER merges the pair into a single frame before any consumer sees it + // (Bluedroid semantics, HubCapabilities::merges_scan_response). 62 is twice + // the legacy maximum: defensive headroom for the data_len clamp, and the + // same width as the merged framing downstream. + 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 task: reports are queued from the SDK's rw task and +/// drained by the controller's loop(), so consumers never deal with cross-task +/// 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 rw task and loop(). Sized from the +// measured worst case, not copied: WiFi/BLE coexistence delays rw-task report +// delivery by up to ~136 ms on this device (see the tracker's pending-adv +// timeout rationale), and a busy 2.4 GHz environment delivers ~200-400 +// reports/s — a stall plus one loop() interval buffers ~30-60 reports, so 63 +// usable slots absorb it with margin. ~4.7 KB at high water, reached only +// during such stalls. +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; + class LN882HBLE final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override; @@ -37,12 +83,53 @@ class LN882HBLE final : public Component { /// (the bk72xx sibling exposes the same accessor). void get_mac_lsb_first(uint8_t out[6]) const; +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + /// Register a consumer for scan reports (delivered on the main task via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits LN882H_BLE_SCAN_LISTENER_COUNT. + 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); + /// active enables scan requests on the 1M PHY. Enables the stack first if + /// needed. Scans the legacy 1M PHY only (extended/coded PHY advertisements + /// exceed the legacy 62-byte framing consumers are sized for). + void scan_start(uint16_t interval, uint16_t window, bool active); + /// Stop the controller scan (no-op when not scanning). + void scan_stop(); + + /// Internal, SDK rw-task event-callback context: allocate a pool slot for a + /// scan report. Returns nullptr (and counts the drop) when the queue is full; + /// the callback fills the slot in place — no intermediate copy. + BLEScanReport *allocate_scan_report(); + /// Internal: hand a filled slot to the main-task queue (cannot fail — the + /// pool is sized to the queue capacity). + void push_scan_report(BLEScanReport *report); + protected: void resolve_mac_(); +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif + // Report ring: the SDK event callback (rw task) allocates a report from the + // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. + // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. + // Overflow drops the NEWEST report (allocate fails, producer counts and + // returns) — under a coexistence stall the freshest advertisements are lost + // while queued ones drain. Deliberate: matches esp32_ble, and dropping from + // the head would need consumer-side locking this design exists to avoid. + 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_; uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; + bool scanning_{false}; // controller scan running (re-entry guard for scan_start) }; } // namespace esphome::ln882h_ble diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b184300681..ed6a117622 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -455,6 +455,7 @@ #ifdef USE_LIBRETINY #define USE_BK72XX_BLE #define USE_LN882H_BLE +#define LN882H_BLE_SCAN_LISTENER_COUNT 1 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS