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/binary_sensor/* @esphome/core
|
||||||
esphome/components/bk72xx/* @kuba2k2
|
esphome/components/bk72xx/* @kuba2k2
|
||||||
esphome/components/bk72xx_ble/* @Bl00d-B0b
|
esphome/components/bk72xx_ble/* @Bl00d-B0b
|
||||||
|
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
|
||||||
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
|
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
|
||||||
esphome/components/bl0939/* @ziceva
|
esphome/components/bl0939/* @ziceva
|
||||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
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