diff --git a/CODEOWNERS b/CODEOWNERS index 03172a5b1bb..f9f87127681 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index c3b8982a92c..152ca571e9d 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -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(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(-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(info->length) : static_cast(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(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_)); } diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index e957cdd41e4..21868222084 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -9,6 +9,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/lock_free_queue.h" +#include #include 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 report_pool_; + // Reports rejected by the legacy-only filter (rw-task producer, main-task + // consumer via exchange in loop()). + std::atomic 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 diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py new file mode 100644 index 00000000000..30646dca9a2 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -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])) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp new file mode 100644 index 00000000000..b98ad228a8f --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -0,0 +1,345 @@ +#ifdef USE_LIBRETINY + +#include "ln882h_ble_tracker.h" + +#include +#include + +#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 diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h new file mode 100644 index 00000000000..00f1af16a43 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -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 + +#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, + 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 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 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 06a0ea5c469..231ba50dd7d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index bbf49539536..bd41a9476ae 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -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 diff --git a/tests/components/ln882h_ble_tracker/common-boundary.yaml b/tests/components/ln882h_ble_tracker/common-boundary.yaml new file mode 100644 index 00000000000..b6df6f6f392 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/common-boundary.yaml @@ -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 diff --git a/tests/components/ln882h_ble_tracker/common.yaml b/tests/components/ln882h_ble_tracker/common.yaml new file mode 100644 index 00000000000..aba02147c8a --- /dev/null +++ b/tests/components/ln882h_ble_tracker/common.yaml @@ -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 diff --git a/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml new file mode 100644 index 00000000000..6a9efad314c --- /dev/null +++ b/tests/components/ln882h_ble_tracker/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble_tracker: !include common.yaml diff --git a/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml b/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml new file mode 100644 index 00000000000..fc5790e3b28 --- /dev/null +++ b/tests/components/ln882h_ble_tracker/validate-boundary.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + ln882h_ble_tracker: !include common-boundary.yaml