diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index c000d6e5a3..f10fe8ab6e 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -12,18 +12,30 @@ Scan modes: 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. + starts a non-continuous scan on boot — the radio stays + idle until bk72xx_ble_tracker.start_scan fires (e.g. + from an api client-connected automation), so the + single-core radio can service WiFi in between scans. """ +from esphome import automation import esphome.codegen as cg from esphome.components import bk72xx_ble, ble_device_base, ota -from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv -from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL +from esphome.const import ( + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, +) +from esphome.core import ID from esphome.types import ConfigType CONF_BK72XX_BLE_ID = "bk72xx_ble_id" @@ -37,6 +49,14 @@ BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( "BK72xxBLETracker", ble_device_base.BLEHub, cg.Component ) +StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger + # interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms # window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in @@ -48,10 +68,79 @@ 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, + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger + ), + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, + ), + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger + ), } ).extend(cv.COMPONENT_SCHEMA) +@automation.register_action( + "bk72xx_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + # Optional with no default, unlike esp32_ble_tracker: omitting it + # keeps whatever scan_parameters.continuous configured, instead of + # silently forcing one-shot. + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "bk72xx_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -69,4 +158,23 @@ async def to_code(config: ConfigType) -> None: 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])) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + await ble_automation.advertise_trigger_to_code(conf, var) + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix + ) + + for conf in config.get(CONF_ON_SCAN_END, []): + await ble_automation.scan_end_trigger_to_code(conf, var) diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h new file mode 100644 index 0000000000..5a49fed04a --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -0,0 +1,48 @@ +// Automation triggers and actions for bk72xx_ble_tracker: triggers are the +// neutral ble_device_base classes; only the scan-control actions are +// platform-specific. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "bk72xx_ble_tracker.h" + +#include "esphome/components/ble_device_base/automation.h" +#include "esphome/core/automation.h" + +namespace esphome::bk72xx_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index 2bc91cbc6f..c859f22c61 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -210,6 +210,17 @@ void BK72xxBLETracker::start_scan() { this->try_start_with_backoff_(millis(), /* force= */ true); } +void BK72xxBLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; + // Re-anchor only the one-shot duration clock. scan_period_start_ (the + // continuous-mode on_scan_end period) is deliberately left alone: a + // start_scan action fired more often than scan_duration_ would otherwise + // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN + // publish) rides on that period. + this->scan_start_time_ = millis(); +} + void BK72xxBLETracker::stop_scan() { this->scan_continuous_ = false; this->scan_requested_ = false; // also cancels a pending (not yet successful) start diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 1905e36f94..67e4467c77 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -70,7 +70,22 @@ class BK72xxBLETracker : public Component, 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; } + /// Set from YAML (scan_parameters.continuous); also the value + /// configured_continuous() reports and a bare start_scan action restores. + void set_configured_continuous(bool scan_continuous) { + this->scan_continuous_ = scan_continuous; + this->scan_continuous_configured_ = scan_continuous; + } + /// Runtime control (esp32_ble_tracker lambda parity): does not change the + /// configured value, so configured_continuous() still reports what YAML + /// asked for. void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->scan_continuous_configured_; } + /// Re-anchor the one-shot duration clock of a running scan to now — used + /// when an action changes the scan mode without stopping the radio. The + /// continuous-mode on_scan_end period is deliberately not touched. + void restart_scan_duration(); // ---- Public scan control ---- // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). @@ -134,6 +149,7 @@ class BK72xxBLETracker : public Component, uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) uint32_t scan_duration_{300000}; bool scan_continuous_{true}; + bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h new file mode 100644 index 0000000000..507b3278d9 --- /dev/null +++ b/esphome/components/ble_device_base/automation.h @@ -0,0 +1,117 @@ +// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses +// registered on a BLEHub, exposed by each tracker under its own automation +// names. parse_device()'s return feeds the "Found device" suppression. + +#pragma once + +#include "ble_device.h" +#include "ble_hub.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::ble_device_base { + +// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + + void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + + bool parse_device(const ESPBTDevice &device) override { + if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), + device.address_uint64()) == this->addresses_.end()) { + return false; + } + this->trigger(device); + return true; + } + + protected: + FixedVector addresses_; +}; + +// on_ble_service_data_advertise: fires when an advertisement contains service +// data for the given UUID. Optional single-MAC filter. +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + + void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &sd : device.get_service_datas()) { + if (sd.uuid == this->uuid_) { + this->trigger(sd.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_ble_manufacturer_data_advertise: fires when an advertisement contains +// manufacturer data for the given ID. Optional single-MAC filter. +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + + void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &md : device.get_manufacturer_datas()) { + if (md.uuid == this->uuid_) { + this->trigger(md.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_scan_end: fires whenever a scan period ends (duration elapsed or stop +// requested). A listener whose on_scan_end() hook fires the trigger — never +// claims devices (parse_device always returns false). +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { + public: + explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); } + + bool parse_device(const ESPBTDevice &device) override { return false; } + void on_scan_end() override { this->trigger(); } +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/automation.py b/esphome/components/ble_device_base/automation.py new file mode 100644 index 0000000000..eb63061a8d --- /dev/null +++ b/esphome/components/ble_device_base/automation.py @@ -0,0 +1,128 @@ +"""Shared codegen for the neutral BLE advertisement triggers (automation.h).""" + +from typing import Any + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_MAC_ADDRESS, CONF_TRIGGER_ID +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +from . import ( + BT_UUID16_FORMAT, + BT_UUID32_FORMAT, + BT_UUID128_FORMAT, + LISTENER_COUNT_DEFINE, + as_hex, + as_reversed_hex_array, + ble_device_base_ns, +) + +adv_data_t = cg.std_vector.template(cg.uint8) +adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") +ESPBTDeviceConstRef = ( + ble_device_base_ns.class_("ESPBTDevice").operator("ref").operator("const") +) + +ESPBTAdvertiseTrigger = ble_device_base_ns.class_( + "ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef) +) +BLEServiceDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref) +) +BLEManufacturerDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEManufacturerDataAdvertiseTrigger", + automation.Trigger.template(adv_data_t_const_ref), +) +BLEEndOfScanTrigger = ble_device_base_ns.class_( + "BLEEndOfScanTrigger", automation.Trigger.template() +) + +# UUID string length -> setter width. 16/32-bit go out as plain hex literals, +# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an +# impossible length fails as a KeyError instead of silently picking a width +# (bt_uuid validation upstream only ever produces these three). +_UUID_WIDTHS = { + len(BT_UUID16_FORMAT): "16", + len(BT_UUID32_FORMAT): "32", + len(BT_UUID128_FORMAT): "128", +} + + +def uuid_trigger_schema( + trigger_class: MockObjClass, extra: dict[Any, Any] | None = None +): + """Schema for a UUID-filtered trigger — pairs with uuid_trigger_to_code(). + + `extra` carries the required UUID key (a cv marker, so a dict rather than + **kwargs); the optional single-mac filter is what uuid_trigger_to_code() + reads back. + """ + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + **(extra or {}), + } + ) + + +def advertise_trigger_schema(trigger_class: MockObjClass): + """on_ble_advertise schema: multi-mac list filter, unlike the single-mac + uuid_trigger_schema() — pairs with advertise_trigger_to_code().""" + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + } + ) + + +def scan_end_trigger_schema(trigger_class: MockObjClass): + """on_scan_end schema: id only — pairs with scan_end_trigger_to_code().""" + return automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class)} + ) + + +# Triggers register as ble_device_base listeners in their constructors; count +# them where they are created so no backend can undercount the StaticVector +# (push_back past capacity drops silently). Shares the define with +# register_ble_device() via the core slot-counter factory. +_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE) + + +async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_ble_advertise trigger (optional multi-mac filter).""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_addresses([it.as_hex for it in macs])) + await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) + _count_listener() + + +async def scan_end_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_scan_end trigger.""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + _count_listener() + + +async def uuid_trigger_to_code( + conf: ConfigType, var: cg.MockObj, key: str, setter_prefix: str +) -> None: + """Build a UUID-filtered advertise trigger. + + The UUID width picks the setter: 16-/32-bit go out as a plain hex literal, + 128-bit as a reversed byte array (BLE wire order). + """ + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + uuid = conf[key] + width = _UUID_WIDTHS[len(uuid)] + value = as_hex(uuid) if width != "128" else as_reversed_hex_array(uuid) + cg.add(getattr(trigger, f"{setter_prefix}{width}")(value)) + if (mac := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_address(mac.as_hex)) + await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) + _count_listener() diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 7476385563..c4b66a8b9a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -27,6 +27,7 @@ CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" +CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 102e59bfb2..b45d4a48be 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -5,7 +5,7 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, esp32_ble, ota -from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, request_bluetooth, @@ -44,7 +44,6 @@ DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] CONF_ESP32_BLE_ID = "esp32_ble_id" -CONF_ON_SCAN_END = "on_scan_end" CONF_SOFTWARE_COEXISTENCE = "software_coexistence" _LOGGER = logging.getLogger(__name__) diff --git a/tests/component_tests/bk72xx_ble_tracker/__init__.py b/tests/component_tests/bk72xx_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml new file mode 100644 index 0000000000..1701807984 --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -0,0 +1,39 @@ +esphome: + name: bk-trigger-codegen + on_boot: + then: + - bk72xx_ble_tracker.start_scan: + continuous: true + - bk72xx_ble_tracker.stop_scan + +bk72xx: + board: cb2s + +bk72xx_ble_tracker: + on_ble_advertise: + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - lambda: 'ESP_LOGD("t", "%s", x.address_str().c_str());' + on_ble_service_data_advertise: + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + mac_address: AC:37:43:77:5F:4C + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - service_uuid: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: 'ESP_LOGD("t", "%zu", x.size());' + on_scan_end: + - then: + - lambda: 'ESP_LOGD("t", "end");' diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py new file mode 100644 index 0000000000..c98bb8a111 --- /dev/null +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -0,0 +1,54 @@ +"""Codegen tests for the tracker automations. + +The shared trigger classes (ble_device_base/automation.h) are compiled by every +esp32 BLE compile test via AUTO_LOAD, but the BK-specific side — automation.h's +action templates and restart_scan_duration() — compiles on no CI board (the +bk72xx base board generic-bk7252 is BLE 4.2 and cannot build the tracker), and +validate fixtures never run to_code. The generated main is therefore the only +automated check on the setter spellings and the listener accounting.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components import ble_device_base +from tests.component_tests.helpers import get_define_value + + +def test_trigger_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_automations.yaml")) + + # on_ble_advertise: multi-mac filter (two addresses in one initializer list) + assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter + assert ( + "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + assert "set_address(0xAC3743775F4CULL)" in main_cpp + # 32-bit middle branch of the width dispatch + assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp + # All three manufacturer widths: getattr() builds these names as strings, + # so a misspelling only ever fails here. + assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp + assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp + assert ( + "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," + "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp + ) + # scan-control actions: templatable continuous lambda + parented actions + assert "startscanaction_id->set_continuous(" in main_cpp + assert "stopscanaction_id->set_parent(" in main_cpp + # Constructor call, not just the declaration: the parent argument is what + # registers the trigger as a listener. + assert re.search( + r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp + ) + + # Seven triggers register as listeners; an undercount silently drops the + # last trigger at runtime (StaticVector::push_back past capacity), so the + # define is the assertion that matters most. + assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7" diff --git a/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml new file mode 100644 index 0000000000..407d19e67d --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-automations.bk72xx-ard.yaml @@ -0,0 +1,52 @@ +packages: + bk72xx_ble_tracker: !include common.yaml + +esphome: + on_boot: + then: + - bk72xx_ble_tracker.start_scan + - bk72xx_ble_tracker.start_scan: + continuous: true + - bk72xx_ble_tracker.stop_scan + - bk72xx_ble_tracker.stop_scan: ble_tracker + +bk72xx_ble_tracker: + on_ble_advertise: + - mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + - mac_address: + - AC:37:43:77:5F:4C + - AC:37:43:77:5F:4D + then: + - lambda: |- + ESP_LOGD("main", "The device address is %s", x.address_str().c_str()); + on_ble_service_data_advertise: + - service_uuid: ABCD + # mac_address exercises the UUID triggers' set_address() codegen branch. + mac_address: AC:37:43:77:5F:4C + then: + - lambda: |- + ESP_LOGD("main", "Length of service data is %zu", x.size()); + - service_uuid: ABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "32-bit service data is %zu", x.size()); + - service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit service data is %zu", x.size()); + on_ble_manufacturer_data_advertise: + - manufacturer_id: ABCD + then: + - lambda: |- + ESP_LOGD("main", "Length of manufacturer data is %zu", x.size()); + - manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD + then: + - lambda: |- + ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size()); + on_scan_end: + - then: + - lambda: |- + ESP_LOGD("main", "Scan ended");