From 8b0e23d55b9022b0307cfe4bec3fbecff39c57fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 17:30:00 +0300 Subject: [PATCH] [bluetooth_proxy] Fold scanner-state bookkeeping into the sender; extend platform-gate tests (#18150) Co-authored-by: J. Nick Koston --- .../bluetooth_proxy/bluetooth_proxy.cpp | 20 ++--- .../bluetooth_proxy/test_platform_gates.py | 81 +++++++++++++++---- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 08b58fc3b4..9002727bbf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -93,9 +93,13 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } void BluetoothProxy::send_bluetooth_scanner_state_() { + // Records what goes on the wire so loop()'s change detector cannot report the + // same transition twice; every caller relies on this instead of updating + // last_scan_running_ itself. + this->last_scan_running_ = this->hub_->scan_running(); api::BluetoothScannerStateResponse resp; - resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; + resp.state = this->last_scan_running_ ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING + : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ @@ -483,9 +487,7 @@ void BluetoothProxy::loop() { return; // The hub has no scanner-state listener interface; poll and report on change. - bool running = this->hub_->scan_running(); - if (running != this->last_scan_running_) { - this->last_scan_running_ = running; + if (this->hub_->scan_running() != this->last_scan_running_) { this->send_bluetooth_scanner_state_(); } @@ -561,10 +563,9 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { } } if (this->api_connection_ != nullptr) { - // Keep loop()'s change detector in step with the state sent here, so a - // failed restart (scan_running_ dropped by the tracker) is not reported - // twice — once now and again on the next tick. - this->last_scan_running_ = this->hub_->scan_running(); + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. this->send_bluetooth_scanner_state_(); } } @@ -589,7 +590,6 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection this->parent_->recalculate_advertisement_parser_types(); this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); #else - this->last_scan_running_ = this->hub_->scan_running(); this->send_bluetooth_scanner_state_(); #endif } diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index c5240105fa..682f98bf6b 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -6,44 +6,93 @@ import pytest from esphome import config_validation as cv from esphome.components import bluetooth_connection, bluetooth_proxy -from esphome.const import CONF_ACTIVE, KEY_TARGET_PLATFORM -from esphome.core import CORE, KEY_CORE +from esphome.const import ( + CONF_ACTIVE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PlatformFramework, +) +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + +HUB_PLATFORM_FRAMEWORKS = [ + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.RP2_ARDUINO, +] def _set_platform(platform: str | None) -> None: + # For arms set_core_config cannot express (bare platform, no framework). CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform -def test_ble_less_platform_gets_the_real_reason() -> None: - _set_platform("esp8266") +def test_ble_less_platform_gets_the_real_reason( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) with pytest.raises(cv.Invalid, match="not supported on esp8266"): bluetooth_proxy.CONFIG_SCHEMA({}) -def test_ble_less_platform_connection_keys_fall_through() -> None: +def test_ble_less_platform_connection_keys_fall_through( + set_core_config: SetCoreConfigCallable, +) -> None: # The key-level rejection must not fire here — it would imply an # advertisement-only proxy exists on this platform. - _set_platform("esp8266") + set_core_config(PlatformFramework.ESP8266_ARDUINO) with pytest.raises(cv.Invalid, match="not supported on esp8266"): bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) -def test_hub_platform_rejects_active() -> None: - _set_platform("ln882x") +def test_no_target_platform_keeps_the_key_gate_out_of_the_way() -> None: + # set_core_config cannot express "no platform"; script/build_codeowners.py + # sets exactly this shape, and the key gate returns early on it so the + # platform gate is what reports. + CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None} + with pytest.raises(cv.Invalid, match="not supported on None"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + + +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_rejects_active( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) with pytest.raises(cv.Invalid, match="Active connections are not supported"): bluetooth_proxy.CONFIG_SCHEMA({"active": True}) -def test_hub_platform_rejects_connection_keys_by_name() -> None: - _set_platform("ln882x") - with pytest.raises(cv.Invalid, match="'connection_slots' requires active"): - bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) - with pytest.raises(cv.Invalid, match="'cache_services' requires active"): - bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True}) +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +@pytest.mark.parametrize( + ("key", "value"), + [ + ("connection_slots", 2), + ("cache_services", True), + # Absent from the outer CONFIG_SCHEMA, so this gate is the only test + # that touches it. + ("connections", [{}]), + ], +) +def test_hub_platform_rejects_connection_keys_by_name( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + key: str, + value: object, +) -> None: + set_core_config(platform_framework) + with pytest.raises(cv.Invalid, match=f"'{key}' requires active"): + bluetooth_proxy.CONFIG_SCHEMA({key: value}) -def test_hub_platform_accepts_the_advertisement_only_shape() -> None: - _set_platform("ln882x") +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_accepts_the_advertisement_only_shape( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False