From 3909906823472351c09bd476cfff98ee82de84bf Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 2 Sep 2025 11:02:38 -0500 Subject: [PATCH 1/7] Add required features for mowing intents (#151580) --- homeassistant/components/lawn_mower/intent.py | 4 ++- tests/components/lawn_mower/test_intent.py | 26 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lawn_mower/intent.py b/homeassistant/components/lawn_mower/intent.py index ca06ed2e2389f0..a0176446b7709c 100644 --- a/homeassistant/components/lawn_mower/intent.py +++ b/homeassistant/components/lawn_mower/intent.py @@ -3,7 +3,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import DOMAIN, SERVICE_DOCK, SERVICE_START_MOWING +from . import DOMAIN, SERVICE_DOCK, SERVICE_START_MOWING, LawnMowerEntityFeature INTENT_LANW_MOWER_START_MOWING = "HassLawnMowerStartMowing" INTENT_LANW_MOWER_DOCK = "HassLawnMowerDock" @@ -20,6 +20,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: description="Starts a lawn mower", required_domains={DOMAIN}, platforms={DOMAIN}, + required_features=LawnMowerEntityFeature.START_MOWING, ), ) intent.async_register( @@ -31,5 +32,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None: description="Sends a lawn mower to dock", required_domains={DOMAIN}, platforms={DOMAIN}, + required_features=LawnMowerEntityFeature.DOCK, ), ) diff --git a/tests/components/lawn_mower/test_intent.py b/tests/components/lawn_mower/test_intent.py index f673833d75673a..81f64c3cffeef3 100644 --- a/tests/components/lawn_mower/test_intent.py +++ b/tests/components/lawn_mower/test_intent.py @@ -5,8 +5,10 @@ SERVICE_DOCK, SERVICE_START_MOWING, LawnMowerActivity, + LawnMowerEntityFeature, intent as lawn_mower_intent, ) +from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -18,7 +20,11 @@ async def test_start_lawn_mower_intent(hass: HomeAssistant) -> None: await lawn_mower_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_lawn_mower" - hass.states.async_set(entity_id, LawnMowerActivity.DOCKED) + hass.states.async_set( + entity_id, + LawnMowerActivity.DOCKED, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.START_MOWING}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_START_MOWING) response = await intent.async_handle( @@ -42,7 +48,11 @@ async def test_start_lawn_mower_without_name(hass: HomeAssistant) -> None: await lawn_mower_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_lawn_mower" - hass.states.async_set(entity_id, LawnMowerActivity.DOCKED) + hass.states.async_set( + entity_id, + LawnMowerActivity.DOCKED, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.START_MOWING}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_START_MOWING) response = await intent.async_handle( @@ -63,7 +73,11 @@ async def test_stop_lawn_mower_intent(hass: HomeAssistant) -> None: await lawn_mower_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_lawn_mower" - hass.states.async_set(entity_id, LawnMowerActivity.MOWING) + hass.states.async_set( + entity_id, + LawnMowerActivity.MOWING, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_DOCK) response = await intent.async_handle( @@ -87,7 +101,11 @@ async def test_stop_lawn_mower_without_name(hass: HomeAssistant) -> None: await lawn_mower_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_lawn_mower" - hass.states.async_set(entity_id, LawnMowerActivity.MOWING) + hass.states.async_set( + entity_id, + LawnMowerActivity.MOWING, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_DOCK) response = await intent.async_handle( From a8ff14ecb8fccf60aac8f37a66584da1646d27f7 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:09:21 +0200 Subject: [PATCH 2/7] Bump `volvocarsapi` to v0.4.2 (#151579) --- homeassistant/components/volvo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json index 1530634a10af17..c1979582804192 100644 --- a/homeassistant/components/volvo/manifest.json +++ b/homeassistant/components/volvo/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["volvocarsapi"], "quality_scale": "silver", - "requirements": ["volvocarsapi==0.4.1"] + "requirements": ["volvocarsapi==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 723e9252976c2b..a0e6fddf3bdf8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3068,7 +3068,7 @@ voip-utils==0.3.4 volkszaehler==0.4.0 # homeassistant.components.volvo -volvocarsapi==0.4.1 +volvocarsapi==0.4.2 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45821c01898829..19781c62a2a969 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2533,7 +2533,7 @@ vilfo-api-client==0.5.0 voip-utils==0.3.4 # homeassistant.components.volvo -volvocarsapi==0.4.1 +volvocarsapi==0.4.2 # homeassistant.components.volvooncall volvooncall==0.10.3 From 61c904d2259e488518dce6f00d8585fdb7e8677f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:11:15 +0200 Subject: [PATCH 3/7] Update pytest-rerunfailures to 16.0.1 (#151573) --- homeassistant/package_constraints.txt | 5 ++--- script/gen_requirements_all.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 60d0f341b75e62..11b7b0234e5575 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -224,6 +224,5 @@ pymodbus==3.11.1 # Some packages don't support gql 4.0.0 yet gql<4.0.0 -# pytest-rerunfailures 16.0 breaks pytest, pin 15.1 until resolved -# https://github.com/pytest-dev/pytest-rerunfailures/issues/302 -pytest-rerunfailures==15.1 +# Pin pytest-rerunfailures to prevent accidental breaks +pytest-rerunfailures==16.0.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ff3c6c182babca..8a6c09ff3a4c05 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -250,9 +250,8 @@ # Some packages don't support gql 4.0.0 yet gql<4.0.0 -# pytest-rerunfailures 16.0 breaks pytest, pin 15.1 until resolved -# https://github.com/pytest-dev/pytest-rerunfailures/issues/302 -pytest-rerunfailures==15.1 +# Pin pytest-rerunfailures to prevent accidental breaks +pytest-rerunfailures==16.0.1 """ GENERATED_MESSAGE = ( From a8f56e4b966149a0aaec7dca1eec5d3c1b1a59d4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 2 Sep 2025 18:21:38 +0200 Subject: [PATCH 4/7] Fix Slide local tests (#151569) Co-authored-by: Erik Montnemery --- tests/components/slide_local/__init__.py | 12 ++++++- tests/components/slide_local/conftest.py | 5 +-- tests/components/slide_local/const.py | 5 --- .../slide_local/test_config_flow.py | 19 +++++++--- tests/components/slide_local/test_cover.py | 36 ++++++++++--------- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/tests/components/slide_local/__init__.py b/tests/components/slide_local/__init__.py index cd7bd6cb6d1d3b..ac12738c2fdf8e 100644 --- a/tests/components/slide_local/__init__.py +++ b/tests/components/slide_local/__init__.py @@ -1,11 +1,13 @@ """Tests for the slide_local integration.""" +from typing import Any from unittest.mock import patch +from homeassistant.components.slide_local.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def setup_platform( @@ -19,3 +21,11 @@ async def setup_platform( await hass.async_block_till_done() return config_entry + + +def get_data() -> dict[str, Any]: + """Return the default state data. + + The coordinator mutates the returned API data, so we can't return a glocal dict. + """ + return load_json_object_fixture("slide_1.json", DOMAIN) diff --git a/tests/components/slide_local/conftest.py b/tests/components/slide_local/conftest.py index ad2734bbb64d0e..f5c48259b1221d 100644 --- a/tests/components/slide_local/conftest.py +++ b/tests/components/slide_local/conftest.py @@ -8,7 +8,8 @@ from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC -from .const import HOST, SLIDE_INFO_DATA +from . import get_data +from .const import HOST from tests.common import MockConfigEntry @@ -48,7 +49,7 @@ def mock_slide_api() -> Generator[AsyncMock]: ), ): client = mock_slide_local_api.return_value - client.slide_info.return_value = SLIDE_INFO_DATA + client.slide_info.return_value = get_data() yield client diff --git a/tests/components/slide_local/const.py b/tests/components/slide_local/const.py index edf457534074f0..2ba097e9107818 100644 --- a/tests/components/slide_local/const.py +++ b/tests/components/slide_local/const.py @@ -1,8 +1,3 @@ """Common const used across tests for slide_local.""" -from homeassistant.components.slide_local.const import DOMAIN - -from tests.common import load_json_object_fixture - HOST = "127.0.0.2" -SLIDE_INFO_DATA = load_json_object_fixture("slide_1.json", DOMAIN) diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py index b8b69d99fd802f..ac5e7506bb1145 100644 --- a/tests/components/slide_local/test_config_flow.py +++ b/tests/components/slide_local/test_config_flow.py @@ -18,8 +18,8 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import setup_platform -from .const import HOST, SLIDE_INFO_DATA +from . import get_data, setup_platform +from .const import HOST from tests.common import MockConfigEntry @@ -82,7 +82,10 @@ async def test_user_api_1( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + mock_slide_api.slide_info.side_effect = [ + None, + get_data(), + ] result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -129,7 +132,10 @@ async def test_user_api_error( assert result["step_id"] == "user" assert result["errors"]["base"] == "unknown" - mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + mock_slide_api.slide_info.side_effect = [ + None, + get_data(), + ] result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -188,7 +194,10 @@ async def test_api_1_exceptions( assert result["errors"]["base"] == error # tests with all provided - mock_slide_api.slide_info.side_effect = [None, SLIDE_INFO_DATA] + mock_slide_api.slide_info.side_effect = [ + None, + get_data(), + ] result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/slide_local/test_cover.py b/tests/components/slide_local/test_cover.py index 793f9d9513dada..a2262e6c89f32f 100644 --- a/tests/components/slide_local/test_cover.py +++ b/tests/components/slide_local/test_cover.py @@ -20,8 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_platform -from .const import SLIDE_INFO_DATA +from . import get_data, setup_platform from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -48,7 +47,9 @@ async def test_connection_error( """Test connection error.""" await setup_platform(hass, mock_config_entry, [Platform.COVER]) - mock_slide_api.slide_info.side_effect = [ClientConnectionError, SLIDE_INFO_DATA] + assert hass.states.get("cover.slide_bedroom").state == CoverState.OPEN + + mock_slide_api.slide_info.side_effect = [ClientConnectionError, get_data()] freezer.tick(delta=timedelta(minutes=1)) async_fire_time_changed(hass) @@ -69,15 +70,13 @@ async def test_state_change( mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test connection error.""" + """Test state changes.""" await setup_platform(hass, mock_config_entry, [Platform.COVER]) - mock_slide_api.slide_info.side_effect = [ - dict(SLIDE_INFO_DATA, pos=0.0), - dict(SLIDE_INFO_DATA, pos=0.4), - dict(SLIDE_INFO_DATA, pos=1.0), - dict(SLIDE_INFO_DATA, pos=0.8), - ] + mock_slide_api.slide_info.return_value = { + **get_data(), + "pos": 0.0, + } freezer.tick(delta=timedelta(minutes=1)) async_fire_time_changed(hass) @@ -85,18 +84,24 @@ async def test_state_change( assert hass.states.get("cover.slide_bedroom").state == CoverState.OPEN + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.4} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("cover.slide_bedroom").state == CoverState.CLOSING + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 1.0} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("cover.slide_bedroom").state == CoverState.CLOSED + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.8} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -171,12 +176,7 @@ async def test_set_position( await setup_platform(hass, mock_config_entry, [Platform.COVER]) - mock_slide_api.slide_info.side_effect = [ - dict(SLIDE_INFO_DATA, pos=0.0), - dict(SLIDE_INFO_DATA, pos=1.0), - dict(SLIDE_INFO_DATA, pos=1.0), - dict(SLIDE_INFO_DATA, pos=0.0), - ] + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.0} freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) @@ -189,6 +189,8 @@ async def test_set_position( blocking=True, ) + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 1.0} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -206,6 +208,8 @@ async def test_set_position( blocking=True, ) + mock_slide_api.slide_info.return_value = {**get_data(), "pos": 0.0} + freezer.tick(delta=timedelta(seconds=15)) async_fire_time_changed(hass) await hass.async_block_till_done() From fa0f70787215ece2fc0e1a381acea42a07bc40b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 13:17:27 -0500 Subject: [PATCH 5/7] Add bluetooth websocket_api to subscribe to scanner state (#151452) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/bluetooth/websocket_api.py | 147 +++++++++++++++--- .../bluetooth/test_websocket_api.py | 125 ++++++++++++++- 2 files changed, 247 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 9022d98bf06f39..042fe3fe24b219 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -8,8 +8,10 @@ from typing import Any from habluetooth import ( + BaseHaScanner, BluetoothScanningMode, HaBluetoothSlotAllocations, + HaScannerModeChange, HaScannerRegistration, HaScannerRegistrationEvent, ) @@ -27,12 +29,54 @@ from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source +@callback +def _async_get_source_from_config_entry( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg_id: int, + config_entry_id: str | None, + validate_source: bool = True, +) -> str | None: + """Get source from config entry id. + + Returns None if no config_entry_id provided or on error (after sending error response). + If validate_source is True, also validates that the scanner exists. + """ + if not config_entry_id: + return None + + if validate_source: + # Use the full validation that checks if scanner exists + try: + return config_entry_id_to_source(hass, config_entry_id) + except InvalidConfigEntryID as err: + connection.send_error(msg_id, "invalid_config_entry_id", str(err)) + return None + except InvalidSource as err: + connection.send_error(msg_id, "invalid_source", str(err)) + return None + + # Just check if config entry exists and belongs to bluetooth + if ( + not (entry := hass.config_entries.async_get_entry(config_entry_id)) + or entry.domain != DOMAIN + ): + connection.send_error( + msg_id, + "invalid_config_entry_id", + f"Config entry {config_entry_id} not found", + ) + return None + return entry.unique_id + + @callback def async_setup(hass: HomeAssistant) -> None: """Set up the bluetooth websocket API.""" websocket_api.async_register_command(hass, ws_subscribe_advertisements) websocket_api.async_register_command(hass, ws_subscribe_connection_allocations) websocket_api.async_register_command(hass, ws_subscribe_scanner_details) + websocket_api.async_register_command(hass, ws_subscribe_scanner_state) @lru_cache(maxsize=1024) @@ -180,16 +224,12 @@ async def ws_subscribe_connection_allocations( ) -> None: """Handle subscribe advertisements websocket command.""" ws_msg_id = msg["id"] - source: str | None = None - if config_entry_id := msg.get("config_entry_id"): - try: - source = config_entry_id_to_source(hass, config_entry_id) - except InvalidConfigEntryID as err: - connection.send_error(ws_msg_id, "invalid_config_entry_id", str(err)) - return - except InvalidSource as err: - connection.send_error(ws_msg_id, "invalid_source", str(err)) - return + config_entry_id = msg.get("config_entry_id") + source = _async_get_source_from_config_entry( + hass, connection, ws_msg_id, config_entry_id + ) + if config_entry_id and source is None: + return # Error already sent by helper def _async_allocations_changed(allocations: HaBluetoothSlotAllocations) -> None: connection.send_message( @@ -220,20 +260,12 @@ async def ws_subscribe_scanner_details( ) -> None: """Handle subscribe scanner details websocket command.""" ws_msg_id = msg["id"] - source: str | None = None - if config_entry_id := msg.get("config_entry_id"): - if ( - not (entry := hass.config_entries.async_get_entry(config_entry_id)) - or entry.domain != DOMAIN - ): - connection.send_error( - ws_msg_id, - "invalid_config_entry_id", - f"Invalid config entry id: {config_entry_id}", - ) - return - source = entry.unique_id - assert source is not None + config_entry_id = msg.get("config_entry_id") + source = _async_get_source_from_config_entry( + hass, connection, ws_msg_id, config_entry_id, validate_source=False + ) + if config_entry_id and source is None: + return # Error already sent by helper def _async_event_message(message: dict[str, Any]) -> None: connection.send_message( @@ -260,3 +292,70 @@ def _async_registration_changed(registration: HaScannerRegistration) -> None: ] ): _async_event_message({"add": matching_scanners}) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_scanner_state", + vol.Optional("config_entry_id"): str, + } +) +@websocket_api.async_response +async def ws_subscribe_scanner_state( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe scanner state websocket command.""" + ws_msg_id = msg["id"] + config_entry_id = msg.get("config_entry_id") + source = _async_get_source_from_config_entry( + hass, connection, ws_msg_id, config_entry_id, validate_source=False + ) + if config_entry_id and source is None: + return # Error already sent by helper + + @callback + def _async_send_scanner_state( + scanner: BaseHaScanner, + current_mode: BluetoothScanningMode | None, + requested_mode: BluetoothScanningMode | None, + ) -> None: + payload = { + "source": scanner.source, + "adapter": scanner.adapter, + "current_mode": current_mode.value if current_mode else None, + "requested_mode": requested_mode.value if requested_mode else None, + } + connection.send_message( + json_bytes( + websocket_api.event_message( + ws_msg_id, + payload, + ) + ) + ) + + @callback + def _async_scanner_state_changed(mode_change: HaScannerModeChange) -> None: + _async_send_scanner_state( + mode_change.scanner, + mode_change.current_mode, + mode_change.requested_mode, + ) + + manager = _get_manager(hass) + connection.subscriptions[ws_msg_id] = ( + manager.async_register_scanner_mode_change_callback( + _async_scanner_state_changed, source + ) + ) + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + + # Send initial state for all matching scanners + for scanner in manager.async_current_scanners(): + if source is None or scanner.source == source: + _async_send_scanner_state( + scanner, + scanner.current_mode, + scanner.requested_mode, + ) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index 19693db40004e1..1bb76065a5dc2a 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -7,6 +7,7 @@ from bleak_retry_connector import Allocations from freezegun import freeze_time +from habluetooth import BluetoothScanningMode import pytest from homeassistant.components.bluetooth import DOMAIN @@ -440,4 +441,126 @@ async def test_subscribe_scanner_details_invalid_config_entry_id( response = await client.receive_json() assert not response["success"] assert response["error"]["code"] == "invalid_config_entry_id" - assert response["error"]["message"] == "Invalid config entry id: non_existent" + assert response["error"]["message"] == "Config entry non_existent not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_state( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_state.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_state", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + # Should receive initial state for existing scanner + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "source": "00:00:00:00:00:01", + "adapter": "hci0", + "current_mode": "active", + "requested_mode": "active", + } + + # Register a new scanner + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + # Simulate a mode change + hci3_scanner.current_mode = BluetoothScanningMode.ACTIVE + hci3_scanner.requested_mode = BluetoothScanningMode.ACTIVE + manager.scanner_mode_changed(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "source": "AA:BB:CC:DD:EE:33", + "adapter": "hci3", + "current_mode": "active", + "requested_mode": "active", + } + + cancel_hci3() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_state_specific_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_state for a specific source address.""" + # Register the scanner first + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_state", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + # Should receive initial state + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "source": "AA:BB:CC:DD:EE:33", + "adapter": "hci3", + "current_mode": None, + "requested_mode": None, + } + + # Simulate a mode change + hci3_scanner.current_mode = BluetoothScanningMode.PASSIVE + hci3_scanner.requested_mode = BluetoothScanningMode.ACTIVE + manager.scanner_mode_changed(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "source": "AA:BB:CC:DD:EE:33", + "adapter": "hci3", + "current_mode": "passive", + "requested_mode": "active", + } + + cancel_hci3() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_state_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_state for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_state", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Config entry non_existent not found" From a023dfc013958794ea263edebc903de7771588f6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 2 Sep 2025 14:10:31 -0500 Subject: [PATCH 6/7] Add required features to vacuum intents (#151581) --- homeassistant/components/vacuum/intent.py | 4 +++- .../test_default_agent_intents.py | 9 +++++++- tests/components/vacuum/test_intent.py | 23 +++++++++++++++---- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py index 48340252b6e834..c5edbbd0338fa8 100644 --- a/homeassistant/components/vacuum/intent.py +++ b/homeassistant/components/vacuum/intent.py @@ -3,7 +3,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START +from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, VacuumEntityFeature INTENT_VACUUM_START = "HassVacuumStart" INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase" @@ -20,6 +20,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: description="Starts a vacuum", required_domains={DOMAIN}, platforms={DOMAIN}, + required_features=VacuumEntityFeature.START, ), ) intent.async_register( @@ -31,5 +32,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None: description="Returns a vacuum to base", required_domains={DOMAIN}, platforms={DOMAIN}, + required_features=VacuumEntityFeature.RETURN_HOME, ), ) diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 244fa6bda7bdc0..2b0e9f30190ab2 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -212,7 +212,14 @@ async def test_vacuum_intents( await vaccum_intent.async_setup_intents(hass) entity_id = f"{vacuum.DOMAIN}.rover" - hass.states.async_set(entity_id, STATE_CLOSED) + hass.states.async_set( + entity_id, + STATE_CLOSED, + { + ATTR_SUPPORTED_FEATURES: vacuum.VacuumEntityFeature.START + | vacuum.VacuumEntityFeature.RETURN_HOME + }, + ) async_expose_entity(hass, conversation.DOMAIN, entity_id, True) # start diff --git a/tests/components/vacuum/test_intent.py b/tests/components/vacuum/test_intent.py index 9ede7dbc04e0fb..f3500d28653897 100644 --- a/tests/components/vacuum/test_intent.py +++ b/tests/components/vacuum/test_intent.py @@ -4,9 +4,10 @@ DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, + VacuumEntityFeature, intent as vacuum_intent, ) -from homeassistant.const import STATE_IDLE +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_IDLE from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -18,7 +19,9 @@ async def test_start_vacuum_intent(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, STATE_IDLE, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.START} + ) calls = async_mock_service(hass, DOMAIN, SERVICE_START) response = await intent.async_handle( @@ -42,7 +45,9 @@ async def test_start_vacuum_without_name(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, STATE_IDLE, {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.START} + ) calls = async_mock_service(hass, DOMAIN, SERVICE_START) response = await intent.async_handle( @@ -63,7 +68,11 @@ async def test_stop_vacuum_intent(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, + STATE_IDLE, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_RETURN_TO_BASE) response = await intent.async_handle( @@ -87,7 +96,11 @@ async def test_stop_vacuum_without_name(hass: HomeAssistant) -> None: await vacuum_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_vacuum" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set( + entity_id, + STATE_IDLE, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.RETURN_HOME}, + ) calls = async_mock_service(hass, DOMAIN, SERVICE_RETURN_TO_BASE) response = await intent.async_handle( From 8b03a23ed8cd10ca936e5cb3ae76ba9877b1e3e4 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 3 Sep 2025 00:06:07 +0300 Subject: [PATCH 7/7] Add option descriptions to Z-Wave reconfigure flow (#151558) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/strings.json | 4 ++++ script/hassfest/translations.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index fffcb2ca9dd4f4..e02dff8e04a2f5 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -140,6 +140,10 @@ "menu_options": { "intent_migrate": "Migrate to a new adapter", "intent_reconfigure": "Re-configure the current adapter" + }, + "menu_option_descriptions": { + "intent_migrate": "This will move your Z-Wave network to a new adapter.", + "intent_reconfigure": "This will let you change the adapter configuration." } }, "instruct_unplug": { diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index e29967d6716f9d..d476ea5da4444b 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -170,6 +170,9 @@ def gen_data_entry_schema( vol.Optional("data"): {str: translation_value_validator}, vol.Optional("data_description"): {str: translation_value_validator}, vol.Optional("menu_options"): {str: translation_value_validator}, + vol.Optional("menu_option_descriptions"): { + str: translation_value_validator + }, vol.Optional("submit"): translation_value_validator, vol.Optional("sections"): { str: {