[ln882h_ble_tracker] BLE 5.x scanner for LN882H (#16691)
CI / Create common environment (push) Canceled after 0s
CI / Determine which jobs to run (push) Canceled after 0s
CI / Run script/ci-custom (push) Canceled after 0s
CI / Check pylint (push) Canceled after 0s
CI / Check lint and formatting (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (macOS-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.13) (push) Canceled after 0s
CI / Run pytest (ubuntu-latest, 3.14) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.12) (push) Canceled after 0s
CI / Run pytest (windows-latest, 3.14) (push) Canceled after 0s
CI / Report no coverage to Codecov (push) Canceled after 0s
CI / Run integration tests () (push) Canceled after 0s
CI / Check import esphome.__main__ time (push) Canceled after 0s
CI / Run CodSpeed benchmarks (push) Canceled after 0s
CI / Run C++ unit tests (push) Canceled after 0s
CI / Run script/clang-tidy for LibreTiny (push) Canceled after 0s
CI / Run script/clang-tidy for ESP8266 (push) Canceled after 0s
CI / Run script/clang-tidy for RP2 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 Arduino (push) Canceled after 0s
CI / Run script/clang-tidy for ZEPHYR (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 1/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 2/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 IDF 3/3 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 C6 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 P4 (push) Canceled after 0s
CI / Run script/clang-tidy for ESP32 S3 (push) Canceled after 0s
CI / Test components batch () (push) Canceled after 0s
CI / Test esp32 components with PlatformIO (push) Canceled after 0s
CI / Test downstream esphome/device-builder (push) Canceled after 0s
CI / Build target branch for memory impact (push) Canceled after 0s
CI / Build PR branch for memory impact (push) Canceled after 0s
CI / Comment memory impact (push) Canceled after 0s
CI / CI Status (push) Canceled after 0s

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Edvard Filistovič
2026-08-04 22:45:47 +00:00
committed by GitHub
co-authored by J. Nick Koston
parent 1f5df20cb2
commit 1bae91abaa
12 changed files with 695 additions and 18 deletions
+1
View File
@@ -296,6 +296,7 @@ esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
+49 -18
View File
@@ -108,7 +108,7 @@ 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_ADV_LEG = 1;
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).
@@ -211,14 +211,22 @@ static void ble_scan_callback(void *param) {
return;
const auto *info = reinterpret_cast<const ble_scan_report_t *>(param);
// Only legacy framing is supported (see scan_start(): legacy 1M PHY only):
// an extended report does not fit BLEScanReport::data and would reach
// consumers as a truncated legacy frame. Reject before allocating so these
// do not burn pool slots either.
const uint8_t report_type = info->info & 0x07;
if (report_type != GAPM_REPORT_TYPE_ADV_LEG && report_type != GAPM_REPORT_TYPE_SCAN_RSP_LEG) {
s_ble->count_rejected_report();
return;
}
// 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;
return; // no slot — counted as dropped in allocate_scan_report()
// 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
@@ -231,7 +239,7 @@ static void ble_scan_callback(void *param) {
memcpy(slot->mac, info->trans_addr, 6);
slot->rssi = (raw > 20) ? static_cast<int8_t>(-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->is_scan_response = 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<uint8_t>(info->length)
: static_cast<uint8_t>(sizeof(slot->data));
@@ -243,7 +251,8 @@ static void ble_scan_callback(void *param) {
BLEScanReport *LN882HBLE::allocate_scan_report() {
BLEScanReport *slot = this->report_pool_.allocate();
if (slot == nullptr) {
// Pool exhausted — the queue is full; count and drop.
// No slot: pool exhausted (queue full) or the pool's on-demand RAM
// allocation failed; count and drop either way.
this->report_queue_.increment_dropped_count();
}
return slot;
@@ -319,24 +328,46 @@ void LN882HBLE::enable() {
}
void LN882HBLE::loop() {
// Log dropped reports before the empty-queue return: a drop can also mean
// EventPool::allocate() failed on heap exhaustion, and that can happen with
// the queue empty — from the very first report on. Checking here keeps that
// failure visible instead of producing a scanner that is silently dead.
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
if (dropped > 0)
ESP_LOGW(TAG, "Dropped %u scan reports (queue full or out of memory for a report slot)", dropped);
// 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 {
if (report != nullptr) {
this->reject_diagnosis_done_ = true;
do {
#ifdef LN882H_BLE_SCAN_LISTENER_COUNT
for (auto *listener : this->scan_listeners_)
listener->on_scan_report(*report);
for (auto *listener : this->scan_listeners_)
listener->on_scan_report(*report);
#endif
this->report_pool_.release(report);
} while ((report = this->report_queue_.pop()) != nullptr);
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);
// Rejected-report accounting AFTER the drain: a stray non-legacy frame
// arriving ahead of the first good one must not latch the dead-scanner
// warning; the threshold keeps one-off boot noise below it while a truly
// dead scanner (~200 reports/s all rejected) crosses it within a second.
// Avoid the sub-word CAS in the common case (LockFreeQueue's dropped-count
// pattern): rejects are rare, the load is cheap.
uint16_t rejected = this->rejected_reports_.load(std::memory_order_relaxed);
if (rejected > 0) {
rejected = this->rejected_reports_.exchange(0, std::memory_order_relaxed);
if (!this->reject_diagnosis_done_) {
this->rejected_before_delivery_ += rejected;
if (this->rejected_before_delivery_ >= REJECTED_DEAD_SCANNER_THRESHOLD) {
this->reject_diagnosis_done_ = true;
ESP_LOGW(TAG, "Rejected %u scan reports before any was delivered - unexpected report encoding?",
static_cast<unsigned>(this->rejected_before_delivery_));
}
}
ESP_LOGV(TAG, "Rejected %u non-legacy scan reports", rejected);
}
}
void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); }
@@ -9,6 +9,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#include <atomic>
#include <cstdint>
namespace esphome::ln882h_ble {
@@ -61,6 +62,10 @@ class BLEScanListener {
// during such stalls.
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64;
// Rejected frames tolerated before the first delivered report without
// declaring the scanner dead (boot-time stray extended frames are normal).
static constexpr uint16_t REJECTED_DEAD_SCANNER_THRESHOLD = 16;
class LN882HBLE final : public Component {
public:
void setup() override;
@@ -105,6 +110,10 @@ class LN882HBLE final : public Component {
/// 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);
/// Internal, rw-task context: count a report rejected by the legacy-only
/// filter, so a wrong assumption about the stack's report encoding shows up
/// in verbose logs instead of as a scanner that silently reports nothing.
void count_rejected_report() { this->rejected_reports_.fetch_add(1, std::memory_order_relaxed); }
protected:
void resolve_mac_();
@@ -126,10 +135,17 @@ class LN882HBLE final : public Component {
// allocate() returns nullptr before push() can fail. This prevents leaking a
// pool slot on a failed push and keeps release() off the producer path.
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
// Reports rejected by the legacy-only filter (rw-task producer, main-task
// consumer via exchange in loop()).
std::atomic<uint16_t> rejected_reports_{0};
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)
// Dead-scanner diagnosis: done once a report is delivered or the one-shot
// warning has fired, whichever comes first.
bool reject_diagnosis_done_{false};
uint32_t rejected_before_delivery_{0}; // drives the dead-scanner warning
};
} // namespace esphome::ln882h_ble
@@ -0,0 +1,63 @@
"""LN882H BLE scanner implementing the ble_device_base BLEHub contract on
top of the ln882h_ble controller. With continuous: false nothing scans until
an explicit start_scan() call."""
import esphome.codegen as cg
from esphome.components import ble_device_base, ln882h_ble, ota
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTIVE,
CONF_CONTINUOUS,
CONF_DURATION,
CONF_ID,
CONF_INTERVAL,
)
from esphome.types import ConfigType
CONF_LN882H_BLE_ID = "ln882h_ble_id"
DEPENDENCIES = ["ln882x"]
AUTO_LOAD = ["ble_device_base", "ln882h_ble"]
CODEOWNERS = ["@Bl00d-B0b"]
ln882h_ble_tracker_ns = cg.esphome_ns.namespace("ln882h_ble_tracker")
LN882HBLETracker = ln882h_ble_tracker_ns.class_(
"LN882HBLETracker", ble_device_base.BLEHub, cg.Component
)
# LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty).
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"100ms", window_default="50ms", supports_active=True
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LN882HBLETracker),
cv.GenerateID(CONF_LN882H_BLE_ID): cv.use_id(ln882h_ble.LN882HBLE),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_LN882H_BLE_ID])
cg.add(var.set_parent(parent))
# The tracker registers itself as a controller scan listener in setup();
# request the codegen-sized StaticVector slot for it.
ln882h_ble.request_scan_listener_slot()
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
ota.request_ota_state_listeners()
scan = config[CONF_SCAN_PARAMETERS]
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
@@ -0,0 +1,345 @@
#ifdef USE_LIBRETINY
#include "ln882h_ble_tracker.h"
#include <cinttypes>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::ln882h_ble_tracker {
static const char *const TAG = "ln882h_ble_tracker";
static constexpr float BLE_SCAN_UNIT_MS = 0.625f;
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
void LN882HBLETracker::setup() {
// Receive the controller's scan reports; the controller queues them from the
// rw task and delivers here on the main task.
this->parent_->register_scan_listener(this);
if (!this->scan_continuous_) {
// Say so once: with continuous: false nothing scans until an explicit
// start_scan() — silence here reads as a broken scanner.
ESP_LOGD(TAG, "Scanning not started (continuous: false) - waiting for an explicit start_scan()");
// Nothing to time until then; start_scan_() re-enables the loop.
this->disable_loop();
}
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update is in flight — on the single-core LN882H the
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
}
#ifdef USE_OTA_STATE_LISTENER
void LN882HBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_running_before_ota_ = this->scan_running_;
this->stop_scan();
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
// On success the device reboots, so restore only on a failed/aborted
// update. Continuous mode resumes via loop()'s idle branch; a one-shot
// scan that was running is restarted explicitly (bk72xx sibling parity —
// stop_scan() cleared it and nothing else would bring it back).
if (this->scan_continuous_before_ota_) {
this->scan_continuous_ = true;
this->enable_loop(); // stop_scan() disabled it; loop()'s idle branch restarts the scan
} else if (this->scan_running_before_ota_) {
this->start_scan();
}
this->scan_continuous_before_ota_ = false;
this->scan_running_before_ota_ = false;
}
}
#endif // USE_OTA_STATE_LISTENER
void LN882HBLETracker::loop() {
// Flush pending scannable advertisements whose scan response never arrived
// (device didn't answer / frame lost) — delivered unmerged after the timeout.
// Main-task only, like every consumer of pending_adv_.
const uint32_t now = millis();
if (this->pending_count_ != 0) {
for (auto &p : this->pending_adv_) {
if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) {
p.used = false;
this->pending_count_--;
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
}
if (this->scan_continuous_) {
if (!this->scan_running_) {
this->start_scan_();
// start_scan_() re-anchors scan_period_start_ from a later millis() than
// the cached `now`; resume the period timer next iteration.
return;
}
// Period timer: once per scan_duration_ window, restart the controller scan
// and fire on_scan_end(), mirroring esp32_ble_tracker::cleanup_scan_state_().
// The restart is the recovery path for the coexistence failure documented in
// the header. scan_start() re-enters cleanly on its own: it stops an
// in-flight scan and grants the controller's 10 ms GAPM settle before
// restarting — an explicit scan_stop() first would clear the controller's
// re-entry guard and skip that settle.
if (now - this->scan_period_start_ >= this->scan_duration_) {
ESP_LOGD(TAG, "Scan period elapsed - restarting scan");
this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_);
// Keep both clocks anchored to the restart: a runtime switch to
// non-continuous then times out the current period, not the whole run.
this->scan_start_time_ = now;
this->end_scan_period_(now);
}
return;
}
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
// Restart is driven externally (e.g. wifi: on_connect:).
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
this->stop_scan_();
}
}
bool LN882HBLETracker::request_scan_mode(bool active) {
if (this->scan_active_ == active)
return true;
this->scan_active_ = active;
ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive");
// scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and
// no period reset: the scan logically continues, only the mode changes.
if (this->scan_running_) {
this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_);
}
return true;
}
void LN882HBLETracker::dump_config() {
ESP_LOGCONFIG(TAG,
"LN882H BLE Tracker:\n"
" Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %.0f ms (%" PRIu16 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu16 " BLE units)\n"
" Scan Type: %s\n"
" Continuous Scanning: %s",
this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_,
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
YESNO(this->scan_continuous_));
}
// ---------------------------------------------------------------------------
// Adv/scan-response demux with Bluedroid-style merge: the LN controller
// delivers the pair as separate reports; a scannable advertisement is held
// until its scan response arrives and delivered as one merged frame.
// ---------------------------------------------------------------------------
void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) {
if (report.is_scan_response) {
this->deliver_scan_rsp_(report);
return;
}
// Stash only while the scan runs: after a one-shot stop the loop is
// disabled and nothing would sweep the table, so a late report would
// surface minutes later as a fresh advertisement.
if (this->scan_running_ && this->scan_active_ && report.scannable) {
this->stash_adv_(report);
return;
}
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false);
}
// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its
// scan response.
void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) {
// One pass: find a same-device entry (deliver + reuse) while remembering the
// first free slot as the fallback.
PendingAdv *slot = nullptr;
PendingAdv *free_slot = nullptr;
for (auto &p : this->pending_adv_) {
if (!p.used) {
if (free_slot == nullptr)
free_slot = &p;
continue;
}
if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) {
// Same device advertised again before its scan response arrived — deliver
// the previous advertisement (its scan response is not coming) and reuse
// the slot, so no frame is ever lost.
p.used = false;
this->pending_count_--;
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
slot = &p;
break;
}
}
if (slot == nullptr)
slot = free_slot;
if (slot == nullptr) {
// Table full — degrade gracefully: deliver the advertisement unmerged.
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false);
return;
}
slot->used = true;
this->pending_count_++;
memcpy(slot->mac, report.mac, 6);
slot->addr_type = report.addr_type;
slot->rssi = report.rssi;
slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data);
memcpy(slot->data, report.data, slot->data_len);
slot->stored_ms = millis();
}
// Scan response arrived: merge it with the pending advertisement from the same
// device into ONE frame (ESP-IDF/Bluedroid semantics).
void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) {
// Fast-out on the empty table (loop()/flush use the same guard); this is
// the hottest caller.
if (this->pending_count_ != 0) {
for (auto &p : this->pending_adv_) {
if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) {
// Append in place: the slot is released on delivery, so its 62-byte
// buffer (legacy adv + scan response) holds the merged frame directly.
const uint8_t room = sizeof(p.data) - p.data_len;
const uint8_t add = (report.data_len <= room) ? report.data_len : room;
memcpy(p.data + p.data_len, report.data, add);
p.used = false;
this->pending_count_--;
// The advertisement's RSSI, not the scan response's: every unmerged path
// reports the advertisement's measurement, so a device's RSSI must not
// jump between two measurements depending on merge timing.
this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false);
return;
}
}
}
// Unmatched scan-response: goes out on the raw callback only (HA merges per
// address); local listeners/triggers receive each advertisement exactly once
// via the merged/plain path above.
this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true);
}
void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len, bool raw_only) {
// Raw callback (the raw-advertisement path). Both full advertisements and
// unmatched scan responses (raw_only) are forwarded.
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{
.mac = mac, .data = data, .data_len = data_len, .rssi = rssi, .addr_type = addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Scan-response-only frames are never parsed for local sensors/triggers.
if (raw_only)
return;
ble_device_base::ESPBTDevice device;
device.from_scan_result(mac, rssi, addr_type, data, data_len);
// The listener list holds sensors AND this tracker's automation triggers
// (the triggers are listeners, exactly like esp32_ble_tracker), so one
// loop feeds both and ORs into `found`.
bool found = false;
for (auto *listener : this->listeners_) {
if (listener->parse_device(device)) {
found = true;
}
}
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
// it and the scan is one-shot (continuous scans would spam).
if (!found && !this->scan_continuous_)
this->discovered_log_.log_device(TAG, device);
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
}
// ---------------------------------------------------------------------------
// Public scan actions
// ---------------------------------------------------------------------------
void LN882HBLETracker::start_scan() {
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
// set_scan_continuous() first, then calls start_scan() to begin scanning.
if (!this->scan_running_) {
this->start_scan_();
}
}
void LN882HBLETracker::stop_scan() {
this->scan_continuous_ = false;
this->stop_scan_();
}
// ---------------------------------------------------------------------------
// Internal scan start / stop
// ---------------------------------------------------------------------------
void LN882HBLETracker::start_scan_() {
if (this->scan_running_)
return;
// The controller enables the stack on first use and owns the report queue;
// this call is all the SDK interaction the tracker ever needs.
this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_);
const uint32_t now = millis();
this->scan_running_ = true;
this->scan_start_time_ = now;
this->enable_loop(); // an idle non-continuous tracker disabled it in stop_scan_()
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
// in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up.
ESP_LOGD(TAG, "BLE scan started (%s, window=%.0fms, interval=%.0fms)", this->scan_active_ ? "active" : "passive",
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS);
// Re-anchor the on_scan_end period to every successful start, so a restart
// later than scan_duration (e.g. a failed OTA restoring continuous mode)
// does not fire on_scan_end before an advertisement can arrive.
this->scan_period_start_ = now;
}
void LN882HBLETracker::stop_scan_() {
if (!this->scan_running_)
return;
this->parent_->scan_stop();
this->scan_running_ = false;
// DEBUG like start_scan_() — a per-period stop at INFO would read as the
// scanner failing to come back up.
ESP_LOGD(TAG, "BLE scan stopped");
this->end_scan_period_(millis()); // also resets the period clock so on_scan_end does not double-fire
if (!this->scan_continuous_) {
// Nothing left to time; start_scan_() re-enables the loop.
this->disable_loop();
}
}
// Close a scan period: deliver held advertisements whose scan response never
// came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock.
void LN882HBLETracker::end_scan_period_(uint32_t now) {
this->flush_pending_adv_();
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
#endif
this->scan_period_start_ = now;
}
// Deliver every held advertisement now (scan period/scan is ending): unmerged
// delivery, same as the timeout path in loop(). Main-task only.
void LN882HBLETracker::flush_pending_adv_() {
if (this->pending_count_ == 0)
return;
for (auto &p : this->pending_adv_) {
if (p.used) {
p.used = false;
this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
this->pending_count_ = 0;
}
} // namespace esphome::ln882h_ble_tracker
#endif // USE_LIBRETINY
@@ -0,0 +1,176 @@
// BLE scanner for LN882H: implements ble_device_base::BLEHub on top of the
// ln882h_ble controller (which owns all SDK calls and delivers scan reports on
// the main task). Scan policy lives here: parameters, period timers with
// per-period restart, and the adv+scan-response merge.
#pragma once
#ifdef USE_LIBRETINY
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/ln882h_ble/ln882h_ble.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
#endif
namespace esphome::ln882h_ble_tracker {
// ---------------------------------------------------------------------------
// LN882HBLETracker
// ---------------------------------------------------------------------------
class LN882HBLETracker : public Component,
public ble_device_base::BLEHub,
public Parented<ln882h_ble::LN882HBLE>,
public ln882h_ble::BLEScanListener
#ifdef USE_OTA_STATE_LISTENER
,
public ota::OTAGlobalStateListener
#endif
{
public:
// ---- ESPHome Component ----
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention);
// mirrors esp32_ble_tracker.
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
// ---- YAML configuration setters ----
void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; }
void set_scan_interval(uint16_t scan_interval) { this->scan_interval_ = scan_interval; }
void set_scan_window(uint16_t scan_window) { this->scan_window_ = scan_window; }
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
// ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
void start_scan();
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
// The LN882H controller supports active scanning; adv + scan response arrive
// as separate reports and are merged by this tracker (Bluedroid semantics).
// The SDK's GATT client is not exposed.
return {.active_scan = true, .merges_scan_response = true, .gatt = false};
}
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
}
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return this->scan_active_; }
bool request_scan_mode(bool active) override;
// ---- ln882h_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
// rw-task → main-task handoff already happened in the controller's queue.
// Demultiplexes advertisements vs scan responses and drives the merge.
void on_scan_report(const ln882h_ble::BLEScanReport &report) override;
protected:
// Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into
// one result before ESPHome sees it; the LN controller reports them separately):
// a scannable advertisement is held here briefly, its scan response is appended
// on arrival and the pair is delivered as ONE merged frame. Held entries whose
// scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS.
// All of this runs on the main task (the controller queue already crossed tasks),
// so no locking is involved.
void stash_adv_(const ln882h_ble::BLEScanReport &report);
void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report);
// Dispatch one (possibly merged) advertisement: the raw
// callback, and — unless raw_only — parsing for listeners/triggers. raw_only
// marks unmatched scan-response frames: forwarded on the raw callback only,
// never to local sensors/triggers (HA merges per address).
void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only);
void start_scan_();
void stop_scan_();
// Close a scan period: flush held advertisements (unmerged) BEFORE
// on_scan_end fires, then re-anchor the period clock to `now`.
void end_scan_period_(uint32_t now);
void flush_pending_adv_();
bool scan_running_{false};
bool scan_active_{false};
// Defaults are the LN882H SDK's recommended scan parameters
// (ln_ble_scan.h: SCAN_INTERVAL_DEF 0xA0, SCAN_WINDOW_DEF 0x50 → 50 % duty).
// uint16_t matches the controller's scan_start() parameters.
uint16_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms (SDK SCAN_INTERVAL_DEF)
uint16_t scan_window_{80}; // 80 × 0.625 ms = 50 ms (SDK SCAN_WINDOW_DEF; 50/100 = 50 %)
uint32_t scan_duration_{300000};
bool scan_continuous_{true};
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
bool scan_running_before_ota_{false}; // one-shot scan running at OTA start, restarted on OTA failure
#endif
uint32_t scan_start_time_{0};
// Pending scannable advertisements awaiting their scan response (active scan).
// 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as
// ESP-IDF delivers on ESP32. Main-task only.
struct PendingAdv {
bool used{false};
uint8_t mac[6];
uint8_t addr_type;
int8_t rssi;
uint8_t data_len; // <= sizeof(data)
uint8_t data[62];
uint32_t stored_ms;
};
// Sized for the unanswered case: a pair that IS answered normally matches
// within one queue drain, so a slot is held for the full timeout only by
// scannable devices that never reply. 8 concurrent such advertisers before
// the merge degrades (frames still delivered, just unmerged) at ~80 B each.
static constexpr size_t MAX_PENDING_ADV = 8;
// On air a scan response follows its advertisement by T_IFS (150 µs) — the
// timeout only covers HOST-side report queuing in rw_task under WiFi/BLE
// coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin,
// while staying below any device's re-advertising period.
static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300;
PendingAdv pending_adv_[MAX_PENDING_ADV];
// Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table
// in the common case (empty: passive scan, or every pair already matched).
uint8_t pending_count_{0};
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Parsed-advertisement consumers registered through ble_device_base.
// Codegen-sized: no heap allocation, no std::vector template instantiations.
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
// Per-period "Found device" DEBUG log with MAC dedup — shared implementation
// in ble_device_base, identical output on every tracker backend. Guarded like
// its only writer so a no-listener build does not carry an unused vector.
ble_device_base::DiscoveredDeviceLog discovered_log_{};
#endif
};
} // namespace esphome::ln882h_ble_tracker
#endif // USE_LIBRETINY
+1
View File
@@ -458,6 +458,7 @@
#define BK72XX_BLE_SCAN_LISTENER_COUNT 1
#define USE_LN882H_BLE
#define LN882H_BLE_SCAN_LISTENER_COUNT 1
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_CAPTIVE_PORTAL
#define USE_WIFI_SCAN_RESULTS_LOCK
#define USE_SOCKET_IMPL_LWIP_SOCKETS
@@ -10,6 +10,9 @@ from esphome.components.bk72xx_ble_tracker import (
)
from esphome.components.ble_device_base import to_ble_units
from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA
from esphome.components.ln882h_ble_tracker import (
SCAN_PARAMETERS_SCHEMA as LN882H_SCHEMA,
)
from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA
@@ -69,6 +72,15 @@ def test_rp2_defaults_are_valid() -> None:
assert config["active"] is True
def test_ln882h_defaults_are_valid() -> None:
"""ln882h pins the LN SDK reference rate — 100 ms interval / 50 ms window
(50 % duty) — and exposes active (default on)."""
config = LN882H_SCHEMA({})
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 80
assert config["active"] is True
def test_esp32_active_can_disable() -> None:
config = ESP32_SCHEMA({"active": False})
assert config["active"] is False
@@ -0,0 +1,12 @@
ln882h_ble_tracker:
id: ble_tracker
scan_parameters:
# Boundary coverage: the documented 2.5 ms floor on window (expressible only
# via the microsecond-accurate validation), a non-round interval exercising the
# 0.625 ms unit conversion without collapsing onto the window's unit count,
# and the non-continuous config path.
interval: 5000us
window: 2500us
duration: 5min
active: false
continuous: false
@@ -0,0 +1,16 @@
ln882h_ble_tracker:
id: ble_tracker
scan_parameters:
interval: 100ms
window: 50ms
duration: 5min
continuous: true
# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI
# (same coverage arrangement as the rp2_ble_tracker tests).
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
@@ -0,0 +1,2 @@
packages:
ln882h_ble_tracker: !include common.yaml
@@ -0,0 +1,2 @@
packages:
ln882h_ble_tracker: !include common-boundary.yaml