[ble_device_base] Merge adv and scan response before delivery on rp2 (#18217)

This commit is contained in:
J. Nick Koston
2026-08-10 10:06:06 -05:00
committed by GitHub
parent d9567b2974
commit c8c929d487
11 changed files with 572 additions and 249 deletions
@@ -0,0 +1,150 @@
#include "scan_response_merger.h"
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include <cstring>
namespace esphome::ble_device_base {
void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len, bool raw_only) {
if (this->dispatcher_ == nullptr)
return;
this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only,
*this->scan_continuous_ ? nullptr : this->log_tag_);
}
void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len, uint32_t now) {
// 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 == addr_type && memcmp(p.mac, 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->deliver_(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->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false);
return;
}
slot->used = true;
this->pending_count_++;
memcpy(slot->mac, mac, 6);
slot->addr_type = addr_type;
slot->rssi = rssi;
slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data);
memcpy(slot->data, data, slot->data_len);
slot->stored_ms = now;
}
void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len) {
// Fast-out on the empty table (sweep/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 == addr_type && memcmp(p.mac, 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 = (data_len <= room) ? data_len : room;
memcpy(p.data + p.data_len, data, add);
p.used = false;
this->pending_count_--;
// The advertisement's RSSI, not the scan response's (header contract).
this->deliver_(mac, p.rssi, 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->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true);
}
void ScanResponseMerger::sweep(uint32_t now) {
if (this->pending_count_ == 0)
return;
for (auto &p : this->pending_adv_) {
if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) {
p.used = false;
this->pending_count_--;
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
}
void ScanResponseMerger::flush() {
if (this->pending_count_ == 0)
return;
for (auto &p : this->pending_adv_) {
if (p.used) {
p.used = false;
this->pending_count_--;
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
}
void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only, const char *log_unclaimed_tag) {
// Raw callback (the raw-advertisement path). Both full advertisements and
// unmatched scan responses (raw_only) are forwarded.
if (this->raw_callback_.is_set()) {
const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac),
.data = data,
.data_len = data_len,
.rssi = rssi,
.addr_type = addr_type};
this->raw_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;
ESPBTDevice device;
device.from_scan_result(mac, rssi, addr_type, data, data_len);
// The listener list holds sensors AND the 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;
}
}
if (!found && log_unclaimed_tag != nullptr)
this->discovered_log_.log_device(log_unclaimed_tag, device);
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
}
void AdvDispatcher::on_scan_end() {
#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
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_SCAN_RESPONSE_MERGER
@@ -0,0 +1,152 @@
// Shared support for trackers whose controller delivers advertisement and
// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates
// both into one result before ESPHome sees it):
//
// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is
// held briefly, its scan response is appended on arrival and the pair is
// delivered as ONE merged frame. Merged delivery is what the receiving side
// is built around: Home Assistant keeps the latest raw frame per device and
// skips re-parsing when it is unchanged — split delivery alternates two raw
// frames per device and defeats both.
//
// AdvDispatcher — the delivery half every such tracker repeats: raw
// callback, listener parsing, discovered-device log. Trackers delegate
// their BLEHub register_listener / set_raw_advertisement_callback here.
//
// The merger delivers straight into the tracker's AdvDispatcher — bind() wires
// the pair once in setup(). Single-task use only (every tracker calls this on
// the ESPHome main task). The clock is caller-provided: pass the same clock to
// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(),
// never mixed).
#pragma once
#include "esphome/core/defines.h"
// Emitted (cg.add_define) by each tracker that adopts the merger, so builds
// whose tracker merges in-stack (esp32) never compile this code.
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "ble_device.h"
#include "ble_hub.h"
#include "esphome/core/helpers.h"
#include <cstdint>
namespace esphome::ble_device_base {
/// The delivery half of a split-report tracker, shared so the dispatch
/// contract (raw-callback ordering, raw_only gate, discovered-log policy)
/// lives in one place. Owns the members every tracker otherwise duplicates;
/// the tracker's BLEHub methods delegate here.
class AdvDispatcher {
public:
void register_listener(ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; }
/// 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
/// parsed for local sensors/triggers (Home Assistant merges per address).
/// log_unclaimed_tag: when non-null, a device no listener claimed is logged
/// under this tag (esp32_ble_tracker parity: pass the tracker TAG on
/// one-shot scans, nullptr on continuous scans, which would spam).
void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only, const char *log_unclaimed_tag);
/// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup.
void on_scan_end();
protected:
RawAdvertisementCallback raw_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<ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
// Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only
// writer so a no-listener build does not carry an unused vector.
DiscoveredDeviceLog discovered_log_{};
#endif
};
class ScanResponseMerger {
public:
/// Wire the merger's output; call once in the tracker's setup(). Every
/// delivered frame goes to dispatcher->dispatch(); scan_continuous is read
/// at each delivery (runtime continuous flips are honored) to decide the
/// unclaimed-device log tag, so both pointers must outlive the merger —
/// tracker members always do.
void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) {
this->dispatcher_ = dispatcher;
this->scan_continuous_ = scan_continuous;
this->log_tag_ = log_tag;
}
/// Hold a scannable advertisement, waiting for its scan response. The
/// tracker calls this only when it wants the merge (scannable advertisement
/// while an active scan runs) and delivers everything else directly. A
/// same-device re-advertisement delivers the held frame (its scan response
/// is not coming) and reuses the slot; a full table degrades gracefully to
/// unmerged delivery.
void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
uint32_t now);
/// A scan response arrived: append it to the held advertisement from the
/// same device and deliver the pair as one frame. The merged frame reports
/// the ADVERTISEMENT's RSSI — every unmerged path reports the
/// advertisement's measurement, so a device's RSSI must not jump between two
/// measurements depending on merge timing. Unmatched responses are delivered
/// raw_only.
void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len);
/// Timeout flush (call from loop() with the stash_adv() clock): deliver
/// held advertisements whose scan response never arrived (device didn't
/// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS.
void sweep(uint32_t now);
/// Deliver every held advertisement now (scan period/scan is ending, before
/// on_scan_end fires): unmerged delivery, same as the timeout path.
void flush();
/// Lets loop() skip the cross-TU sweep() call in the common case (empty:
/// passive scan, or every pair already matched).
bool empty() const { return this->pending_count_ == 0; }
private:
/// All delivery funnels through here: an unbound merger (bind() not called)
/// drops the frame instead of jumping through a null pointer, mirroring the
/// guard-before-invoke convention of the ble_hub.h callback slots.
void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only);
// 62 bytes = legacy adv (31) + scan response (31), the same merged maximum
// as ESP-IDF delivers on ESP32.
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 report-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 under WiFi/BLE coexistence,
// measured on-device (ln882h) 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;
AdvDispatcher *dispatcher_{nullptr};
const bool *scan_continuous_{nullptr}; // read at delivery; see bind()
const char *log_tag_{nullptr};
// pending_count_ mirrors the number of set `used` flags; both are updated
// together on every transition.
PendingAdv pending_adv_[MAX_PENDING_ADV];
uint8_t pending_count_{0};
};
} // namespace esphome::ble_device_base
#endif // USE_BLE_SCAN_RESPONSE_MERGER
@@ -129,6 +129,9 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_LN882H_BLE_TRACKER")
# Compiles the shared adv + scan-response merge (the LN controller
# delivers the pair as separate reports).
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -3,7 +3,6 @@
#include "ln882h_ble_tracker.h"
#include <cinttypes>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
@@ -22,6 +21,9 @@ 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);
// Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_
// is read at each delivery to decide unclaimed-device logging.
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
// scan_running_ check: an on_boot start_scan action (priority 600) runs
// before this setup() (200) and enable_loop() is a no-op pre-setup — parking
// the loop here would strand that already-running scan.
@@ -72,19 +74,11 @@ void LN882HBLETracker::loop() {
this->start_scan_();
}
}
// 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_.
// Deliver held scannable advertisements whose scan response never arrived
// unmerged after the merger's timeout. Main-task only, like every merger call.
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->merger_.empty())
this->merger_.sweep(now);
if (this->scan_continuous_) {
if (!this->scan_running_) {
@@ -145,129 +139,25 @@ void LN882HBLETracker::dump_config() {
}
// ---------------------------------------------------------------------------
// 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.
// Adv/scan-response demux into the shared merger (ble_device_base): 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);
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
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
// disabled and nothing would sweep the merger, 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);
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis());
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{.address = ble_device_base::mac_lsb_first_to_uint64(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
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
}
// ---------------------------------------------------------------------------
@@ -356,29 +246,11 @@ void LN882HBLETracker::stop_scan_() {
// 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->merger_.flush();
this->dispatcher_.on_scan_end();
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
@@ -9,6 +9,7 @@
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/ble_device_base/scan_response_merger.h"
#include "esphome/components/ln882h_ble/ln882h_ble.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -75,12 +76,10 @@ class LN882HBLETracker : public Component,
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
this->dispatcher_.register_listener(listener);
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback;
this->dispatcher_.set_raw_advertisement_callback(callback);
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// The LN882H controller supports active scanning; adv + scan response arrive
@@ -108,27 +107,11 @@ class LN882HBLETracker : public Component,
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};
@@ -147,45 +130,13 @@ class LN882HBLETracker : public Component,
#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};
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
// All calls run on the main task (the controller queue already crossed
// tasks); the merger is clocked by millis() throughout this tracker.
ble_device_base::ScanResponseMerger merger_;
ble_device_base::AdvDispatcher dispatcher_;
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
@@ -57,6 +57,9 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_RP2_BLE_TRACKER")
# Compiles the shared adv + scan-response merge (BTstack delivers the pair
# as separate reports).
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -24,6 +24,9 @@ void RP2BLETracker::setup() {
// Receive the controller's scan reports; the controller queues them from the
// BTstack packet handler (IRQ) and delivers here on the main loop.
this->parent_->register_scan_listener(this);
// Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_
// is read at each delivery to decide unclaimed-device logging.
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update is in flight — the BLE scan competes with
// the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker.
@@ -64,6 +67,10 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin
void RP2BLETracker::loop() {
const uint32_t now = App.get_loop_component_start_time();
// Deliver held scannable advertisements whose scan response never arrived —
// unmerged after the merger's timeout.
if (!this->merger_.empty())
this->merger_.sweep(now);
if (this->scan_running_ && !this->parent_->is_active()) {
// The controller was disabled underneath us (e.g. a lambda calling
// rp2040_ble's disable()); the scan died with the stack. Reconcile so the
@@ -119,30 +126,32 @@ void RP2BLETracker::dump_config() {
YESNO(this->scan_continuous_));
}
void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) {
// Raw callback (the raw-advertisement path).
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac),
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
.addr_type = report.addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
// GAP advertising event types as BTstack reports them (Core spec advertising
// report event types; the tracker deliberately does not include BTstack
// headers). ADV_IND and ADV_SCAN_IND are the scannable types.
static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0;
static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2;
static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4;
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
ble_device_base::ESPBTDevice device;
device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
bool found = false;
for (auto *listener : this->listeners_) {
if (listener->parse_device(device))
found = true;
// Demux advertisements vs scan responses into the shared merger: BTstack
// delivers the pair as separate reports; a scannable advertisement is held
// until its scan response arrives and delivered as one merged frame.
void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) {
if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) {
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
return;
}
// 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
// Stash only while an active scan runs: a passive scan never gets a
// response, and after a stop nothing would sweep the merger, so a late
// report would surface minutes later as a fresh advertisement.
if (this->scan_running_ && this->scan_active_ &&
(report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) {
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
App.get_loop_component_start_time());
return;
}
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
}
void RP2BLETracker::start_scan() {
@@ -229,11 +238,10 @@ void RP2BLETracker::stop_scan_() {
}
void RP2BLETracker::fire_scan_end_() {
#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
// Deliver held advertisements whose scan response never came (unmerged)
// BEFORE on_scan_end fires.
this->merger_.flush();
this->dispatcher_.on_scan_end();
}
} // namespace esphome::rp2_ble_tracker
@@ -4,6 +4,7 @@
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/ble_device_base/scan_response_merger.h"
#include "esphome/components/rp2040_ble/rp2040_ble.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -51,25 +52,22 @@ class RP2BLETracker : public Component,
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
this->dispatcher_.register_listener(listener);
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback;
this->dispatcher_.set_raw_advertisement_callback(callback);
}
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// BTstack delivers scan responses as separate advertisement reports rather
// than merging them into the advertisement — consumers relying on
// scan-response fields (device names) get them only where the receiver
// merges per address (Home Assistant does). GATT is available when the
// BTstack connection backend is compiled in (bluetooth_proxy active).
// BTstack delivers scan responses as separate advertisement reports; this
// tracker merges the pair before delivery (shared ScanResponseMerger,
// Bluedroid semantics). GATT is available when the BTstack connection
// backend is compiled in (bluetooth_proxy active).
#ifdef USE_BLE_GATT_CLIENT
constexpr bool has_gatt = true;
#else
constexpr bool has_gatt = false;
#endif
return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true};
return {.active_scan = true, .merges_scan_response = true, .gatt = has_gatt, .scan_mode_switch = true};
}
// The controller stores the address in printable (MSB-first) order, which is
// exactly what the contract wants.
@@ -104,16 +102,13 @@ class RP2BLETracker : public Component,
bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure
#endif
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
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
// All calls run on the main loop. Merger clock: stash_adv() reads the
// PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue
// drain), sweep() this component's — same App.loop() pass, so the delta
// stays non-negative and the 300 ms timeout holds.
ble_device_base::ScanResponseMerger merger_;
ble_device_base::AdvDispatcher dispatcher_;
};
} // namespace esphome::rp2_ble_tracker
+2
View File
@@ -469,6 +469,7 @@
#define USE_RP2_BLE_TRACKER
#define RP2040_BLE_SCAN_LISTENER_COUNT 1
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_BLE_SCAN_RESPONSE_MERGER
#define USE_BLE_GATT_CLIENT
#define ESPHOME_BLE_GATT_CLIENT_COUNT 1
#define USE_RP2040_VARIANT_RP2040
@@ -500,6 +501,7 @@
#define USE_BK72XX_BLE_TRACKER
#endif
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_BLE_SCAN_RESPONSE_MERGER
#define USE_CAPTIVE_PORTAL
#define USE_WIFI_SCAN_RESULTS_LOCK
#define USE_SOCKET_IMPL_LWIP_SOCKETS
@@ -6,7 +6,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# resolve_irk() is compiled only when a sensor configures irk:
# (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has
# no sensors, so emit the define here to put the real IRK path under test.
# Likewise the scan-response merger (emitted by the split-report trackers)
# and the listener vector it dispatches into (codegen-sized by consumers).
async def to_code_testing(config):
cg.add_define("USE_BLE_DEVICE_IRK")
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4)
manifest.to_code = to_code_testing
@@ -0,0 +1,183 @@
// The host test build gets this from the manifest override; clang-tidy does not.
#ifndef USE_BLE_SCAN_RESPONSE_MERGER
#define USE_BLE_SCAN_RESPONSE_MERGER
#endif
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <vector>
#include "esphome/components/ble_device_base/scan_response_merger.h"
namespace esphome::ble_device_base::testing {
namespace {
// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot
// bookkeeping, the same-device reuse path, the table-full fallback, the
// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate.
// Delivery is observed through a real AdvDispatcher: the raw callback sees
// every frame (including raw_only), a listener only the parsed ones.
struct DeliveredFrame {
uint64_t address;
std::vector<uint8_t> data;
int8_t rssi;
};
struct RawCapture {
std::vector<DeliveredFrame> frames;
static void trampoline(void *self, const RawAdvertisement &adv) {
auto *capture = static_cast<RawCapture *>(self);
capture->frames.push_back({adv.address, std::vector<uint8_t>(adv.data, adv.data + adv.data_len), adv.rssi});
}
};
class CountingListener : public ESPBTDeviceListener {
public:
bool parse_device(const ESPBTDevice &device) override {
this->parsed++;
return true; // claimed: keeps the discovered log quiet
}
int parsed{0};
};
class ScanResponseMergerTest : public ::testing::Test {
protected:
void SetUp() override {
this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline});
this->dispatcher_.register_listener(&this->listener_);
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test");
}
void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) {
std::vector<uint8_t> data(data_len, fill);
this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now);
}
void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) {
std::vector<uint8_t> data(data_len, fill);
this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len);
}
ScanResponseMerger merger_;
AdvDispatcher dispatcher_;
RawCapture raw_;
CountingListener listener_;
bool scan_continuous_{true};
};
constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16};
TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) {
this->stash_(MAC_A, -40, 20, 0xAA);
EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered
this->scan_rsp_(MAC_A, -70, 10, 0xBB);
ASSERT_EQ(this->raw_.frames.size(), 1u);
const auto &frame = this->raw_.frames[0];
ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame
EXPECT_EQ(frame.data[0], 0xAA);
EXPECT_EQ(frame.data[19], 0xAA);
EXPECT_EQ(frame.data[20], 0xBB);
// The advertisement's RSSI, never the scan response's.
EXPECT_EQ(frame.rssi, -40);
EXPECT_EQ(this->listener_.parsed, 1);
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) {
this->stash_(MAC_A, -40, 20, 0xAA);
this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].data.size(), 20u);
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot
this->scan_rsp_(MAC_A, -70, 5, 0xBB);
ASSERT_EQ(this->raw_.frames.size(), 2u);
EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot
EXPECT_EQ(this->raw_.frames[1].rssi, -45);
}
TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) {
uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00};
for (uint8_t i = 0; i < 8; i++) {
mac[5] = i;
this->stash_(mac, -50, 10, i);
}
EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held
mac[5] = 8;
this->stash_(mac, -50, 10, 8); // 9th device: no slot left
ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
this->merger_.flush(); // the 8 held frames are all still intact
EXPECT_EQ(this->raw_.frames.size(), 9u);
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) {
this->stash_(MAC_A, -40, 31, 0xAA);
this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].data.size(), 62u);
EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB);
EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB);
}
TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) {
this->scan_rsp_(MAC_B, -60, 12, 0xDD);
ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path
EXPECT_EQ(this->raw_.frames[0].rssi, -60);
EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners
}
TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) {
std::vector<uint8_t> adv(20, 0xAA);
this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0);
std::vector<uint8_t> rsp(10, 0xBB);
this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size());
// Same MAC, different addr_type: no merge — the response goes out raw_only.
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
EXPECT_EQ(this->listener_.parsed, 0);
EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held
}
TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) {
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout
EXPECT_TRUE(this->raw_.frames.empty());
this->merger_.sweep(1301);
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) {
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000);
this->merger_.flush();
EXPECT_EQ(this->raw_.frames.size(), 2u);
EXPECT_EQ(this->listener_.parsed, 2);
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) {
ScanResponseMerger unbound;
std::vector<uint8_t> data(20, 0xAA);
unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
unbound.sweep(1000);
unbound.flush(); // no null jump anywhere
EXPECT_TRUE(unbound.empty());
}
} // namespace
} // namespace esphome::ble_device_base::testing