Compare commits

...

13 Commits

Author SHA1 Message Date
J. Nick Koston a2a3f09dea use timeout=0 in scan_duration wiring test to avoid pytest-timeout 2026-05-23 00:00:11 -05:00
J. Nick Koston 900f5469bc wire timeout through as scan_duration in async_process_advertisements
so callers like snooz/qingping/xiaomi_ble config flows get an immediate active scan window for the duration of the wait, instead of relying on AUTO-mode scanners to be in active mode by coincidence. habluetooth clamps the effective window to 30s; the timeout still bounds the asyncio wait.
2026-05-22 23:37:02 -05:00
J. Nick Koston 1f541ed9d0 address copilot feedback
forward mode through async_process_advertisements so PASSIVE callers don't accidentally trigger an active scan; mock async_register_active_scan in the new tests instead of poking at the scheduler internals, and parametrize to cover the default scan_duration case.
2026-05-22 23:27:50 -05:00
J. Nick Koston 852c98b5d4 Merge remote-tracking branch 'upstream/dev' into feat/bluetooth-active-scan-window 2026-05-22 23:14:18 -05:00
J. Nick Koston 833e15d6f2 Bump habluetooth to 6.4.0 (#171918) 2026-05-23 00:10:50 -04:00
Matt ee56fd1eb0 Fix two HEOS bugs: host set construction and missing error decorator (#171913) 2026-05-22 18:42:43 -05:00
Felipe Santos e6528bae8a Add missing translation for connection failure on OpenRGB (#171892) 2026-05-22 21:59:39 +02:00
Joost Lekkerkerker a17eb65498 Refactor labs websocket API tests to use async_setup_component (#171891) 2026-05-22 21:53:52 +02:00
Joost Lekkerkerker 912a839d66 Don't call migrate entry in generic thermostat tests directly (#171887) 2026-05-22 21:44:10 +02:00
J. Nick Koston 65b2348e99 Merge remote-tracking branch 'upstream/dev' into feat/bluetooth-active-scan-window 2026-05-22 13:54:41 -05:00
J. Nick Koston 1f97b39bad cleanup 2026-05-22 13:54:39 -05:00
J. Nick Koston 0b5a8a0426 Merge branch 'dev' into feat/bluetooth-active-scan-window 2026-05-21 23:42:14 -05:00
J. Nick Koston d892c76f49 Wire scan_interval and scan_duration into bluetooth.async_register_callback
When an integration registers an ACTIVE callback for a specific
address and declares how often it actually needs an active scan, the
manager now forwards (address, scan_interval, scan_duration) into
habluetooth's async_register_active_scan so AUTO-mode scanners flip
to ACTIVE on demand for that device instead of forcing continuous
active scanning. Without an address in the matcher, or for PASSIVE
mode, the new kwargs are no-ops on the active-scan path and the
callback registration itself is unchanged.
2026-05-21 23:02:00 -05:00
11 changed files with 220 additions and 40 deletions
+15 -6
View File
@@ -130,17 +130,26 @@ def async_register_callback(
callback: BluetoothCallback,
match_dict: BluetoothCallbackMatcher | None,
mode: BluetoothScanningMode,
*,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> Callable[[], None]:
"""Register to receive a callback on bluetooth change.
mode is currently not used as we only support active scanning.
Passive scanning will be available in the future. The flag
is required to be present to avoid a future breaking change
when we support passive scanning.
When ``mode`` is not PASSIVE and ``match_dict["address"]`` is set,
the address is registered with habluetooth's active-scan scheduler
so AUTO-mode scanners flip ACTIVE on demand for that device.
``scan_interval`` / ``scan_duration`` default to habluetooth's
DEFAULT_ACTIVE_SCAN_* (5 minutes / 10 seconds) when not provided;
integrations that need a different cadence can pass explicit
values. Without an address in the matcher the active-scan request
is skipped; the callback itself still fires normally.
Returns a callback that can be used to cancel the registration.
"""
return _get_manager(hass).async_register_callback(callback, match_dict)
return _get_manager(hass).async_register_callback(
callback, match_dict, mode, scan_interval, scan_duration
)
async def async_process_advertisements(
@@ -161,7 +170,7 @@ async def async_process_advertisements(
done.set_result(service_info)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
try:
+20 -1
View File
@@ -202,6 +202,9 @@ class HomeAssistantBluetoothManager(BluetoothManager):
self,
callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None,
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> Callable[[], None]:
"""Register a callback."""
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
@@ -216,15 +219,31 @@ class HomeAssistantBluetoothManager(BluetoothManager):
connectable = callback_matcher[CONNECTABLE]
self._callback_index.add_callback_matcher(callback_matcher)
# If the matcher targets a specific address and the caller
# didn't explicitly ask for PASSIVE, wire it into habluetooth's
# active-scan scheduler so AUTO-mode scanners flip ACTIVE on
# demand for this device. ``scan_interval``/``scan_duration``
# default to habluetooth's DEFAULT_ACTIVE_SCAN_* when None.
cancel_active_scan: Callable[[], None] | None = None
if (
mode is not BluetoothScanningMode.PASSIVE
and (address := callback_matcher.get(ADDRESS)) is not None
):
cancel_active_scan = self.async_register_active_scan(
address, scan_interval, scan_duration
)
def _async_remove_callback() -> None:
self._callback_index.remove_callback_matcher(callback_matcher)
if cancel_active_scan is not None:
cancel_active_scan()
# If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the
# device.
history = self._connectable_history if connectable else self._all_history
service_infos: Iterable[BluetoothServiceInfoBleak] = []
if address := callback_matcher.get(ADDRESS):
if (address := callback_matcher.get(ADDRESS)) is not None:
if service_info := history.get(address):
service_infos = [service_info]
else:
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.29.11",
"dbus-fast==5.0.3",
"habluetooth==6.2.0"
"habluetooth==6.4.0"
]
}
+1 -1
View File
@@ -116,7 +116,7 @@ async def _validate_auth(
def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
"""Get a set of current hosts from the entry."""
hosts = set(entry.data[CONF_HOST])
hosts = {entry.data[CONF_HOST]}
if hasattr(entry, "runtime_data"):
hosts.update(
player.ip_address
@@ -473,6 +473,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
await self.coordinator.heos.set_group(new_members)
return
@catch_action_error("remove from queue")
async def async_remove_from_queue(self, queue_ids: list[int]) -> None:
"""Remove items from the queue."""
await self._player.remove_from_queue(queue_ids)
@@ -73,6 +73,9 @@
}
},
"exceptions": {
"cannot_connect": {
"message": "Failed to connect to OpenRGB SDK server {server_address}: {error}"
},
"communication_error": {
"message": "Failed to communicate with OpenRGB SDK server {server_address}: {error}"
},
+1 -1
View File
@@ -35,7 +35,7 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.2
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.2.0
habluetooth==6.4.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
+1 -1
View File
@@ -1210,7 +1210,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.2.0
habluetooth==6.4.0
# homeassistant.components.hanna
hanna-cloud==0.0.7
+140
View File
@@ -1642,6 +1642,117 @@ async def test_register_callback_by_address(
assert service_info.manufacturer_id == 89
@pytest.mark.parametrize(
("matcher", "mode", "kwargs", "expected_args"),
[
pytest.param(
{"address": "44:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
{"scan_interval": 300.0, "scan_duration": 5.0},
("44:44:33:11:23:45", 300.0, 5.0),
id="active_with_interval_and_duration",
),
pytest.param(
{"address": "44:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
{"scan_interval": 300.0},
("44:44:33:11:23:45", 300.0, None),
id="active_with_interval_default_duration",
),
pytest.param(
{"address": "44:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
{},
("44:44:33:11:23:45", None, None),
id="active_with_address_default_cadence",
),
],
)
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
async def test_register_callback_registers_active_scan(
hass: HomeAssistant,
matcher: dict[str, str],
mode: BluetoothScanningMode,
kwargs: dict[str, float],
expected_args: tuple[str, float | None, float | None],
) -> None:
"""An address matcher in non-PASSIVE mode registers an active-scan request."""
mock_bt: list[Any] = []
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
):
await async_setup_with_default_adapter(hass)
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
def _cb(_si: BluetoothServiceInfo, _ch: BluetoothChange) -> None:
return None
mock_cancel = Mock()
with patch.object(
HomeAssistantBluetoothManager,
"async_register_active_scan",
return_value=mock_cancel,
) as mock_register:
cancel = bluetooth.async_register_callback(
hass, _cb, matcher, mode, **kwargs
)
mock_register.assert_called_once_with(*expected_args)
mock_cancel.assert_not_called()
cancel()
mock_cancel.assert_called_once()
@pytest.mark.parametrize(
("matcher", "mode", "kwargs"),
[
pytest.param(
{"address": "44:44:33:11:23:45"},
BluetoothScanningMode.PASSIVE,
{"scan_interval": 300.0},
id="passive_mode",
),
pytest.param(
{SERVICE_UUID: "cba20d00-224d-11e6-9fb8-0002a5d5c51b"},
BluetoothScanningMode.ACTIVE,
{"scan_interval": 300.0},
id="no_address_in_matcher",
),
],
)
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
async def test_register_callback_skips_active_scan(
hass: HomeAssistant,
matcher: dict[str, str],
mode: BluetoothScanningMode,
kwargs: dict[str, float],
) -> None:
"""PASSIVE mode or a matcher without an address never registers an active scan."""
mock_bt: list[Any] = []
with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
):
await async_setup_with_default_adapter(hass)
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
def _cb(_si: BluetoothServiceInfo, _ch: BluetoothChange) -> None:
return None
with patch.object(
HomeAssistantBluetoothManager, "async_register_active_scan"
) as mock_register:
cancel = bluetooth.async_register_callback(
hass, _cb, matcher, mode, **kwargs
)
mock_register.assert_not_called()
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_register_callback_by_address_connectable_only(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
@@ -2536,6 +2647,35 @@ async def test_process_advertisements_timeout(
)
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
async def test_process_advertisements_wires_timeout_as_scan_duration(
hass: HomeAssistant,
) -> None:
"""async_process_advertisements forwards its timeout as scan_duration."""
def _callback(service_info: BluetoothServiceInfo) -> bool:
return False
mock_cancel = Mock()
with (
patch.object(
HomeAssistantBluetoothManager,
"async_register_active_scan",
return_value=mock_cancel,
) as mock_register,
pytest.raises(TimeoutError),
):
await async_process_advertisements(
hass,
_callback,
{"address": "aa:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
0,
)
mock_register.assert_called_once_with("aa:44:33:11:23:45", None, 0)
mock_cancel.assert_called_once()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_wrapped_instance_with_filter(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
@@ -626,15 +626,20 @@ async def test_migration_from_future_version(
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
async def test_migration_1_2(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("sensor_device")
async def test_migration_1_2(
hass: HomeAssistant,
sensor_entity_entry: er.RegistryEntry,
switch_entity_entry: er.RegistryEntry,
) -> None:
"""Test migration from 1.2 to 1.3 copies CONF_MIN_DUR to CONF_DUR_COOLDOWN."""
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"name": "My generic thermostat",
"heater": "switch.test",
"target_sensor": "sensor.test",
"heater": switch_entity_entry.entity_id,
"target_sensor": sensor_entity_entry.entity_id,
CONF_MIN_DUR: {"hours": 0, "minutes": 5, "seconds": 0},
"ac_mode": False,
"cold_tolerance": 0.3,
@@ -646,9 +651,10 @@ async def test_migration_1_2(hass: HomeAssistant) -> None:
)
config_entry.add_to_hass(hass)
# Run migration
result = await generic_thermostat.async_migrate_entry(hass, config_entry)
assert result is True
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
# After migration, cooldown should be set to min_cycle_duration
# and minor version bumped
@@ -657,4 +663,5 @@ async def test_migration_1_2(hass: HomeAssistant) -> None:
"minutes": 5,
"seconds": 0,
}
assert config_entry.version == 1
assert config_entry.minor_version == 3
+24 -23
View File
@@ -8,9 +8,10 @@ import pytest
from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
async_is_preview_feature_enabled,
async_setup,
)
from homeassistant.components.labs.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import assert_stored_labs_data
@@ -48,7 +49,7 @@ async def test_websocket_list_preview_features(
if load_integration:
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -68,7 +69,7 @@ async def test_websocket_update_preview_feature_enable(
"""Test enabling a preview feature via WebSocket."""
# Load kitchen_sink integration
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -132,7 +133,7 @@ async def test_websocket_update_preview_feature_disable(
}
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -164,7 +165,7 @@ async def test_websocket_update_nonexistent_feature(
hass_storage: dict[str, Any],
) -> None:
"""Test updating a preview feature that doesn't exist."""
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -193,7 +194,7 @@ async def test_websocket_update_unavailable_preview_feature(
) -> None:
"""Test updating a preview feature whose integration is not loaded still works."""
# Don't load kitchen_sink integration
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -235,7 +236,7 @@ async def test_websocket_requires_admin(
hass_admin_user.groups = []
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -264,7 +265,7 @@ async def test_websocket_update_validates_enabled_parameter(
) -> None:
"""Test that enabled parameter must be boolean."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -291,7 +292,7 @@ async def test_storage_persists_preview_feature_across_calls(
) -> None:
"""Test that storage persists preview feature state across multiple calls."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -350,7 +351,7 @@ async def test_preview_feature_urls_present(
) -> None:
"""Test that preview features include feedback and report URLs."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -401,7 +402,7 @@ async def test_websocket_update_preview_feature_backup_scenarios(
) -> None:
"""Test various backup scenarios when updating preview features."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -463,7 +464,7 @@ async def test_websocket_list_multiple_enabled_features(
}
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -484,7 +485,7 @@ async def test_websocket_update_rapid_toggle(
) -> None:
"""Test rapid toggling of a preview feature."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -534,7 +535,7 @@ async def test_websocket_update_same_state_idempotent(
) -> None:
"""Test that enabling an already-enabled feature is idempotent."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -572,7 +573,7 @@ async def test_websocket_list_filtered_by_loaded_components(
) -> None:
"""Test that list only shows features from loaded integrations."""
# Don't load kitchen_sink - its preview feature shouldn't appear
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -600,7 +601,7 @@ async def test_websocket_update_with_missing_required_field(
) -> None:
"""Test that missing required fields are rejected."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -625,7 +626,7 @@ async def test_websocket_event_data_structure(
) -> None:
"""Test that event data has correct structure."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -666,7 +667,7 @@ async def test_websocket_backup_timeout_handling(
) -> None:
"""Test handling of backup timeout/long-running backup."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -702,7 +703,7 @@ async def test_websocket_subscribe_feature(
) -> None:
"""Test subscribing to a specific preview feature."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -739,7 +740,7 @@ async def test_websocket_subscribe_feature_receives_updates(
) -> None:
"""Test that subscription receives updates when feature is toggled."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -793,7 +794,7 @@ async def test_websocket_subscribe_nonexistent_feature(
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test subscribing to a preview feature that doesn't exist."""
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -821,7 +822,7 @@ async def test_websocket_subscribe_does_not_require_admin(
hass_admin_user.groups = []
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
@@ -847,7 +848,7 @@ async def test_websocket_subscribe_only_receives_subscribed_feature_updates(
) -> None:
"""Test that subscription only receives updates for the subscribed feature."""
hass.config.components.add("kitchen_sink")
assert await async_setup(hass, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)