mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[bk72xx_ble_tracker] BLE 5.x scanner for BK72xx (#17135)
This commit is contained in:
@@ -70,6 +70,7 @@ esphome/components/bh1900nux/* @B48D81EFCC
|
||||
esphome/components/binary_sensor/* @esphome/core
|
||||
esphome/components/bk72xx/* @kuba2k2
|
||||
esphome/components/bk72xx_ble/* @Bl00d-B0b
|
||||
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
|
||||
esphome/components/bl0939/* @ziceva
|
||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable
|
||||
LibreTiny Beken chips (beken-72xx family).
|
||||
|
||||
Builds on the bk72xx_ble controller component (stack bring-up, BLE address,
|
||||
scan primitives) and implements the platform-neutral ble_device_base BLEHub
|
||||
contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner,
|
||||
bthome_mithermometer, xiaomi_*, …) bind to this tracker through
|
||||
cv.use_id(BLEHub) with no BK-specific code.
|
||||
|
||||
Scan modes:
|
||||
continuous: true — scan runs forever; never stops automatically.
|
||||
Use this when the radio is dedicated to BLE.
|
||||
continuous: false — a started scan runs for `duration` ms, then stops. The
|
||||
FIRST start is external too: nothing in this component
|
||||
starts a non-continuous scan on boot, so until the
|
||||
automation actions land (follow-up PR) the radio stays
|
||||
idle. start_scan() is called from code (e.g. an api
|
||||
client-connected automation) so the single-core radio
|
||||
can service WiFi in between scans.
|
||||
"""
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import bk72xx_ble, ble_device_base, ota
|
||||
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_WINDOW = "window"
|
||||
CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
|
||||
|
||||
DEPENDENCIES = ["bk72xx"]
|
||||
AUTO_LOAD = ["ble_device_base", "bk72xx_ble"]
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
|
||||
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
|
||||
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component
|
||||
)
|
||||
|
||||
|
||||
def to_ble_units(value: cv.TimePeriod) -> int:
|
||||
"""Convert a scan time to the controller's 0.625 ms units.
|
||||
|
||||
Used by both validation and codegen so what is validated is exactly what is
|
||||
programmed — the truncation here is what makes the duty-cycle check below
|
||||
meaningful.
|
||||
"""
|
||||
return value.total_microseconds // 625
|
||||
|
||||
|
||||
def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
||||
"""Reject impossible window/interval/duration combinations at config time.
|
||||
|
||||
Mirrors esp32_ble_tracker: the controller cannot scan for longer than the
|
||||
interval, and a too-short duration would end the scan period almost
|
||||
immediately. Catching it here gives a clear error instead of a runtime
|
||||
controller failure and the 1/sec retry loop.
|
||||
"""
|
||||
duration = config[CONF_DURATION]
|
||||
interval = config[CONF_INTERVAL]
|
||||
window = config[CONF_WINDOW]
|
||||
|
||||
if window > interval:
|
||||
raise cv.Invalid(
|
||||
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
|
||||
)
|
||||
|
||||
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
|
||||
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
|
||||
# values here instead of letting the unit conversion silently overflow.
|
||||
for name, value in (("interval", interval), ("window", window)):
|
||||
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
|
||||
raise cv.Invalid(
|
||||
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
|
||||
)
|
||||
|
||||
# Validate what actually reaches the controller: both values are truncated to
|
||||
# whole 0.625 ms units, so a window/interval pair that differs by less than one
|
||||
# unit collapses to the same value — silently programming a 100 % duty cycle
|
||||
# (radio permanently on) from a config that asked for less.
|
||||
interval_units = to_ble_units(interval)
|
||||
window_units = to_ble_units(window)
|
||||
if window_units == interval_units and window < interval:
|
||||
raise cv.Invalid(
|
||||
f"Scan window ({window}) and interval ({interval}) both round to "
|
||||
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
|
||||
f"cycle. Separate them by at least 0.625 ms."
|
||||
)
|
||||
|
||||
if interval.total_microseconds * 3 > duration.total_microseconds:
|
||||
raise cv.Invalid(
|
||||
f"Scan duration ({duration}) must cover at least three scan intervals "
|
||||
f"({interval}): the scanner listens on one of the three BLE advertising "
|
||||
f"channels per interval, so a shorter duration can miss devices entirely."
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
SCAN_PARAMETERS_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
||||
# interval/window default to the BK reference scan rate — 100 ms / 30 ms,
|
||||
# a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in
|
||||
# to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.)
|
||||
cv.Optional(CONF_INTERVAL, default="100ms"): cv.positive_time_period,
|
||||
cv.Optional(CONF_WINDOW, default="30ms"): cv.positive_time_period,
|
||||
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
validate_scan_parameters,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BK72xxBLETracker),
|
||||
cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE),
|
||||
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_BK72XX_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(to_ble_units(scan[CONF_INTERVAL])))
|
||||
cg.add(var.set_scan_window(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,205 @@
|
||||
// bk72xx_ble_tracker.cpp
|
||||
//
|
||||
// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period
|
||||
// timers and the rate-limited start retry. All controller access (stack
|
||||
// bring-up, scan primitives, the BLE-task → main-task report queue) goes
|
||||
// through the bk72xx_ble component — no SDK calls and no cross-task state here.
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "bk72xx_ble_tracker.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
|
||||
static const char *const TAG = "bk72xx_ble_tracker";
|
||||
|
||||
// Minimum interval between scan (re)start attempts, so a failing controller start
|
||||
// cannot be retried every main-loop iteration (single-core CPU starvation). The
|
||||
// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller
|
||||
// that never comes up — the controller logs each failure at ERROR — settles into a
|
||||
// slow, quiet poll instead of an error line every second for the rest of uptime;
|
||||
// a single WARN is emitted when the retry interval first saturates.
|
||||
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
|
||||
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLETracker::setup() {
|
||||
// Receive the controller's scan reports; the controller queues them from the
|
||||
// BLE task and delivers here on the main task.
|
||||
this->parent_->register_scan_listener(this);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the
|
||||
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
|
||||
ota::get_global_ota_callback()->add_global_state_listener(this);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
|
||||
ota::OTAComponent *comp) {
|
||||
if (state == ota::OTA_STARTED) {
|
||||
this->scan_continuous_before_ota_ = this->scan_continuous_;
|
||||
this->stop_scan();
|
||||
} else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) {
|
||||
// On success the device reboots, so restore only on a failed/aborted update;
|
||||
// loop() restarts the scan on its next iteration (continuous idle branch).
|
||||
this->scan_continuous_before_ota_ = false;
|
||||
this->scan_continuous_ = true;
|
||||
}
|
||||
}
|
||||
#endif // USE_OTA_STATE_LISTENER
|
||||
|
||||
void BK72xxBLETracker::loop() {
|
||||
const uint32_t now = millis();
|
||||
if (this->scan_continuous_) {
|
||||
if (!this->scan_running_) {
|
||||
// Rate-limit (re)start attempts. The controller start can fail (no idle activity
|
||||
// handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every
|
||||
// main-loop iteration would spin the single-core CPU and starve WiFi (device
|
||||
// becomes unresponsive). The interval backs off with consecutive failures so a
|
||||
// controller that never comes up polls slowly and quietly.
|
||||
const uint8_t doublings = std::min<uint8_t>(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS);
|
||||
if (now - this->last_scan_start_attempt_ >= (SCAN_START_RETRY_MS << doublings)) {
|
||||
this->last_scan_start_attempt_ = now;
|
||||
this->start_scan_();
|
||||
if (this->scan_running_) {
|
||||
this->failed_start_count_ = 0;
|
||||
} else if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
++this->failed_start_count_;
|
||||
if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
|
||||
(SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Period timer: fire on_scan_end() once per scan_duration_ window, mirroring
|
||||
// esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
|
||||
// that never came up (start kept failing) does not fire spurious on_scan_end events.
|
||||
if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
|
||||
#endif
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
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 (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
|
||||
this->stop_scan_();
|
||||
}
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BK72xx 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_ * 0.625f, this->scan_interval_,
|
||||
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan report — delivered by the controller's loop() on the ESPHome main task
|
||||
// (the controller queues reports from the BLE task), so publish_state() and
|
||||
// listener dispatch run in main-loop context with no cross-task handling here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
|
||||
// Raw callback (the raw-advertisement path).
|
||||
if (this->raw_advertisement_callback_)
|
||||
this->raw_advertisement_callback_(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
|
||||
|
||||
#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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public scan control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLETracker::start_scan() {
|
||||
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
|
||||
// set_scan_continuous() first, then calls start_scan() to begin scanning.
|
||||
if (!this->scan_running_) {
|
||||
this->start_scan_();
|
||||
}
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::stop_scan() {
|
||||
this->scan_continuous_ = false;
|
||||
this->stop_scan_();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal scan start / stop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLETracker::start_scan_() {
|
||||
if (this->scan_running_)
|
||||
return;
|
||||
|
||||
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
|
||||
static_cast<uint16_t>(this->scan_window_)))
|
||||
return;
|
||||
|
||||
const uint32_t now = millis();
|
||||
this->scan_running_ = true;
|
||||
this->scan_start_time_ = now;
|
||||
// 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_ * 0.625f,
|
||||
this->scan_interval_ * 0.625f);
|
||||
// Re-anchor the on_scan_end 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). scan_started_once_ purely gates the period timer.
|
||||
this->scan_period_start_ = now;
|
||||
this->scan_started_once_ = true;
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::stop_scan_() {
|
||||
if (!this->scan_running_)
|
||||
return;
|
||||
this->parent_->scan_stop();
|
||||
this->scan_running_ = false;
|
||||
ESP_LOGD(TAG, "Scan stopped");
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
for (auto *listener : this->listeners_)
|
||||
listener->on_scan_end();
|
||||
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
|
||||
#endif
|
||||
this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -0,0 +1,151 @@
|
||||
// bk72xx_ble_tracker.h
|
||||
//
|
||||
// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family).
|
||||
// Implements the platform-neutral ble_device_base::BLEHub contract on top of the
|
||||
// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered
|
||||
// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the
|
||||
// hub's raw-advertisement callback.
|
||||
//
|
||||
// This component contains no Beken SDK calls and no cross-task state: the
|
||||
// controller (stack bring-up, BLE address, scan primitives, and the BLE-task →
|
||||
// main-task report queue) is owned by bk72xx_ble, which delivers every scan
|
||||
// report on the ESPHome main task. The tracker owns scan policy — parameters,
|
||||
// duration/period timers and the rate-limited start retry.
|
||||
//
|
||||
// YAML config (values shown are the defaults; interval/window are a 30 % duty
|
||||
// cycle, the BK reference scan rate):
|
||||
//
|
||||
// bk72xx_ble_tracker:
|
||||
// scan_parameters:
|
||||
// interval: 100ms
|
||||
// window: 30ms
|
||||
// duration: 5min
|
||||
// continuous: true
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "esphome/components/bk72xx_ble/bk72xx_ble.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <utility>
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BK72xxBLETracker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class BK72xxBLETracker : public Component,
|
||||
public ble_device_base::BLEHub,
|
||||
public bk72xx_ble::BLEScanListener,
|
||||
public Parented<bk72xx_ble::BK72xxBLE>
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
,
|
||||
public ota::OTAGlobalStateListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
// ---- ESPHome Component ----
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention);
|
||||
// mirrors esp32_ble_tracker.
|
||||
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
|
||||
#endif
|
||||
|
||||
// ---- YAML configuration setters ----
|
||||
void set_scan_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 cb) override {
|
||||
this->raw_advertisement_callback_ = std::move(cb);
|
||||
}
|
||||
ble_device_base::HubCapabilities get_capabilities() const override {
|
||||
// The Beken BDK exposes no active-scan path (passive scanning only), so the
|
||||
// controller never solicits scan responses and never merges them; consumers
|
||||
// relying on scan-response fields (device names) get them only where the
|
||||
// receiver merges per address (Home Assistant does). No GATT client either.
|
||||
return {.active_scan = false, .merges_scan_response = false, .gatt = false};
|
||||
}
|
||||
// The controller stores the address LSB-first (BLE convention); the contract
|
||||
// wants printable (MSB-first) order.
|
||||
void get_adapter_mac(uint8_t out[6]) override {
|
||||
uint8_t mac[6];
|
||||
this->parent_->get_mac_lsb_first(mac);
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = mac[5 - i];
|
||||
}
|
||||
bool scan_running() override { return this->scan_running_; }
|
||||
bool scan_active() override { return false; } // BK72xx scan is passive-only
|
||||
|
||||
// ---- bk72xx_ble::BLEScanListener ----
|
||||
// Delivered by the controller's loop() on the ESPHome main task — the
|
||||
// BLE-task → main-task handoff already happened in the controller's queue.
|
||||
void on_scan_report(const bk72xx_ble::BLEScanReport &report) override;
|
||||
|
||||
protected:
|
||||
void start_scan_();
|
||||
void stop_scan_();
|
||||
|
||||
bool scan_running_{false};
|
||||
// Defaults: the BK reference — 30 % duty cycle
|
||||
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units.
|
||||
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};
|
||||
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
|
||||
#endif
|
||||
uint32_t scan_start_time_{0};
|
||||
|
||||
uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries
|
||||
uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success)
|
||||
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
|
||||
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
|
||||
|
||||
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{nullptr};
|
||||
#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_;
|
||||
#endif
|
||||
|
||||
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
|
||||
// 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::bk72xx_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for bk72xx_ble_tracker scan parameter validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.bk72xx_ble_tracker import SCAN_PARAMETERS_SCHEMA, to_ble_units
|
||||
|
||||
|
||||
def _validate(**kwargs: str) -> dict:
|
||||
"""Run a scan_parameters config through the schema, applying defaults."""
|
||||
return SCAN_PARAMETERS_SCHEMA(dict(kwargs))
|
||||
|
||||
|
||||
# --- to_ble_units ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("2500us", 4), # controller minimum, 2.5 ms
|
||||
("30ms", 48),
|
||||
("100ms", 160),
|
||||
("10240ms", 16384), # controller maximum, 0x4000
|
||||
],
|
||||
)
|
||||
def test_to_ble_units_converts_to_controller_units(value: str, expected: int) -> None:
|
||||
"""A time is converted to whole 0.625 ms units."""
|
||||
assert to_ble_units(cv.positive_time_period(value)) == expected
|
||||
|
||||
|
||||
def test_to_ble_units_truncates() -> None:
|
||||
"""Sub-unit remainders are dropped, which is what makes collapse possible."""
|
||||
assert to_ble_units(cv.positive_time_period("3000us")) == 4
|
||||
assert to_ble_units(cv.positive_time_period("2500us")) == 4
|
||||
|
||||
|
||||
# --- accepted configurations ---
|
||||
|
||||
|
||||
def test_defaults_are_valid() -> None:
|
||||
"""The documented default 100 ms / 30 ms pair validates."""
|
||||
config = _validate()
|
||||
assert to_ble_units(config["interval"]) == 160
|
||||
assert to_ble_units(config["window"]) == 48
|
||||
|
||||
|
||||
def test_minimum_separation_accepted() -> None:
|
||||
"""Values one unit apart at the 2.5 ms floor are honest, not collapsed."""
|
||||
config = _validate(interval="5000us", window="2500us")
|
||||
assert to_ble_units(config["interval"]) == 8
|
||||
assert to_ble_units(config["window"]) == 4
|
||||
|
||||
|
||||
def test_maximum_interval_accepted() -> None:
|
||||
"""The documented 10240 ms ceiling is inclusive, and maps to 0x4000.
|
||||
|
||||
Pins the ceiling from the accept side, mirroring the 2.5 ms floor above: the
|
||||
reject cases alone would let the bound silently become exclusive.
|
||||
"""
|
||||
config = _validate(interval="10240ms", window="30ms")
|
||||
assert to_ble_units(config["interval"]) == 16384
|
||||
|
||||
|
||||
def test_maximum_window_accepted() -> None:
|
||||
"""The ceiling applies to the window too, and is likewise inclusive."""
|
||||
config = _validate(interval="10240ms", window="10240ms")
|
||||
assert to_ble_units(config["window"]) == 16384
|
||||
|
||||
|
||||
def test_window_equal_to_interval_accepted() -> None:
|
||||
"""A deliberate 100 % duty cycle is allowed; only an accidental one is not."""
|
||||
config = _validate(interval="100ms", window="100ms")
|
||||
assert to_ble_units(config["interval"]) == to_ble_units(config["window"])
|
||||
|
||||
|
||||
# --- rejected configurations ---
|
||||
|
||||
|
||||
def test_window_larger_than_interval_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
|
||||
_validate(interval="30ms", window="100ms")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("interval", "window", "offender"),
|
||||
[
|
||||
("2ms", "1ms", "interval"), # below the 2.5 ms controller floor
|
||||
("20s", "1s", "interval"), # above the 10240 ms controller ceiling
|
||||
("100ms", "1ms", "window"), # window below the floor
|
||||
],
|
||||
)
|
||||
def test_out_of_range_rejected(interval: str, window: str, offender: str) -> None:
|
||||
"""Values the controller cannot represent are rejected, not silently wrapped."""
|
||||
with pytest.raises(
|
||||
cv.Invalid, match=f"Scan {offender} .* must be between 2.5 ms and 10240 ms"
|
||||
):
|
||||
_validate(interval=interval, window=window)
|
||||
|
||||
|
||||
def test_unit_collapse_rejected() -> None:
|
||||
"""Regression: 3000us/2500us both floor to 4 units — a hidden 100 % duty cycle.
|
||||
|
||||
This is the configuration that previously validated and programmed the radio
|
||||
permanently on despite asking for roughly 83 %.
|
||||
"""
|
||||
with pytest.raises(cv.Invalid, match="both round to 4 x 0.625 ms"):
|
||||
_validate(interval="3000us", window="2500us")
|
||||
|
||||
|
||||
def test_duration_shorter_than_three_intervals_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="must cover at least three scan intervals"):
|
||||
_validate(duration="1s", interval="500ms", window="100ms")
|
||||
@@ -0,0 +1,11 @@
|
||||
bk72xx_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,7 @@
|
||||
bk72xx_ble_tracker:
|
||||
id: ble_tracker
|
||||
scan_parameters:
|
||||
interval: 100ms
|
||||
window: 30ms
|
||||
duration: 5min
|
||||
continuous: true
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
bk72xx_ble_tracker: !include common-boundary.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
bk72xx_ble_tracker: !include common.yaml
|
||||
Reference in New Issue
Block a user