[rp2_ble_tracker] BLE tracker for Raspberry Pi Pico W (#18002)

This commit is contained in:
J. Nick Koston
2026-08-03 19:53:07 -05:00
committed by GitHub
parent 5d89e432da
commit a50369fc0e
11 changed files with 463 additions and 0 deletions
+1
View File
@@ -438,6 +438,7 @@ esphome/components/rp2/* @jesserockz
esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
esphome/components/rp2040_pwm/* @jesserockz
esphome/components/rp2_ble_tracker/* @bdraco
esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet @ximex
+14
View File
@@ -17,6 +17,20 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_SCHEMA)
def _validate_board(config: ConfigType) -> ConfigType:
from esphome.components.rp2 import board_has_wifi, get_board
if not board_has_wifi():
raise cv.Invalid(
f"Board '{get_board()}' does not have Bluetooth support (no CYW43 wireless "
f"chip). Use a board like 'rpipicow' or 'rpipico2w'."
)
return config
FINAL_VALIDATE_SCHEMA = _validate_board
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -0,0 +1,73 @@
"""BLE scanner for the Raspberry Pi Pico W / Pico 2 W (BLEHub on rp2040_ble).
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();`.
"""
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
import esphome.config_validation as cv
from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
CONF_RP2040_BLE_ID = "rp2040_ble_id"
DEPENDENCIES = ["rp2"]
AUTO_LOAD = ["ble_device_base", "rp2040_ble"]
CODEOWNERS = ["@bdraco"]
rp2_ble_tracker_ns = cg.esphome_ns.namespace("rp2_ble_tracker")
RP2BLETracker = rp2_ble_tracker_ns.class_(
"RP2BLETracker", ble_device_base.BLEHub, cg.Component
)
# 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
# WiFi on the shared CYW43. Converted to the controller's 0.625 ms BLE units in
# to_code().
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms")
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,
}
).extend(cv.COMPONENT_SCHEMA)
# Runs at FINAL priority so every BLE sensor has registered through
# ble_device_base (and any tracker-owned listeners have been counted) before
# the StaticVector size is emitted. Same pattern as esp32_ble_tracker.
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_listener_count() -> None:
count = ble_device_base.get_listener_count()
if count > 0:
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_RP2040_BLE_ID])
cg.add(var.set_parent(parent))
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
ota.request_ota_state_listeners()
scan = config[CONF_SCAN_PARAMETERS]
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
CORE.add_job(_emit_listener_count)
@@ -0,0 +1,214 @@
#ifdef USE_RP2
#include "rp2_ble_tracker.h"
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
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.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
// One BLE scan unit in milliseconds; the controller programs interval/window in these units.
static constexpr float BLE_SCAN_UNIT_MS = 0.625f;
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);
#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.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
if (!this->scan_continuous_) {
// Nothing to do until an external start_scan(); the loop is re-enabled there.
this->disable_loop();
}
}
#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) {
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());
this->stop_scan();
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
// On success the device reboots, so restore only on a failed/aborted update;
// loop()'s retry branch restarts the scan on its next iteration.
if (this->scan_continuous_before_ota_) {
this->scan_continuous_before_ota_ = false;
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.
if (this->scan_pending_before_ota_) {
this->scan_pending_before_ota_ = false;
this->enable_loop();
}
}
}
#endif // USE_OTA_STATE_LISTENER
void RP2BLETracker::loop() {
const uint32_t now = App.get_loop_component_start_time();
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.
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.
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.
return;
}
if (now - this->last_scan_start_attempt_ >= SCAN_START_RETRY_MS) {
this->start_scan_();
}
return;
}
if (this->scan_continuous_) {
// Period timer: fire on_scan_end() once per scan_duration_ window, mirroring
// esp32_ble_tracker::cleanup_scan_state_().
if (now - this->scan_period_start_ >= this->scan_duration_) {
this->fire_scan_end_();
this->scan_period_start_ = now;
}
return;
}
// 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_) {
this->stop_scan_();
}
}
void RP2BLETracker::dump_config() {
ESP_LOGCONFIG(TAG,
"RP2 BLE Tracker:\n"
" Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Type: PASSIVE\n"
" Continuous Scanning: %s",
this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_,
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, 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{.mac = report.mac,
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
.addr_type = report.addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
#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;
}
// 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
}
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.
this->enable_loop();
this->start_scan_();
}
void RP2BLETracker::stop_scan() {
this->scan_continuous_ = false;
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();
}
void RP2BLETracker::start_scan_() {
if (this->scan_running_)
return;
// Stamp every attempt regardless of caller so the loop's rate limit also
// covers a failed start that came through the public start_scan().
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_)))
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.
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", 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.
this->scan_period_start_ = App.get_loop_component_start_time();
}
void RP2BLETracker::stop_scan_() {
if (!this->scan_running_)
return;
this->parent_->scan_stop();
this->scan_running_ = false;
ESP_LOGD(TAG, "Scan stopped");
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_) {
// Nothing left to time; start_scan() re-enables the loop.
this->disable_loop();
}
}
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
}
} // namespace esphome::rp2_ble_tracker
#endif // USE_RP2
@@ -0,0 +1,114 @@
#pragma once
#ifdef USE_RP2
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/rp2040_ble/rp2040_ble.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
#endif
namespace esphome::rp2_ble_tracker {
class RP2BLETracker : public Component,
public ble_device_base::BLEHub,
public rp2040_ble::BLEScanListener,
public Parented<rp2040_ble::RP2040BLE>
#ifdef USE_OTA_STATE_LISTENER
,
public ota::OTAGlobalStateListener
#endif
{
public:
// ---- ESPHome Component ----
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update runs (the BLE scan competes with the OTA
// download on the shared CYW43 radio); mirrors esp32_ble_tracker.
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
// ---- YAML configuration setters ----
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
// ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
void start_scan();
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
// BTstack on the CYW43 supports active scanning and GATT, but this tracker
// drives the controller passively (scan_type 0) and exposes no GATT path
// yet — capabilities describe what this component delivers, so all three
// stay false until those paths are implemented. Consumers relying on
// scan-response fields (device names) get them only where the receiver
// merges per address (Home Assistant does).
return {.active_scan = false, .merges_scan_response = false, .gatt = false};
}
// The controller stores the address in printable (MSB-first) order, which is
// exactly what the contract wants.
void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); }
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return false; } // passive-only (initial implementation)
// ---- 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.
void on_scan_report(const rp2040_ble::BLEScanReport &report) override;
protected:
void start_scan_();
void stop_scan_();
void fire_scan_end_();
// Defaults: 30 % duty cycle (interval 100 ms / window 30 ms), in 0.625 ms
// BLE units — same defaults as bk72xx_ble_tracker.
uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms
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_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
#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
};
} // namespace esphome::rp2_ble_tracker
#endif // USE_RP2
+1
View File
@@ -433,6 +433,7 @@
#define USE_LOGGER_USB_CDC
#define USE_SOCKET_IMPL_LWIP_TCP
#define USE_RP2040_BLE
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_RP2040_VARIANT_RP2040
#define USE_SPI
#ifndef USE_ETHERNET
@@ -10,6 +10,7 @@ from esphome.components.bk72xx_ble_tracker import (
)
from esphome.components.ble_device_base import to_ble_units
from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA
from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA
def _validate(**kwargs: str) -> dict:
@@ -59,6 +60,15 @@ def test_esp32_defaults_are_valid() -> None:
assert config["active"] is True
def test_rp2_defaults_are_valid() -> None:
"""rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the
shared CYW43 radio mostly free for WiFi."""
config = RP2_SCHEMA({})
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 48
assert "active" not in config
def test_esp32_active_can_disable() -> None:
config = ESP32_SCHEMA({"active": False})
assert config["active"] is False
@@ -102,6 +112,11 @@ def test_window_equal_to_interval_accepted() -> None:
assert to_ble_units(config["interval"]) == to_ble_units(config["window"])
def test_duration_equal_to_three_intervals_accepted() -> None:
"""The three-interval floor is inclusive, mirroring the ceilings above."""
_validate(duration="3s", interval="1s", window="500ms")
# --- rejected configurations ---
@@ -0,0 +1,11 @@
rp2_ble_tracker:
id: ble_tracker
scan_parameters:
# Boundary coverage: the documented 2.5 ms floor on window (expressible only
# via the microsecond-accurate validation), a non-round interval exercising the
# 0.625 ms unit conversion without collapsing onto the window's unit count,
# and the non-continuous config path.
interval: 5000us
window: 2500us
duration: 5min
continuous: false
@@ -0,0 +1,16 @@
rp2_ble_tracker:
id: ble_tracker
scan_parameters:
interval: 100ms
window: 30ms
duration: 5min
continuous: true
# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI
# (same coverage arrangement as the esp32_ble_tracker tests).
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
@@ -0,0 +1,2 @@
packages:
rp2_ble_tracker: !include common.yaml
@@ -0,0 +1,2 @@
packages:
rp2_ble_tracker: !include common-boundary.yaml