From 90927ba788d2c00dbb505d16d0b43c64ba630875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 25 Aug 2026 06:57:01 +0300 Subject: [PATCH] [rp2_ble_tracker] Automation triggers and scan actions (#18717) --- .../components/rp2_ble_tracker/__init__.py | 110 ++++++++++++- .../components/rp2_ble_tracker/automation.h | 47 ++++++ .../rp2_ble_tracker/rp2_ble_tracker.cpp | 152 ++++++++++++------ .../rp2_ble_tracker/rp2_ble_tracker.h | 47 ++++-- .../rp2_ble_tracker/__init__.py | 0 .../config/test_automations.yaml | 45 ++++++ .../test_automations_codegen.py | 60 +++++++ .../rp2_ble_tracker/common-automations.yaml | 54 +++++++ .../test-automations.rp2040-ard.yaml | 3 + 9 files changed, 446 insertions(+), 72 deletions(-) create mode 100644 esphome/components/rp2_ble_tracker/automation.h create mode 100644 tests/component_tests/rp2_ble_tracker/__init__.py create mode 100644 tests/component_tests/rp2_ble_tracker/config/test_automations.yaml create mode 100644 tests/component_tests/rp2_ble_tracker/test_automations_codegen.py create mode 100644 tests/components/rp2_ble_tracker/common-automations.yaml create mode 100644 tests/components/rp2_ble_tracker/test-automations.rp2040-ard.yaml diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 7709df9899..b744aee31c 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -4,14 +4,15 @@ Scan modes: continuous: true — scan runs forever; never stops automatically. continuous: false — a started scan runs for `duration`, then stops. The first start is external too; nothing starts a non-continuous - scan on boot. Until start/stop automation actions land - (follow-up PR), starting means a lambda: - `id(my_tracker).start_scan();`. + scan on boot — use the rp2_ble_tracker.start_scan action + (e.g. from api: on_client_connected:). """ +from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, ota, rp2040_ble -from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.rp2040_ble import CONF_RP2040_BLE_ID import esphome.config_validation as cv from esphome.const import ( @@ -20,7 +21,13 @@ from esphome.const import ( CONF_DURATION, CONF_ID, CONF_INTERVAL, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, ) +from esphome.core import ID from esphome.types import ConfigType DEPENDENCIES = ["rp2"] @@ -34,6 +41,14 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( "RP2BLETracker", ble_device_base.BLEHub, cg.Component ) +StartScanAction = rp2_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = rp2_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger + # interval defaults to 100 ms with the shared 30 ms window, a 30 % duty cycle — # the same defaults as bk72xx_ble_tracker, leaving the radio mostly free for @@ -48,6 +63,24 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(): cv.declare_id(RP2BLETracker), cv.GenerateID(CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE), cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger + ), + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, + ), + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -76,4 +109,71 @@ async def to_code(config: ConfigType) -> None: 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])) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + await ble_automation.advertise_trigger_to_code(conf, var) + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix + ) + + for conf in config.get(CONF_ON_SCAN_END, []): + await ble_automation.scan_end_trigger_to_code(conf, var) + + +@automation.register_action( + "rp2_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(RP2BLETracker), + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "rp2_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(RP2BLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/rp2_ble_tracker/automation.h b/esphome/components/rp2_ble_tracker/automation.h new file mode 100644 index 0000000000..a3cede236a --- /dev/null +++ b/esphome/components/rp2_ble_tracker/automation.h @@ -0,0 +1,47 @@ +// Scan-control actions for rp2_ble_tracker. The automation triggers are the +// neutral ble_device_base classes (ble_device_base/automation.h). + +#pragma once + +#ifdef USE_RP2 + +#include "rp2_ble_tracker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::rp2_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::rp2_ble_tracker + +#endif // USE_RP2 diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index 06beb186ae..a0a04b981d 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -11,10 +11,8 @@ namespace esphome::rp2_ble_tracker { static const char *const TAG = "rp2_ble_tracker"; -// Minimum interval between scan start attempts on an active stack. The -// controller start has no failure mode once HCI is WORKING, so this fires at -// most once per enable cycle today; the floor is insurance against a future -// scan_start() failure being retried every main-loop iteration. +// Floor between controller start attempts; insurance against a failing +// scan_start() being retried every loop. static constexpr uint32_t SCAN_START_RETRY_MS = 1000; // One BLE scan unit in milliseconds; the controller programs interval/window in these units. @@ -32,7 +30,8 @@ void RP2BLETracker::setup() { // the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker. ota::get_global_ota_callback()->add_global_state_listener(this); #endif - if (!this->scan_continuous_) { + // An on_boot start_scan runs before setup(); parking here would strand it. + if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) { // Nothing to do until an external start_scan(); the loop is re-enabled there. this->disable_loop(); } @@ -41,12 +40,21 @@ void RP2BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + // Set before stop_scan(): its on_scan_end automations run synchronously and + // may call start_scan(), which must defer instead of resuming the radio. + this->ota_in_progress_ = true; this->scan_continuous_before_ota_ = this->scan_continuous_; - // A one-shot scan counts as pending when it is running or still retrying - // its start (loop enabled); captured before stop_scan() disables the loop. - this->scan_pending_before_ota_ = !this->scan_continuous_ && (this->scan_running_ || this->is_in_loop_state()); + // A one-shot scan counts as pending when it is running, latched, or still + // retrying its start (loop enabled); captured before stop_scan() parks it. + this->scan_pending_before_ota_ = + !this->scan_continuous_ && (this->scan_running_ || this->pending_start_ || this->is_in_loop_state()); + // The pause's own stop is not a user stop, so it must not clear the latches + // captured just above. + this->ota_pausing_ = true; this->stop_scan(); + this->ota_pausing_ = false; } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + this->ota_in_progress_ = false; // On success the device reboots, so restore only on a failed/aborted update; // loop()'s retry branch restarts the scan on its next iteration. if (this->scan_continuous_before_ota_) { @@ -54,9 +62,7 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin this->scan_continuous_ = true; this->enable_loop(); } - // A one-shot scan interrupted by the OTA resumes for a fresh duration - // rather than silently staying idle — an OTA failure does not reboot, so - // nothing external would restart it. + // A failed OTA does not reboot, so nothing else would restart a one-shot. if (this->scan_pending_before_ota_) { this->scan_pending_before_ota_ = false; this->enable_loop(); @@ -66,27 +72,34 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin #endif // USE_OTA_STATE_LISTENER void RP2BLETracker::loop() { +#ifdef USE_OTA_STATE_LISTENER + // Keeps "no radio during an OTA" local instead of emergent from the + // parking sites. + if (this->ota_in_progress_) + return; +#endif const uint32_t now = App.get_loop_component_start_time(); + if (this->pending_start_ && this->parent_->is_active()) { + // Latched start, applied once the stack is ACTIVE; earlier attempts would + // fail and arm the retry floor for nothing. + this->pending_start_ = false; + if (!this->scan_running_) + this->start_scan_(); + } // 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 - // retry branch below takes over once the user re-enables the stack. + // Stack disabled underneath us; reconcile so the retry branch takes over. this->scan_running_ = false; this->fire_scan_end_(); } if (!this->scan_running_) { - // A scan should be running but is not: continuous mode is always in this - // state until the start succeeds, and non-continuous mode only reaches - // here between start_scan() and a successful controller start, because - // stop_scan_() disables the loop otherwise. + // Should be scanning but is not: continuous until the start succeeds, + // one-shot only between start_scan() and a successful controller start. if (!this->parent_->is_active()) { - // Stack not up (still booting, or the user called disable()) — - // scan_start() cannot succeed, so there is nothing to attempt; scanning - // starts on the first iteration after HCI reaches WORKING. + // Stack not up: scan_start() cannot succeed yet. return; } if (now - this->last_scan_start_attempt_ >= SCAN_START_RETRY_MS) { @@ -107,7 +120,7 @@ void RP2BLETracker::loop() { // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. // Restart is driven externally (e.g. api: on_client_connected:). - if (now - this->scan_period_start_ >= this->scan_duration_) { + if (now - this->scan_start_time_ >= this->scan_duration_) { this->stop_scan_(); } } @@ -126,24 +139,20 @@ void RP2BLETracker::dump_config() { YESNO(this->scan_continuous_)); } -// 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. +// Core spec advertising report event types (BTstack headers stay out of this +// TU). ADV_IND and ADV_SCAN_IND are the scannable ones. 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; -// 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. +// BTstack delivers the pair as separate reports; the merger holds a scannable +// advertisement until its response arrives. 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; } - // 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. + // Only while an active scan runs: nothing sweeps the merger after a stop. 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, @@ -157,20 +166,49 @@ void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { void RP2BLETracker::start_scan() { // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via // set_scan_continuous() first, then calls start_scan() to begin scanning. +#ifdef USE_OTA_STATE_LISTENER + if (this->ota_in_progress_) { + // Defer to the post-OTA resume path, carrying the requested mode. Not + // while ota_pausing_: scan_continuous_ is an artefact of the pause's own + // stop there, not intent. + if (!this->ota_pausing_) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + this->scan_pending_before_ota_ = !this->scan_continuous_; + } + return; + } +#endif this->enable_loop(); + if (!this->is_ready() || !this->parent_->is_active()) { + // Pre-setup or stack not ACTIVE: latch, loop() applies it. + this->pending_start_ = true; + return; + } + // bk72xx force semantics: a user start jumps the floor only while the + // controller is healthy. loop()'s retry branch picks the request up. + if (this->last_start_failed_ && + App.get_loop_component_start_time() - this->last_scan_start_attempt_ < SCAN_START_RETRY_MS) { + return; + } this->start_scan_(); } +void RP2BLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; // start_scan_() anchors the clock itself on the next real start + // One-shot clock only (bk72xx parity); re-anchoring the period would let + // repeated actions starve on_scan_end. Same clock as loop()'s now. + this->scan_start_time_ = App.get_loop_component_start_time(); +} + bool RP2BLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; // V: the proxy's "Setting scanner mode" line already narrates this at D. ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); - // Apply to a running scan by restarting the CONTROLLER scan with the new - // mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the - // scan logically continues, only the request mode changes), no period reset. - // An idle scanner picks the mode up on its next start. + // Restart the controller scan only: the scan logically continues, so no + // on_scan_end and no period reset. An idle scanner applies it on next start. if (this->scan_running_) { this->parent_->scan_stop(); if (!this->controller_scan_start_()) { @@ -184,20 +222,34 @@ bool RP2BLETracker::request_scan_mode(bool active) { } void RP2BLETracker::stop_scan() { + // Cancel a start latched before setup(); without this an on_boot + // start_scan/stop_scan pair would still start at the first loop(). + this->pending_start_ = false; this->scan_continuous_ = false; +#ifdef USE_OTA_STATE_LISTENER + // A user stop during the OTA is the latest intent; the pause's own stop + // (ota_pausing_) is exempt - it armed that state. + if (this->ota_in_progress_ && !this->ota_pausing_) { + this->scan_pending_before_ota_ = false; + this->scan_continuous_before_ota_ = false; + } +#endif this->stop_scan_(); - // stop_scan_() early-returns when no scan is running, so disable the loop - // here too: a scan that never came up (stack still powering on at OTA start) - // must not keep attempting scan_start() from the loop's retry branch. - this->disable_loop(); + // stop_scan_() early-returns when idle, so park here too - once set up, and + // re-checked: its synchronous on_scan_end may have restarted the scan. + if (this->is_ready() && !this->scan_running_ && !this->pending_start_) { + this->disable_loop(); + } } // Stamp-and-start for every controller scan attempt: the stamp keeps the // SCAN_START_RETRY_MS floor covering all callers, not only loop()'s retry. bool RP2BLETracker::controller_scan_start_() { this->last_scan_start_attempt_ = App.get_loop_component_start_time(); - return this->parent_->scan_start(static_cast(this->scan_interval_), - static_cast(this->scan_window_), this->scan_active_); + const bool ok = this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); + this->last_start_failed_ = !ok; + return ok; } void RP2BLETracker::start_scan_() { @@ -208,19 +260,15 @@ void RP2BLETracker::start_scan_() { return; this->scan_running_ = true; - // 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. + // Symmetric with stop_scan_()'s stop log; asymmetry would read as the + // scanner failing to come back. ESP_LOGD(TAG, "Scan started (%s, window=%.0fms, interval=%.0fms)", this->scan_active_ ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("passive"), this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS); - // Re-anchor the scan period to every successful start — first start (so the - // period counts from the scan, not from boot) and every restart after a stop (so - // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous - // mode 10 minutes later, does not fire on_scan_end before an advertisement can - // arrive). Same clock as loop()'s `now`: a fresh millis() here would be ahead of - // the cached loop time and make the same-iteration period check underflow. + // Anchor the period to the scan, not to boot, so a restart after a long gap + // does not fire on_scan_end immediately. Same clock as loop()'s now. this->scan_period_start_ = App.get_loop_component_start_time(); + this->scan_start_time_ = this->scan_period_start_; } void RP2BLETracker::stop_scan_() { @@ -232,7 +280,9 @@ void RP2BLETracker::stop_scan_() { this->fire_scan_end_(); // Reset the period clock so on_scan_end does not double-fire; same clock as loop(). this->scan_period_start_ = App.get_loop_component_start_time(); - if (!this->scan_continuous_) { + // on_scan_end runs synchronously and may restart the scan; re-check before + // parking or that scan runs untimed. + if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) { // Nothing left to time; start_scan() re-enables the loop. this->disable_loop(); } diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 431f2daec7..af5250fe7e 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -44,11 +44,20 @@ class RP2BLETracker : public Component, void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; } void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + void set_configured_continuous(bool scan_continuous) { + this->configured_continuous_ = scan_continuous; + this->scan_continuous_ = scan_continuous; + } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->configured_continuous_; } // ---- Public scan control ---- // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). void start_scan(); void stop_scan(); + // Re-anchors the one-shot duration clock only (bk72xx parity); no-op while + // idle. Policy lives in the action. + void restart_scan_duration(); // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { @@ -58,10 +67,8 @@ class RP2BLETracker : public Component, this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // 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). + // Scan responses arrive separately and are merged before delivery + // (Bluedroid semantics). GATT needs the BTstack connection backend. #ifdef USE_BLE_GATT_CLIENT constexpr bool has_gatt = true; #else @@ -77,8 +84,7 @@ class RP2BLETracker : public Component, bool request_scan_mode(bool active); // ---- rp2040_ble::BLEScanListener ---- - // Delivered by the controller's loop() on the ESPHome main loop — the - // IRQ → main-loop handoff already happened in the controller's queue. + // Delivered on the main loop; the controller's queue did the IRQ handoff. void on_scan_report(const rp2040_ble::BLEScanReport &report) override; protected: @@ -93,20 +99,29 @@ class RP2BLETracker : public Component, uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) uint32_t scan_duration_{300000}; uint32_t last_scan_start_attempt_{0}; // loop time of last start_scan_() attempt; rate-limits retries - uint32_t scan_period_start_{0}; // loop time at start of current scan period; rate-limits on_scan_end() - bool scan_running_{false}; - bool scan_active_{true}; + uint32_t scan_period_start_{0}; // continuous-mode on_scan_end period clock + uint32_t scan_start_time_{0}; // one-shot duration clock (bk72xx parity: kept separate from the period) + // Bit-packed (C++20 default member initializers on bit-fields); + // scan_continuous_ stays a plain bool because the merger binds its address. + bool scan_running_ : 1 {false}; + bool pending_start_ : 1 {false}; // start_scan() latched before setup() or while the stack is + // not ACTIVE; loop() applies it once it is + bool last_start_failed_ : 1 {false}; // last controller start failed; gates the public start_scan() floor + bool scan_active_ : 1 {true}; + bool configured_continuous_ : 1 {true}; // YAML scan_parameters.continuous; runtime stop_scan() must not lose it 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_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure + // Resume intent for a failed/aborted OTA: seeded at OTA start, overwritten + // by a start/stop during the download, except from the pause's own stop. + bool scan_continuous_before_ota_ : 1 {false}; // resume continuous + bool scan_pending_before_ota_ : 1 {false}; // resume a one-shot scan + bool ota_in_progress_ : 1 {false}; // OTA holds the radio; start_scan() defers to the resume path + bool ota_pausing_ : 1 {false}; // inside the OTA's own stop_scan(); its latch clear is skipped #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. + // Shared merge + dispatch (ble_device_base), all on the main loop. + // stash_adv() uses the parent's cached loop time and sweep() this one's - + // same App.loop() pass, so the merger delta stays non-negative. ble_device_base::ScanResponseMerger merger_; ble_device_base::AdvDispatcher dispatcher_; }; diff --git a/tests/component_tests/rp2_ble_tracker/__init__.py b/tests/component_tests/rp2_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/rp2_ble_tracker/config/test_automations.yaml b/tests/component_tests/rp2_ble_tracker/config/test_automations.yaml new file mode 100644 index 0000000000..cf0d4f1567 --- /dev/null +++ b/tests/component_tests/rp2_ble_tracker/config/test_automations.yaml @@ -0,0 +1,45 @@ +esphome: + name: rp2-trigger-codegen + on_boot: + then: + - rp2_ble_tracker.start_scan: + continuous: true + # Bare form: restores the configured scan_parameters mode — no + # set_continuous emitted (asserted in the codegen test). + - rp2_ble_tracker.start_scan: + - rp2_ble_tracker.stop_scan + +rp2: + board: rpipicow + +rp2_ble_tracker: + scan_parameters: + continuous: false + active: false + on_ble_advertise: + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));' + on_ble_service_data_advertise: + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + mac_address: AC:37:43:77:5F:4C + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - service_uuid: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_scan_end: + - then: + - lambda: 'ESP_LOGD("t", "end");' diff --git a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py new file mode 100644 index 0000000000..fdcf2bcf75 --- /dev/null +++ b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py @@ -0,0 +1,60 @@ +"""Codegen tests for the tracker automations. + +The shared trigger classes (ble_device_base/automation.h) are compiled by the +rp2040 compile fixtures, but the codegen accounting — the getattr-built setter +spellings, the single set_continuous pin and the listener-count define — is +only checkable from the generated main, mirroring the bk72xx/ln882h tests.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components import ble_device_base +from tests.component_tests.helpers import get_define_value + + +def test_trigger_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_automations.yaml")) + + # on_ble_advertise: multi-mac filter (two addresses in one initializer list) + assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter + assert ( + "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + assert "set_address(0xAC3743775F4CULL)" in main_cpp + # 32-bit middle branch of the width dispatch + assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp + # All three manufacturer widths: getattr() builds these names as strings, + # so a misspelling only ever fails here. + assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp + assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp + assert ( + "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + # scan-control actions: templatable continuous lambda + parented actions. + # Exactly one set_continuous: the bare start_scan emits none, pinning the + # restore-configured-mode divergence from esp32 against a future default=. + assert main_cpp.count("->set_continuous(") == 1 + assert "startscanaction_id->set_continuous(" in main_cpp + assert "stopscanaction_id->set_parent(" in main_cpp + # scan_parameters continuous: false reaches the YAML-mode setter, not the + # runtime override. + assert "->set_configured_continuous(false)" in main_cpp + # active: false (non-default) flows through to the setter. + assert "->set_scan_active(false)" in main_cpp + # Constructor call, not just the declaration: the parent argument is what + # registers the trigger as a listener. + assert re.search( + r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp + ) + + # Seven triggers register as listeners; an undercount silently drops the + # last trigger at runtime (StaticVector::push_back past capacity), so the + # define is the assertion that matters most. + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7" diff --git a/tests/components/rp2_ble_tracker/common-automations.yaml b/tests/components/rp2_ble_tracker/common-automations.yaml new file mode 100644 index 0000000000..273cb5bb66 --- /dev/null +++ b/tests/components/rp2_ble_tracker/common-automations.yaml @@ -0,0 +1,54 @@ +esphome: + on_boot: + then: + - rp2_ble_tracker.start_scan + - rp2_ble_tracker.start_scan: + continuous: true + # Lambda arm of the templatable value — different codegen instantiation. + - rp2_ble_tracker.start_scan: + continuous: !lambda return false; + - rp2_ble_tracker.stop_scan + - rp2_ble_tracker.stop_scan: ble_tracker + +rp2_ble_tracker: + on_ble_advertise: + - mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); + - mac_address: + - AC:37:43:77:5F:4C + - AC:37:43:77:5F:4D + then: + - lambda: |- + char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD("main", "The device address is %s", x.address_str_to(addr)); + on_ble_service_data_advertise: + - service_uuid: ABCD + # mac_address exercises the UUID triggers' set_address() codegen branch. + mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + ESP_LOGD("main", "Length of service data is %zu", x.size()); + - service_uuid: ABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "32-bit service data is %zu", x.size()); + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit service data is %zu", x.size()); + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size()); + on_scan_end: + - then: + - lambda: |- + ESP_LOGD("main", "Scan ended"); diff --git a/tests/components/rp2_ble_tracker/test-automations.rp2040-ard.yaml b/tests/components/rp2_ble_tracker/test-automations.rp2040-ard.yaml new file mode 100644 index 0000000000..6710ee4373 --- /dev/null +++ b/tests/components/rp2_ble_tracker/test-automations.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + rp2_ble_tracker: !include common.yaml + automations: !include common-automations.yaml