[bluetooth_proxy] Fold scanner-state bookkeeping into the sender; extend platform-gate tests (#18150)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Edvard Filistovič
2026-08-07 14:30:00 +00:00
committed by GitHub
co-authored by J. Nick Koston
parent 2f2634bf6b
commit 8b0e23d55b
2 changed files with 75 additions and 26 deletions
@@ -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
}
@@ -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