From 18b80aced3a1b4c9d3541e05e06ccb54534bf503 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 30 Sep 2025 11:38:16 +0200 Subject: [PATCH 01/16] Record current quality scale of Electricity Maps (#149241) --- .../components/co2signal/quality_scale.yaml | 106 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/co2signal/quality_scale.yaml diff --git a/homeassistant/components/co2signal/quality_scale.yaml b/homeassistant/components/co2signal/quality_scale.yaml new file mode 100644 index 00000000000000..d2ddb091e5ef7a --- /dev/null +++ b/homeassistant/components/co2signal/quality_scale.yaml @@ -0,0 +1,106 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: | + Stale docstring and test name: `test_form_home` and reusing result. + Extract `async_setup_entry` into own fixture. + Avoid importing `config_flow` in tests. + Test reauth with errors + config-flow: + status: todo + comment: | + The config flow misses data descriptions. + Remove URLs from data descriptions, they should be replaced with placeholders. + Make use of Electricity Maps zone keys in country code as dropdown. + Make use of location selector for coordinates. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: todo + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + The integration does not provide any additional options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: + status: todo + comment: | + Use `hass.config_entries.async_setup` instead of assert await `async_setup_component(hass, DOMAIN, {})` + `test_sensor` could use `snapshot_platform` + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a cloud service. + discovery: + status: exempt + comment: | + This integration cannot be discovered, it is a connecting to a cloud service. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + The integration connects to a single service per configuration entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not raise any repairable issues. + stale-devices: + status: exempt + comment: | + This integration connect to a single device per configuration entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index ea47339ac9a249..5f24e00f938a0a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -249,7 +249,6 @@ class Rule: "cloud", "cloudflare", "cmus", - "co2signal", "coinbase", "color_extractor", "comed_hourly_pricing", From 474b40511f7e6799b4c0bf607817bc415c8bb3f2 Mon Sep 17 00:00:00 2001 From: andreimoraru Date: Tue, 30 Sep 2025 14:19:06 +0300 Subject: [PATCH 02/16] Bump yt-dlp to 2025.09.26 (#153252) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 288921b624e67d..35977da9924aa9 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.09.23"], + "requirements": ["yt-dlp[default]==2025.09.26"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index a935108cbb299d..fd44f2638479a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3217,7 +3217,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.23 +yt-dlp[default]==2025.09.26 # homeassistant.components.zabbix zabbix-utils==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cf0ad1f026c82..02d281fcf9ebf7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2667,7 +2667,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.23 +yt-dlp[default]==2025.09.26 # homeassistant.components.zamg zamg==0.3.6 From 0960d78eb5838c0dc53b75adeda6a0aea3155230 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Tue, 30 Sep 2025 13:34:43 +0200 Subject: [PATCH 03/16] Use initial received WebSocket state in Bang & Olufsen (#152432) --- .../components/bang_olufsen/__init__.py | 7 +-- .../components/bang_olufsen/media_player.py | 29 +---------- tests/components/bang_olufsen/conftest.py | 35 ++++++++++++++ .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_media_player.ambr | 45 +++++++++++++---- .../bang_olufsen/test_diagnostics.py | 7 ++- tests/components/bang_olufsen/test_event.py | 2 + .../bang_olufsen/test_media_player.py | 48 +++++++++---------- 8 files changed, 109 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index eab2bb3d4e5442..34042666ae426b 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -73,11 +73,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) # Add the websocket and API client entry.runtime_data = BangOlufsenData(websocket, client) - # Start WebSocket connection - await client.connect_notifications(remote_control=True, reconnect=True) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Start WebSocket connection once the platforms have been loaded. + # This ensures that the initial WebSocket notifications are dispatched to entities + await client.connect_notifications(remote_control=True, reconnect=True) + return True diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index efb6843356bd2e..583b419eadf087 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -125,7 +125,8 @@ async def async_setup_entry( async_add_entities( new_entities=[ BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client) - ] + ], + update_before_add=True, ) # Register actions. @@ -266,34 +267,8 @@ async def _initialize(self) -> None: self._software_status.software_version, ) - # Get overall device state once. This is handled by WebSocket events the rest of the time. - product_state = await self._client.get_product_state() - - # Get volume information. - if product_state.volume: - self._volume = product_state.volume - - # Get all playback information. - # Ensure that the metadata is not None upon startup - if product_state.playback: - if product_state.playback.metadata: - self._playback_metadata = product_state.playback.metadata - self._remote_leader = product_state.playback.metadata.remote_leader - if product_state.playback.progress: - self._playback_progress = product_state.playback.progress - if product_state.playback.source: - self._source_change = product_state.playback.source - if product_state.playback.state: - self._playback_state = product_state.playback.state - # Set initial state - if self._playback_state.value: - self._state = self._playback_state.value - self._attr_media_position_updated_at = utcnow() - # Get the highest resolution available of the given images. - self._media_image = get_highest_resolution_artwork(self._playback_metadata) - # If the device has been updated with new sources, then the API will fail here. await self._async_update_sources() diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index c7915968cbfea9..0ddbfcafc58a6e 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -76,6 +76,39 @@ def mock_config_entry_core() -> MockConfigEntry: ) +async def mock_websocket_connection( + hass: HomeAssistant, mock_mozart_client: AsyncMock +) -> None: + """Register and receive initial WebSocket notifications.""" + + # Currently only add notifications that are used. + + # Register callbacks. + volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0] + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + playback_state_callback = ( + mock_mozart_client.get_playback_state_notifications.call_args[0][0] + ) + playback_metadata_callback = ( + mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] + ) + + # Trigger callbacks. Try to use existing data + volume_callback(mock_mozart_client.get_product_state.return_value.volume) + source_change_callback( + mock_mozart_client.get_product_state.return_value.playback.source + ) + playback_state_callback( + mock_mozart_client.get_product_state.return_value.playback.state + ) + playback_metadata_callback( + mock_mozart_client.get_product_state.return_value.playback.metadata + ) + await hass.async_block_till_done() + + @pytest.fixture(name="integration") async def integration_fixture( hass: HomeAssistant, @@ -88,6 +121,8 @@ async def integration_fixture( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + await mock_websocket_connection(hass, mock_mozart_client) + @pytest.fixture def mock_mozart_client() -> Generator[AsyncMock]: diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index bc51f89f96dde6..80944a7112d268 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -64,6 +64,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': 'music', + 'repeat': 'off', + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index be7989a2cb926f..38b2d9b4156e7a 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -47,7 +47,7 @@ 'state': 'playing', }) # --- -# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-2] +# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-3] StateSnapshot({ 'attributes': ReadOnlyDict({ 'beolink': dict({ @@ -96,7 +96,7 @@ 'state': 'playing', }) # --- -# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-2] +# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-3] StateSnapshot({ 'attributes': ReadOnlyDict({ 'beolink': dict({ @@ -145,7 +145,7 @@ 'state': 'playing', }) # --- -# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-1] +# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-2] StateSnapshot({ 'attributes': ReadOnlyDict({ 'beolink': dict({ @@ -194,7 +194,7 @@ 'state': 'playing', }) # --- -# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-1] +# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-2] StateSnapshot({ 'attributes': ReadOnlyDict({ 'beolink': dict({ @@ -412,6 +412,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -458,6 +460,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -504,6 +508,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -647,6 +653,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -659,13 +667,14 @@ 'HDMI A', ]), 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'playing', + 'state': 'idle', }) # --- # name: test_async_join_players[group_members1-0-1] @@ -742,6 +751,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -754,13 +765,14 @@ 'HDMI A', ]), 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'playing', + 'state': 'idle', }) # --- # name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source] @@ -789,6 +801,8 @@ ]), 'media_content_type': , 'media_position': 0, + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -836,6 +850,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -848,13 +864,14 @@ 'HDMI A', ]), 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'playing', + 'state': 'idle', }) # --- # name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity] @@ -882,6 +899,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -929,6 +948,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -941,13 +962,14 @@ 'HDMI A', ]), 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'playing', + 'state': 'idle', }) # --- # name: test_async_unjoin_player @@ -1021,6 +1043,8 @@ 'media_player.beosound_balance_11111111', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -1067,6 +1091,8 @@ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), 'media_content_type': , + 'repeat': , + 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', 'sound_mode_list': list([ 'Test Listening Mode (123)', @@ -1079,12 +1105,13 @@ 'HDMI A', ]), 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'playing', + 'state': 'idle', }) # --- diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index efa5a0a86801d0..9b74963ef2d547 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -6,9 +6,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry +from .conftest import mock_websocket_connection from .const import TEST_BUTTON_EVENT_ENTITY_ID -from tests.common import MockConfigEntry +from tests.common import AsyncMock, MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,6 +20,7 @@ async def test_async_get_config_entry_diagnostics( hass_client: ClientSessionGenerator, integration: None, mock_config_entry: MockConfigEntry, + mock_mozart_client: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -27,6 +29,9 @@ async def test_async_get_config_entry_diagnostics( entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) hass.config_entries.async_schedule_reload(mock_config_entry.entry_id) + # Re-trigger WebSocket events after the reload + await mock_websocket_connection(hass, mock_mozart_client) + result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index 1e5546ac5f2fe2..bb9c73893332e3 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry +from .conftest import mock_websocket_connection from .const import TEST_BUTTON_EVENT_ENTITY_ID from tests.common import MockConfigEntry @@ -61,6 +62,7 @@ async def test_button_event_creation_beoconnect_core( # Load entry mock_config_entry_core.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry_core.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) # Check number of entities # The media_player entity should be the only available diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 33719cb2311bcb..9c2bf99f87a9da 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -76,6 +76,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component +from .conftest import mock_websocket_connection from .const import ( TEST_ACTIVE_SOUND_MODE_NAME, TEST_ACTIVE_SOUND_MODE_NAME_2, @@ -126,12 +127,12 @@ async def test_initialization( mock_mozart_client: AsyncMock, ) -> None: """Test the integration is initialized properly in _initialize, async_added_to_hass and __init__.""" - caplog.set_level(logging.DEBUG) # Setup entity mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) # Ensure that the logger has been called with the debug message assert "Connected to: Beosound Balance 11111111 running SW 1.0.0" in caplog.text @@ -145,14 +146,13 @@ async def test_initialization( # Check API calls mock_mozart_client.get_softwareupdate_status.assert_called_once() - mock_mozart_client.get_product_state.assert_called_once() mock_mozart_client.get_available_sources.assert_called_once() mock_mozart_client.get_remote_menu.assert_called_once() mock_mozart_client.get_listening_mode_set.assert_called_once() mock_mozart_client.get_active_listening_mode.assert_called_once() mock_mozart_client.get_beolink_self.assert_called_once() - mock_mozart_client.get_beolink_peers.assert_called_once() - mock_mozart_client.get_beolink_listeners.assert_called_once() + assert mock_mozart_client.get_beolink_peers.call_count == 2 + assert mock_mozart_client.get_beolink_listeners.call_count == 2 async def test_async_update_sources_audio_only( @@ -165,6 +165,7 @@ async def test_async_update_sources_audio_only( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_AUDIO_SOURCES @@ -180,6 +181,7 @@ async def test_async_update_sources_outdated_api( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ( @@ -194,7 +196,6 @@ async def test_async_update_sources_remote( mock_mozart_client: AsyncMock, ) -> None: """Test _async_update_sources is called when there are new video sources.""" - notification_callback = mock_mozart_client.get_notification_notifications.call_args[ 0 ][0] @@ -221,6 +222,7 @@ async def test_async_update_sources_availability( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) playback_source_callback = ( mock_mozart_client.get_playback_source_notifications.call_args[0][0] @@ -408,7 +410,6 @@ async def test_async_turn_off( mock_mozart_client: AsyncMock, ) -> None: """Test async_turn_off.""" - playback_state_callback = ( mock_mozart_client.get_playback_state_notifications.call_args[0][0] ) @@ -475,6 +476,7 @@ async def test_async_update_beolink_line_in( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) source_change_callback = ( mock_mozart_client.get_source_change_notifications.call_args[0][0] @@ -488,9 +490,9 @@ async def test_async_update_beolink_line_in( assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes["group_members"] == [] - # Called once during _initialize and once during _async_update_beolink - assert mock_mozart_client.get_beolink_listeners.call_count == 2 - assert mock_mozart_client.get_beolink_peers.call_count == 2 + # Called twice during _initialize and once during WebSocket connection + assert mock_mozart_client.get_beolink_listeners.call_count == 3 + assert mock_mozart_client.get_beolink_peers.call_count == 3 async def test_async_update_beolink_listener( @@ -525,10 +527,10 @@ async def test_async_update_beolink_listener( ] # Called once for each entity during _initialize - assert mock_mozart_client.get_beolink_listeners.call_count == 2 + assert mock_mozart_client.get_beolink_listeners.call_count == 3 # Called once for each entity during _initialize and # once more during _async_update_beolink for the entity that has the callback associated with it. - assert mock_mozart_client.get_beolink_peers.call_count == 3 + assert mock_mozart_client.get_beolink_peers.call_count == 4 # Main entity assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) @@ -553,6 +555,7 @@ async def test_async_update_name_and_beolink( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + await mock_websocket_connection(hass, mock_mozart_client) configuration_callback = ( mock_mozart_client.get_notification_notifications.call_args[0][0] @@ -563,8 +566,8 @@ async def test_async_update_name_and_beolink( await hass.async_block_till_done() assert mock_mozart_client.get_beolink_self.call_count == 2 - assert mock_mozart_client.get_beolink_peers.call_count == 2 - assert mock_mozart_client.get_beolink_listeners.call_count == 2 + assert mock_mozart_client.get_beolink_peers.call_count == 3 + assert mock_mozart_client.get_beolink_listeners.call_count == 3 # Check that device name has been changed assert mock_config_entry.unique_id @@ -841,7 +844,6 @@ async def test_async_select_sound_mode_invalid( integration: None, ) -> None: """Test async_select_sound_mode with an invalid sound_mode.""" - with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -863,7 +865,6 @@ async def test_async_play_media_invalid_type( integration: None, ) -> None: """Test async_play_media only accepts valid media types.""" - with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -910,7 +911,6 @@ async def test_async_play_media_overlay_absolute_volume_uri( mock_mozart_client: AsyncMock, ) -> None: """Test async_play_media overlay with Home Assistant local URI and absolute volume.""" - await async_setup_component(hass, "media_source", {"media_source": {}}) await hass.services.async_call( @@ -1062,7 +1062,6 @@ async def test_async_play_media_deezer_flow( mock_mozart_client: AsyncMock, ) -> None: """Test async_play_media with Deezer flow.""" - # Send a service call await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -1132,7 +1131,6 @@ async def test_async_play_media_invalid_deezer( mock_mozart_client: AsyncMock, ) -> None: """Test async_play_media with an invalid/no Deezer login.""" - mock_mozart_client.start_deezer_flow.side_effect = TEST_DEEZER_INVALID_FLOW with pytest.raises(HomeAssistantError) as exc_info: @@ -1231,7 +1229,6 @@ async def test_async_browse_media( present: bool, ) -> None: """Test async_browse_media with audio and video source.""" - await async_setup_component(hass, "media_source", {"media_source": {}}) client = await hass_ws_client() @@ -1489,18 +1486,18 @@ async def test_async_beolink_join_invalid( [ # All discovered # Valid peers - ("all_discovered", True, None, [], 2), + ("all_discovered", True, None, [], 3), # Invalid peers ( "all_discovered", True, NotFoundException(), [f"Unable to expand to {TEST_JID_3}", f"Unable to expand to {TEST_JID_4}"], - 2, + 3, ), # Beolink JIDs # Valid peer - ("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 1), + ("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 2), # Invalid peer ( "beolink_jids", @@ -1510,7 +1507,7 @@ async def test_async_beolink_join_invalid( f"Unable to expand to {TEST_JID_3}. Is the device available on the network?", f"Unable to expand to {TEST_JID_4}. Is the device available on the network?", ], - 1, + 2, ), ], ) @@ -1622,9 +1619,8 @@ async def test_async_set_repeat( repeat: RepeatMode, ) -> None: """Test async_set_repeat.""" - assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert ATTR_MEDIA_REPEAT not in states.attributes + assert states.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.OFF # Set the return value of the repeat endpoint to match service call mock_mozart_client.get_settings_queue.return_value = PlayQueueSettings( @@ -1668,7 +1664,7 @@ async def test_async_set_shuffle( ) -> None: """Test async_set_shuffle.""" assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert ATTR_MEDIA_SHUFFLE not in states.attributes + assert states.attributes[ATTR_MEDIA_SHUFFLE] is False # Set the return value of the shuffle endpoint to match service call mock_mozart_client.get_settings_queue.return_value = PlayQueueSettings( From fbabb2778757333e857c14ccaf0464c8f0ebc0d5 Mon Sep 17 00:00:00 2001 From: Imeon-Energy Date: Tue, 30 Sep 2025 13:35:18 +0200 Subject: [PATCH 04/16] Add forecast energy sensor to Imeon inverter integration (#152176) Co-authored-by: TheBushBoy --- .../components/imeon_inverter/icons.json | 6 + .../components/imeon_inverter/sensor.py | 15 +++ .../components/imeon_inverter/strings.json | 6 + .../imeon_inverter/fixtures/entity_data.json | 4 + .../imeon_inverter/snapshots/test_sensor.ambr | 106 ++++++++++++++++++ 5 files changed, 137 insertions(+) diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json index afd98d697c6a21..34ecd9d792315f 100644 --- a/homeassistant/components/imeon_inverter/icons.json +++ b/homeassistant/components/imeon_inverter/icons.json @@ -169,6 +169,12 @@ }, "energy_battery_consumed": { "default": "mdi:battery-arrow-down-outline" + }, + "forecast_cons_remaining_today": { + "default": "mdi:chart-line" + }, + "forecast_prod_remaining_today": { + "default": "mdi:chart-line" } }, "select": { diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py index 21aa37a0523624..3aa26f4a3c31d4 100644 --- a/homeassistant/components/imeon_inverter/sensor.py +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -417,6 +417,21 @@ state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, ), + # Forecast + SensorEntityDescription( + key="forecast_cons_remaining_today", + translation_key="forecast_cons_remaining_today", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="forecast_prod_remaining_today", + translation_key="forecast_prod_remaining_today", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=2, + ), ) diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json index a84e5e6ef776c6..50ca969746d54a 100644 --- a/homeassistant/components/imeon_inverter/strings.json +++ b/homeassistant/components/imeon_inverter/strings.json @@ -213,6 +213,12 @@ }, "energy_battery_consumed": { "name": "Today battery-consumed energy" + }, + "forecast_cons_remaining_today": { + "name": "Forecast remaining energy consumption for today" + }, + "forecast_prod_remaining_today": { + "name": "Forecast remaining energy production for today" } }, "select": { diff --git a/tests/components/imeon_inverter/fixtures/entity_data.json b/tests/components/imeon_inverter/fixtures/entity_data.json index aa254b6a625e05..2fe9f5ebe66247 100644 --- a/tests/components/imeon_inverter/fixtures/entity_data.json +++ b/tests/components/imeon_inverter/fixtures/entity_data.json @@ -78,5 +78,9 @@ "building_consumption": 15000.0, "battery_stored": 8000.0, "battery_consumed": 2000.0 + }, + "forecast": { + "cons_remaining_today": 3000.0, + "prod_remaining_today": 7000.0 } } diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index de8ef9cce19f68..35b51043c73630 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -504,6 +504,112 @@ 'state': '45.5', }) # --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_consumption_for_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_forecast_remaining_energy_consumption_for_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Forecast remaining energy consumption for today', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forecast_cons_remaining_today', + 'unique_id': '111111111111111_forecast_cons_remaining_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_consumption_for_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Forecast remaining energy consumption for today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_forecast_remaining_energy_consumption_for_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_production_for_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_forecast_remaining_energy_production_for_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Forecast remaining energy production for today', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forecast_prod_remaining_today', + 'unique_id': '111111111111111_forecast_prod_remaining_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_forecast_remaining_energy_production_for_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Forecast remaining energy production for today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_forecast_remaining_energy_production_for_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7000.0', + }) +# --- # name: test_sensors[sensor.imeon_inverter_grid_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2aa4ca135195757077fbbc7b5a8c644c336a3732 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Sep 2025 14:04:09 +0200 Subject: [PATCH 05/16] Correct homekit service definition (#153242) --- homeassistant/components/homekit/services.yaml | 8 ++++++-- homeassistant/components/homekit/strings.json | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index 8e9d659af942d2..ffd17d1e8d71e4 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -2,8 +2,12 @@ reload: reset_accessory: - target: - entity: {} + fields: + entity_id: + required: true + selector: + entity: + multiple: true unpair: fields: diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index ce01773af2044c..1ec897660a1b45 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -76,7 +76,13 @@ }, "reset_accessory": { "name": "Reset accessory", - "description": "Resets a HomeKit accessory." + "description": "Resets a HomeKit accessory.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Entity to reset." + } + } }, "unpair": { "name": "Unpair an accessory or bridge", From 68f63be62f3d8557ff6d57edf56980ebb1d8fee2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Sep 2025 14:05:46 +0200 Subject: [PATCH 06/16] Correct target filter in litterrobot services (#153243) --- homeassistant/components/litterrobot/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/litterrobot/services.yaml b/homeassistant/components/litterrobot/services.yaml index 48d17dfdcf72c5..24171a8b6a6536 100644 --- a/homeassistant/components/litterrobot/services.yaml +++ b/homeassistant/components/litterrobot/services.yaml @@ -3,6 +3,7 @@ set_sleep_mode: target: entity: + domain: vacuum integration: litterrobot fields: enabled: From c58ba734e7c3a4d349beff34592ac92b6b1c7619 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Sep 2025 14:06:14 +0200 Subject: [PATCH 07/16] Correct target filter in osoenergy services (#153244) --- homeassistant/components/osoenergy/services.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/osoenergy/services.yaml b/homeassistant/components/osoenergy/services.yaml index 4cd91f3285ff95..a602598a70a942 100644 --- a/homeassistant/components/osoenergy/services.yaml +++ b/homeassistant/components/osoenergy/services.yaml @@ -2,10 +2,12 @@ get_profile: target: entity: domain: water_heater + integration: osoenergy set_profile: target: entity: domain: water_heater + integration: osoenergy fields: hour_00: required: false @@ -227,6 +229,7 @@ set_v40_min: target: entity: domain: water_heater + integration: osoenergy fields: v40_min: required: true @@ -241,6 +244,7 @@ turn_away_mode_on: target: entity: domain: water_heater + integration: osoenergy fields: duration_days: required: true @@ -255,6 +259,7 @@ turn_off: target: entity: domain: water_heater + integration: osoenergy fields: until_temp_limit: required: true @@ -266,6 +271,7 @@ turn_on: target: entity: domain: water_heater + integration: osoenergy fields: until_temp_limit: required: true From 7a41cbc314659b074bfe9646c2449d6ae5376f13 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Sep 2025 15:12:19 +0200 Subject: [PATCH 08/16] Skip unserializable flows in WS config_entries/flow/subscribe (#153259) --- .../components/config/config_entries.py | 55 ++++++-- .../components/config/test_config_entries.py | 128 ++++++++++++++++++ 2 files changed, 171 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 6766bce3f0aafa..db82abd20961a8 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -4,6 +4,7 @@ from collections.abc import Callable from http import HTTPStatus +import logging from typing import Any, NoReturn from aiohttp import web @@ -23,7 +24,12 @@ FlowManagerResourceView, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.json import json_fragment +from homeassistant.helpers.json import ( + JSON_DUMP, + find_paths_unserializable_data, + json_bytes, + json_fragment, +) from homeassistant.loader import ( Integration, IntegrationNotFound, @@ -31,6 +37,9 @@ async_get_integrations, async_get_loaded_integration, ) +from homeassistant.util.json import format_unserializable_data + +_LOGGER = logging.getLogger(__name__) @callback @@ -402,18 +411,40 @@ def async_on_flow_init_remove(change_type: str, flow_id: str) -> None: connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( async_on_flow_init_remove ) - connection.send_message( - websocket_api.event_message( - msg["id"], - [ - {"type": None, "flow_id": flw["flow_id"], "flow": flw} - for flw in hass.config_entries.flow.async_progress() - if flw["context"]["source"] - not in ( - config_entries.SOURCE_RECONFIGURE, - config_entries.SOURCE_USER, + try: + serialized_flows = [ + json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw}) + for flw in hass.config_entries.flow.async_progress() + if flw["context"]["source"] + not in ( + config_entries.SOURCE_RECONFIGURE, + config_entries.SOURCE_USER, + ) + ] + except (ValueError, TypeError): + # If we can't serialize, we'll filter out unserializable flows + serialized_flows = [] + for flw in hass.config_entries.flow.async_progress(): + if flw["context"]["source"] in ( + config_entries.SOURCE_RECONFIGURE, + config_entries.SOURCE_USER, + ): + continue + try: + serialized_flows.append( + json_bytes({"type": None, "flow_id": flw["flow_id"], "flow": flw}) ) - ], + except (ValueError, TypeError): + _LOGGER.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(flw, dump=JSON_DUMP) + ), + ) + continue + connection.send_message( + websocket_api.messages.construct_event_message( + msg["id"], b"".join((b"[", b",".join(serialized_flows), b"]")) ) ) connection.send_result(msg["id"]) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 5819e632d602c5..17703c0958b08a 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1122,6 +1122,134 @@ async def async_step_reconfigure( } +async def test_get_progress_subscribe_in_progress_bad_flow( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test querying for the flows that are in progress.""" + assert await async_setup_component(hass, "config", {}) + mock_platform(hass, "test.config_flow", None) + mock_platform(hass, "test2.config_flow", None) + ws_client = await hass_ws_client(hass) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_bluetooth( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a bluetooth discovery.""" + return self.async_abort(reason="already_configured") + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" + return await self.async_step_account() + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a reauthentication flow.""" + nonlocal entry + assert self._get_reauth_entry() is entry + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + + class BadFlow(core_ce.ConfigFlow): + VERSION = 1 + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + self.context["bad"] = self # This can't be serialized by the JSON encoder + return await self.async_step_account() + + flow_context = { + "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH}, + "hassio": {"source": core_ce.SOURCE_HASSIO}, + "user": {"source": core_ce.SOURCE_USER}, + "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}, + "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}, + } + forms = {} + + with mock_config_flow("test", TestFlow): + for key, context in flow_context.items(): + forms[key] = await hass.config_entries.flow.async_init( + "test", context=context + ) + + assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT + for key in ("hassio", "user", "reauth", "reconfigure"): + assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM + assert forms[key]["step_id"] == "account" + + with mock_config_flow("test2", BadFlow): + forms["bad"] = await hass.config_entries.flow.async_init( + "test2", context={"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"} + ) + assert forms["bad"]["type"] == data_entry_flow.FlowResultType.FORM + assert forms["bad"]["step_id"] == "account" + + await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"}) + + # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE + # should be filtered out + responses = [] + responses.append(await ws_client.receive_json()) + assert responses == [ + { + "event": unordered( + [ + { + "flow": { + "flow_id": forms[key]["flow_id"], + "handler": "test", + "step_id": "account", + "context": flow_context[key], + }, + "flow_id": forms[key]["flow_id"], + "type": None, + } + for key in ("hassio", "reauth") + ] + ), + "id": 1, + "type": "event", + } + ] + + response = await ws_client.receive_json() + assert response == {"id": ANY, "result": None, "success": True, "type": "result"} + + assert "Unable to serialize to JSON. Bad data found at $.context.bad" in caplog.text + + async def test_get_progress_subscribe_unauth( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: From ec503618c343d1397f4266bcd766f446cb94b3bc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Sep 2025 15:12:41 +0200 Subject: [PATCH 09/16] Handle errors in WS manifest/list (#153256) --- homeassistant/components/websocket_api/commands.py | 9 +++++++-- tests/components/websocket_api/test_commands.py | 11 +++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index b63e5e1482068d..d69a8c35c4f90a 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -647,9 +647,14 @@ async def handle_manifest_list( hass, msg.get("integrations") or async_get_loaded_integrations(hass) ) manifest_json_fragments: list[json_fragment] = [] - for int_or_exc in ints_or_excs.values(): + for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): - raise int_or_exc + _LOGGER.error( + "Unable to get manifest for integration %s: %s", + domain, + int_or_exc, + ) + continue manifest_json_fragments.append(int_or_exc.manifest_json_fragment) connection.send_result(msg["id"], manifest_json_fragments) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 253b77b377b243..07a433754ff03e 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2307,14 +2307,21 @@ async def test_manifest_list( ] +@pytest.mark.parametrize( + "integrations", + [ + ["hue", "websocket_api"], + ["hue", "non_existing", "websocket_api"], + ], +) async def test_manifest_list_specific_integrations( - hass: HomeAssistant, websocket_client + hass: HomeAssistant, websocket_client, integrations: list[str] ) -> None: """Test loading manifests for specific integrations.""" websocket_api = await async_get_integration(hass, "websocket_api") await websocket_client.send_json_auto_id( - {"type": "manifest/list", "integrations": ["hue", "websocket_api"]} + {"type": "manifest/list", "integrations": integrations} ) hue = await async_get_integration(hass, "hue") From 905f5e7289275b0a3bb967842331b0098137e32d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Sep 2025 15:28:04 +0200 Subject: [PATCH 10/16] Add device class filter to entity services (#153247) --- homeassistant/helpers/entity_platform.py | 3 ++ homeassistant/helpers/service.py | 17 ++++++ tests/helpers/test_service.py | 67 ++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2587a197005b29..0a676351ee0521 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1079,6 +1079,8 @@ def async_register_entity_service( func: str | Callable[..., Any], required_features: Iterable[int] | None = None, supports_response: SupportsResponse = SupportsResponse.NONE, + *, + entity_device_classes: Iterable[str | None] | None = None, ) -> None: """Register an entity service. @@ -1091,6 +1093,7 @@ def async_register_entity_service( self.hass, self.platform_name, name, + entity_device_classes=entity_device_classes, entities=self.domain_platform_entities, func=func, job_type=None, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index aeba4b28cce328..189abd6474dbda 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -761,6 +761,8 @@ async def entity_service_call( func: str | HassJob, call: ServiceCall, required_features: Iterable[int] | None = None, + *, + entity_device_classes: Iterable[str | None] | None = None, ) -> EntityServiceResponse | None: """Handle an entity service call. @@ -823,6 +825,17 @@ async def entity_service_call( if not entity.available: continue + # Skip entities that don't have the required device class. + if ( + entity_device_classes is not None + and entity.device_class not in entity_device_classes + ): + # If entity explicitly referenced, raise an error + if referenced is not None and entity.entity_id in referenced.referenced: + raise ServiceNotSupported(call.domain, call.service, entity.entity_id) + + continue + # Skip entities that don't have the required feature. if required_features is not None and ( entity.supported_features is None @@ -1134,6 +1147,7 @@ def async_register_entity_service( domain: str, name: str, *, + entity_device_classes: Iterable[str | None] | None = None, entities: dict[str, Entity], func: str | Callable[..., Any], job_type: HassJobType | None, @@ -1160,6 +1174,7 @@ def async_register_entity_service( hass, entities, service_func, + entity_device_classes=entity_device_classes, required_features=required_features, ), schema, @@ -1174,6 +1189,7 @@ def async_register_platform_entity_service( service_domain: str, service_name: str, *, + entity_device_classes: Iterable[str | None] | None = None, entity_domain: str, func: str | Callable[..., Any], required_features: Iterable[int] | None = None, @@ -1204,6 +1220,7 @@ def get_entities() -> dict[str, Entity]: hass, get_entities, service_func, + entity_device_classes=entity_device_classes, required_features=required_features, ), schema, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 7285d5c7df8a9d..e61d1382af28ef 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -91,24 +91,28 @@ def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]: available=True, should_poll=False, supported_features=SUPPORT_A, + device_class=None, ) living_room = MockEntity( entity_id="light.living_room", available=True, should_poll=False, supported_features=SUPPORT_B, + device_class="class_a", ) bedroom = MockEntity( entity_id="light.bedroom", available=True, should_poll=False, supported_features=(SUPPORT_A | SUPPORT_B), + device_class="class_b", ) bathroom = MockEntity( entity_id="light.bathroom", available=True, should_poll=False, supported_features=(SUPPORT_B | SUPPORT_C), + device_class="class_c", ) entities = {} entities[kitchen.entity_id] = kitchen @@ -1478,6 +1482,69 @@ async def test_call_with_one_of_required_features( assert all(entity in actual for entity in expected) +@pytest.mark.parametrize( + ("entity_device_classes", "expected_entities", "unsupported_entity"), + [ + ( + [None], + ["light.kitchen"], + "light.living_room", + ), + ( + ["class_a"], + ["light.living_room"], + "light.kitchen", + ), + ( + [None, "class_a"], + ["light.kitchen", "light.living_room"], + "light.bedroom", + ), + ], +) +async def test_call_with_device_class( + hass: HomeAssistant, + mock_entities, + entity_device_classes: list[str | None], + expected_entities: list[str], + unsupported_entity: str, +) -> None: + """Test service calls invoked only if entity has required features.""" + # Set up homeassistant component to fetch the translations + await async_setup_component(hass, "homeassistant", {}) + test_service_mock = AsyncMock(return_value=None) + await service.entity_service_call( + hass, + mock_entities, + HassJob(test_service_mock), + ServiceCall(hass, "test_domain", "test_service", {"entity_id": "all"}), + entity_device_classes=entity_device_classes, + ) + + assert test_service_mock.call_count == len(expected_entities) + expected = [mock_entities[expected_entity] for expected_entity in expected_entities] + actual = [call[0][0] for call in test_service_mock.call_args_list] + assert actual == unordered(expected) + + # Test we raise if we target entity ID that does not support the service + test_service_mock.reset_mock() + with pytest.raises( + exceptions.ServiceNotSupported, + match=f"Entity {unsupported_entity} does not " + "support action test_domain.test_service", + ): + await service.entity_service_call( + hass, + mock_entities, + HassJob(test_service_mock), + ServiceCall( + hass, "test_domain", "test_service", {"entity_id": unsupported_entity} + ), + entity_device_classes=entity_device_classes, + ) + assert test_service_mock.call_count == 0 + + async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: """Test invoking sync service calls.""" test_service_mock = Mock(return_value=None) From f78bb5adb678b44458066cd15eaadca02a5ca462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 30 Sep 2025 15:29:04 +0200 Subject: [PATCH 11/16] Bump hass-nabucasa from 1.1.2 to 1.2.0 (#153250) --- homeassistant/components/cloud/__init__.py | 2 -- homeassistant/components/cloud/const.py | 1 - homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_init.py | 2 -- 9 files changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2c7c6f80d49c7b..7b025501d0c78d 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -53,7 +53,6 @@ CONF_ACME_SERVER, CONF_ALEXA, CONF_ALIASES, - CONF_CLOUDHOOK_SERVER, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG, CONF_FILTER, @@ -130,7 +129,6 @@ vol.Optional(CONF_ACCOUNT_LINK_SERVER): str, vol.Optional(CONF_ACCOUNTS_SERVER): str, vol.Optional(CONF_ACME_SERVER): str, - vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 1f154832ef9d5e..23f857b9bffbf1 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -78,7 +78,6 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server" CONF_ACCOUNTS_SERVER = "accounts_server" CONF_ACME_SERVER = "acme_server" -CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 1912c20e8d8de8..134c912751238e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.1.2"], + "requirements": ["hass-nabucasa==1.2.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fccb5db82cca68..b77d9913c76e05 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.6.4 -hass-nabucasa==1.1.2 +hass-nabucasa==1.2.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250926.0 diff --git a/pyproject.toml b/pyproject.toml index 4b84c63d951b7c..4ee9024c53fc7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.1.2", + "hass-nabucasa==1.2.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 237ecebb6614dc..57a2035cdcb0b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==1.1.2 +hass-nabucasa==1.2.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fd44f2638479a3..334e3693f682ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1145,7 +1145,7 @@ habiticalib==0.4.5 habluetooth==5.6.4 # homeassistant.components.cloud -hass-nabucasa==1.1.2 +hass-nabucasa==1.2.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02d281fcf9ebf7..351d3419f341d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1006,7 +1006,7 @@ habiticalib==0.4.5 habluetooth==5.6.4 # homeassistant.components.cloud -hass-nabucasa==1.1.2 +hass-nabucasa==1.2.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 9a6d4abfc9352a..a12411b1eb24d1 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -44,7 +44,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "region": "test-region", "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", - "cloudhook_server": "test-cloudhook-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", }, @@ -60,7 +59,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: assert cl.relayer_server == "test-relayer-server" assert cl.iot.ws_server_url == "wss://test-relayer-server/websocket" assert cl.accounts_server == "test-acounts-server" - assert cl.cloudhook_server == "test-cloudhook-server" assert cl.acme_server == "test-acme-server" assert cl.remotestate_server == "test-remotestate-server" From 914990b58a3c2259c93d48149d0ee4135d2a5469 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:39:32 +0200 Subject: [PATCH 12/16] Add analytics platform to wled (#153258) --- homeassistant/components/wled/analytics.py | 11 ++++++++ tests/components/wled/test_analytics.py | 31 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 homeassistant/components/wled/analytics.py create mode 100644 tests/components/wled/test_analytics.py diff --git a/homeassistant/components/wled/analytics.py b/homeassistant/components/wled/analytics.py new file mode 100644 index 00000000000000..d801bfeb31fed7 --- /dev/null +++ b/homeassistant/components/wled/analytics.py @@ -0,0 +1,11 @@ +"""Analytics platform.""" + +from homeassistant.components.analytics import AnalyticsInput, AnalyticsModifications +from homeassistant.core import HomeAssistant + + +async def async_modify_analytics( + hass: HomeAssistant, analytics_input: AnalyticsInput +) -> AnalyticsModifications: + """Modify the analytics.""" + return AnalyticsModifications(remove=True) diff --git a/tests/components/wled/test_analytics.py b/tests/components/wled/test_analytics.py new file mode 100644 index 00000000000000..7b392c22180ae5 --- /dev/null +++ b/tests/components/wled/test_analytics.py @@ -0,0 +1,31 @@ +"""Tests for analytics platform.""" + +import pytest + +from homeassistant.components.analytics import async_devices_payload +from homeassistant.components.wled import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_analytics( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test the analytics platform.""" + await async_setup_component(hass, "analytics", {}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={(DOMAIN, "test")}, + manufacturer="Test Manufacturer", + ) + + result = await async_devices_payload(hass) + assert DOMAIN not in result["integrations"] From 93ee6322f25dcd7e1fc179c1ce32627a2b15dea4 Mon Sep 17 00:00:00 2001 From: falconindy Date: Tue, 30 Sep 2025 10:57:58 -0400 Subject: [PATCH 13/16] snoo: add button entity for calling start_snoo (#151052) Co-authored-by: Joostlek --- homeassistant/components/snoo/__init__.py | 1 + homeassistant/components/snoo/button.py | 69 +++++++++++++++++++ homeassistant/components/snoo/icons.json | 9 +++ homeassistant/components/snoo/strings.json | 8 +++ .../snoo/snapshots/test_button.ambr | 49 +++++++++++++ tests/components/snoo/test_button.py | 41 +++++++++++ 6 files changed, 177 insertions(+) create mode 100644 homeassistant/components/snoo/button.py create mode 100644 homeassistant/components/snoo/icons.json create mode 100644 tests/components/snoo/snapshots/test_button.ambr create mode 100644 tests/components/snoo/test_button.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index 20d94be7c033e0..bf4dc07f96c68d 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -19,6 +19,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.EVENT, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/snoo/button.py b/homeassistant/components/snoo/button.py new file mode 100644 index 00000000000000..c7faabb142f5a3 --- /dev/null +++ b/homeassistant/components/snoo/button.py @@ -0,0 +1,69 @@ +"""Support for Snoo Buttons.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooDevice +from python_snoo.exceptions import SnooCommandException +from python_snoo.snoo import Snoo + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(kw_only=True, frozen=True) +class SnooButtonEntityDescription(ButtonEntityDescription): + """Description for Snoo button entities.""" + + press_fn: Callable[[Snoo, SnooDevice], Awaitable[None]] + + +BUTTON_DESCRIPTIONS: list[SnooButtonEntityDescription] = [ + SnooButtonEntityDescription( + key="start_snoo", + translation_key="start_snoo", + press_fn=lambda snoo, device: snoo.start_snoo( + device, + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up buttons for Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooButton(coordinator, description) + for coordinator in coordinators.values() + for description in BUTTON_DESCRIPTIONS + ) + + +class SnooButton(SnooDescriptionEntity, ButtonEntity): + """Representation of a Snoo button.""" + + entity_description: SnooButtonEntityDescription + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_fn( + self.coordinator.snoo, + self.coordinator.device, + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=f"{self.entity_description.key}_failed", + translation_placeholders={"name": str(self.name)}, + ) from err diff --git a/homeassistant/components/snoo/icons.json b/homeassistant/components/snoo/icons.json new file mode 100644 index 00000000000000..44504a4c969a55 --- /dev/null +++ b/homeassistant/components/snoo/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "start_snoo": { + "default": "mdi:play" + } + } + } +} diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index e4a5c634a68c2a..c86e0dd890733b 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -25,6 +25,9 @@ "select_failed": { "message": "Error while updating {name} to {option}" }, + "start_snoo_failed": { + "message": "Starting {name} failed" + }, "switch_on_failed": { "message": "Turning {name} on failed" }, @@ -41,6 +44,11 @@ "name": "Right safety clip" } }, + "button": { + "start_snoo": { + "name": "Start" + } + }, "event": { "event": { "name": "Snoo event", diff --git a/tests/components/snoo/snapshots/test_button.ambr b/tests/components/snoo/snapshots/test_button.ambr new file mode 100644 index 00000000000000..05920b09105e84 --- /dev/null +++ b/tests/components/snoo/snapshots/test_button.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_entities[button.test_snoo_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_snoo_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'snoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_snoo', + 'unique_id': 'random_num_start_snoo', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button.test_snoo_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Snoo Start', + }), + 'context': , + 'entity_id': 'button.test_snoo_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/snoo/test_button.py b/tests/components/snoo/test_button.py new file mode 100644 index 00000000000000..84705f9e6fdfac --- /dev/null +++ b/tests/components/snoo/test_button.py @@ -0,0 +1,41 @@ +"""Test Snoo Buttons.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + bypass_api: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test buttons.""" + with patch("homeassistant.components.snoo.PLATFORMS", [Platform.BUTTON]): + entry = await async_init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_button_starts_snoo(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test start_snoo button works correctly.""" + await async_init_integration(hass) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_snoo_start"}, + blocking=True, + ) + + assert bypass_api.start_snoo.assert_called_once From 62a49d4244ba161c3c8519cbb2660b7fc1c8c75b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:58:41 +0200 Subject: [PATCH 14/16] Update pandas to 2.3.3 (#153251) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b77d9913c76e05..62e12a1c0f3296 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -120,7 +120,7 @@ hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env numpy==2.3.2 -pandas==2.3.0 +pandas==2.3.3 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4efbcea9ab9848..5d176adfdec060 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -145,7 +145,7 @@ # Ensure we run compatible with musllinux build env numpy==2.3.2 -pandas==2.3.0 +pandas==2.3.3 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 From 5cf7dfca8f5991e6673ce87ae6fbde88deb5f4df Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:59:03 +0100 Subject: [PATCH 15/16] Pihole better logging of update errors (#152077) --- homeassistant/components/pi_hole/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index ae51fe166c4f6a..7d8dbc50866526 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -129,10 +129,16 @@ async def async_update_data() -> None: raise ConfigEntryAuthFailed except HoleError as err: if str(err) == "Authentication failed: Invalid password": - raise ConfigEntryAuthFailed from err - raise UpdateFailed(f"Failed to communicate with API: {err}") from err + raise ConfigEntryAuthFailed( + f"Pi-hole {name} at host {host}, reported an invalid password" + ) from err + raise UpdateFailed( + f"Pi-hole {name} at host {host}, update failed with HoleError: {err}" + ) from err if not isinstance(api.data, dict): - raise ConfigEntryAuthFailed + raise ConfigEntryAuthFailed( + f"Pi-hole {name} at host {host}, returned an unexpected response: {api.data}, assuming authentication failed" + ) coordinator = DataUpdateCoordinator( hass, From 69dd5c91b7f8fef94f1b559d9dfc7af561428d51 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:05:23 +0800 Subject: [PATCH 16/16] Switchbot Cloud: Fix Roller Shade not work issue (#152528) --- homeassistant/components/switchbot_cloud/cover.py | 8 +++----- homeassistant/components/switchbot_cloud/entity.py | 2 +- tests/components/switchbot_cloud/test_cover.py | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py index 77f0b960d2555a..e5e7b745cbb5a0 100644 --- a/homeassistant/components/switchbot_cloud/cover.py +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -109,15 +109,13 @@ class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0)) + await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=0) await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.send_api_command( - RollerShadeCommands.SET_POSITION, parameters=str(100) - ) + await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=100) await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() @@ -126,7 +124,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: position: int | None = kwargs.get("position") if position is not None: await self.send_api_command( - RollerShadeCommands.SET_POSITION, parameters=str(100 - position) + RollerShadeCommands.SET_POSITION, parameters=(100 - position) ) await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index 5eb96ed3ac8af7..376ed47f79f84d 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -44,7 +44,7 @@ async def send_api_command( self, command: Commands, command_type: str = "command", - parameters: dict | str = "default", + parameters: dict | str | int = "default", ) -> None: """Send command to device.""" await self._api.send_command( diff --git a/tests/components/switchbot_cloud/test_cover.py b/tests/components/switchbot_cloud/test_cover.py index 0d0daf1bd7b190..e2efffe0bf4bc8 100644 --- a/tests/components/switchbot_cloud/test_cover.py +++ b/tests/components/switchbot_cloud/test_cover.py @@ -319,7 +319,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "0" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 0 ) await configure_integration(hass) @@ -334,7 +334,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "100" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 100 ) await configure_integration(hass) @@ -349,7 +349,7 @@ async def test_roller_shade_features( blocking=True, ) mock_send_command.assert_called_once_with( - "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "50" + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", 50 )