[rp2_ble_tracker] Automation triggers and scan actions (#18717)

This commit is contained in:
Edvard Filistovič
2026-08-24 22:57:01 -05:00
committed by GitHub
parent 169c055011
commit 90927ba788
9 changed files with 446 additions and 72 deletions
+105 -5
View File
@@ -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
@@ -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<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<RP2BLETracker> {
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<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<RP2BLETracker> {
public:
void play(const Ts &...x) override { this->parent_->stop_scan(); }
};
} // namespace esphome::rp2_ble_tracker
#endif // USE_RP2
@@ -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<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
const bool ok = this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(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();
}
@@ -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_;
};
@@ -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");'
@@ -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"
@@ -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");
@@ -0,0 +1,3 @@
packages:
rp2_ble_tracker: !include common.yaml
automations: !include common-automations.yaml