diff --git a/CODEOWNERS b/CODEOWNERS index fe09bd96cf..1a963d8ea6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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- diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py new file mode 100644 index 0000000000..5cf1fabbbc --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -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) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp new file mode 100644 index 0000000000..8d7199bd0a --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -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 +#include + +#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(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(this->scan_interval_), + static_cast(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 diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h new file mode 100644 index 0000000000..b8d0b31e6a --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -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 +#include + +#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 +#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 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 diff --git a/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py b/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py new file mode 100644 index 0000000000..968a24dad5 --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/test_scan_parameter_validation.py @@ -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") diff --git a/tests/components/bk72xx_ble_tracker/common-boundary.yaml b/tests/components/bk72xx_ble_tracker/common-boundary.yaml new file mode 100644 index 0000000000..24844e18a5 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/common-boundary.yaml @@ -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 diff --git a/tests/components/bk72xx_ble_tracker/common.yaml b/tests/components/bk72xx_ble_tracker/common.yaml new file mode 100644 index 0000000000..d8787a9347 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/common.yaml @@ -0,0 +1,7 @@ +bk72xx_ble_tracker: + id: ble_tracker + scan_parameters: + interval: 100ms + window: 30ms + duration: 5min + continuous: true diff --git a/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml new file mode 100644 index 0000000000..932eb4fb55 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-boundary.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble_tracker: !include common-boundary.yaml diff --git a/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fc89479d06 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + bk72xx_ble_tracker: !include common.yaml