From fce17c8e6fea48448c66dcdafc67d0bde6540568 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 May 2026 16:07:37 +0000 Subject: [PATCH 001/153] Bump version to 2026.6.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 75db16195c71d..3b7dd647da7f0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 9f4452966ed35..d569543cdfd7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.6.0.dev0" +version = "2026.6.0b0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From fd69d384be8cf61dd04baf97d832d08a3af75b07 Mon Sep 17 00:00:00 2001 From: Nikhil Deepak Date: Thu, 28 May 2026 15:37:30 +0530 Subject: [PATCH 002/153] Reset MQTT valve opening/closing state at intermediate positions (#165176) Co-authored-by: jbouwh --- homeassistant/components/mqtt/valve.py | 10 ++++------ tests/components/mqtt/test_valve.py | 12 +++++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index e50a0f2605708..42661e4029b9c 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -271,7 +271,7 @@ def _process_position_valve_update( self._range, float(position_payload) ) except ValueError: - _LOGGER.warning( + _LOGGER.debug( "Ignoring non numeric payload '%s' received on topic '%s'", position_payload, msg.topic, @@ -279,9 +279,9 @@ def _process_position_valve_update( else: percentage_payload = min(max(percentage_payload, 0), 100) self._attr_current_valve_position = percentage_payload - # Reset closing and opening if the valve is fully opened or fully closed - if state is None and percentage_payload in (0, 100): - state = RESET_CLOSING_OPENING + # Reset opening/closing when a position update is received + # without an explicit opening/closing transitional state. + state = state or RESET_CLOSING_OPENING position_set = True if state_payload and state is None and not position_set: _LOGGER.warning( @@ -291,8 +291,6 @@ def _process_position_valve_update( state_payload, ) return - if state is None: - return self._update_state(state) @callback diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index cbd6fdc15d370..9b19f391cd9fd 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -270,7 +270,7 @@ async def test_state_via_state_topic_through_position( ) -> None: """Test the controlling state via topic through position. - Test is still possible to process a `opening` or `closing` + Test it is still possible to process a `opening` or `closing` state update. Additional we test json messages can be processed containing both position and state. Incoming rendered positions are clamped between 0..100. @@ -308,7 +308,7 @@ async def test_opening_closing_state_is_reset( ) -> None: """Test the controlling state via topic through position. - Test a `opening` or `closing` state update is reset + Test an `opening` or `closing` state update is reset correctly after sequential updates. """ await mqtt_mock_entry() @@ -320,11 +320,13 @@ async def test_opening_closing_state_is_reset( messages = [ ('{"position": 0, "state": "opening"}', ValveState.OPENING, 0), ('{"position": 50, "state": "opening"}', ValveState.OPENING, 50), - ('{"position": 60}', ValveState.OPENING, 60), + # Position-only update at intermediate position resets opening state + ('{"position": 60}', ValveState.OPEN, 60), ('{"position": 100, "state": "opening"}', ValveState.OPENING, 100), ('{"position": 100, "state": null}', ValveState.OPEN, 100), ('{"position": 90, "state": "closing"}', ValveState.CLOSING, 90), - ('{"position": 40}', ValveState.CLOSING, 40), + # Position-only update at intermediate position resets closing state + ('{"position": 40}', ValveState.OPEN, 40), ('{"position": 0}', ValveState.CLOSED, 0), ('{"position": 10}', ValveState.OPEN, 10), ('{"position": 0, "state": "opening"}', ValveState.OPENING, 0), @@ -438,7 +440,7 @@ async def test_state_via_state_trough_position_with_alt_range( asserted_state: str, valve_position: int | None, ) -> None: - """Test controlling state via position with alternative range. + """Test controlling state via position with an alternative range. Test is still possible to process a `opening` or `closing` state update. Additional we test json messages can be From 2f334d657da4a68c23200f7e0a1cc6677eff5fbd Mon Sep 17 00:00:00 2001 From: Daniel Feinberg Date: Thu, 28 May 2026 11:47:32 -0600 Subject: [PATCH 003/153] Fix apple_tv HomePod streaming failures when device is idle (#170033) Co-authored-by: Claude Sonnet 4.6 --- .../components/apple_tv/media_player.py | 53 +++-- .../components/apple_tv/strings.json | 6 + .../components/apple_tv/test_media_player.py | 203 ++++++++++++++++++ 3 files changed, 246 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 33ccb0993a54d..f9024a802e256 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -38,11 +38,13 @@ ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import AppleTvConfigEntry, AppleTVManager from .browse_media import build_app_list +from .const import DOMAIN from .entity import AppleTVEntity _LOGGER = logging.getLogger(__name__) @@ -126,7 +128,6 @@ def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None: @callback def async_device_connected(self, atv: AppleTV) -> None: """Handle when connection is made to device.""" - # NB: Do not use _is_feature_available here as it only works when playing if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates): atv.push_updater.listener = self atv.push_updater.start() @@ -352,21 +353,41 @@ async def async_play_media( media_id = async_process_play_media_url(self.hass, play_item.url) media_type = MediaType.MUSIC - if self._is_feature_available(FeatureName.StreamFile) and ( + use_stream_file = self._is_feature_available(FeatureName.StreamFile) and ( media_type == MediaType.MUSIC or await is_streamable(media_id) - ): - _LOGGER.debug("Streaming %s via RAOP", media_id) - await self.atv.stream.stream_file(media_id) - elif self._is_feature_available(FeatureName.PlayUrl) and ( - (parsed_url := URL(media_id)).is_absolute() and parsed_url.host - ): - _LOGGER.debug("Playing %s via AirPlay", media_id) - await self.atv.stream.play_url(media_id) - else: - _LOGGER.error( - "Media streaming is not possible with current configuration for %s", - media_id, - ) + ) + + try: + if use_stream_file: + _LOGGER.debug("Streaming %s via RAOP", media_id) + await self.atv.stream.stream_file(media_id) + elif self._is_feature_available(FeatureName.PlayUrl) and ( + (parsed_url := URL(media_id)).is_absolute() and parsed_url.host + ): + _LOGGER.debug("Playing %s via AirPlay", media_id) + await self.atv.stream.play_url(media_id) + else: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="streaming_not_supported", + ) + except exceptions.NotSupportedError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="streaming_not_supported", + ) from ex + except ( + exceptions.BlockedStateError, + exceptions.ConnectionLostError, + exceptions.InvalidStateError, + exceptions.OperationTimeoutError, + exceptions.PlaybackError, + exceptions.ProtocolError, + ) as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stream_failed", + ) from ex @property def media_image_hash(self) -> str | None: @@ -460,7 +481,7 @@ def shuffle(self) -> bool | None: def _is_feature_available(self, feature: FeatureName) -> bool: """Return if a feature is available.""" - if self.atv and self._playing: + if self.atv: return self.atv.features.in_state(FeatureState.Available, feature) return False diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index c8da75fb1e2d2..fad0672e1161c 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -81,6 +81,12 @@ }, "not_connected": { "message": "Apple TV is not connected" + }, + "stream_failed": { + "message": "Failed to stream media to the Apple TV" + }, + "streaming_not_supported": { + "message": "Streaming the requested media is not supported" } }, "options": { diff --git a/tests/components/apple_tv/test_media_player.py b/tests/components/apple_tv/test_media_player.py index 2b01fc1bb0903..1f72c218e391f 100644 --- a/tests/components/apple_tv/test_media_player.py +++ b/tests/components/apple_tv/test_media_player.py @@ -3,20 +3,38 @@ from pathlib import Path from unittest.mock import AsyncMock, patch +from pyatv.const import FeatureName, FeatureState +from pyatv.exceptions import ( + BlockedStateError, + ConnectionLostError, + InvalidStateError, + NotSupportedError, + OperationTimeoutError, + PlaybackError, + ProtocolError, +) import pytest +from homeassistant.components.apple_tv.const import DOMAIN from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, + BrowseMedia, + MediaClass, MediaType, ) from homeassistant.components.media_source import PlayMedia from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.typing import WebSocketGenerator ENTITY_ID = "media_player.living_room_living_room" +_MUSIC_URL = "http://example.local:8123/api/tts_proxy/abc.mp3" +_VIDEO_URL = "http://example.local:8123/video.mp4" pytestmark = pytest.mark.usefixtures("init_integration") @@ -86,3 +104,188 @@ async def test_play_media_launches_app( mock_atv.apps.launch_app.assert_awaited_once_with("com.netflix.Netflix") mock_atv.stream.stream_file.assert_not_called() + + +@pytest.mark.parametrize( + ("media_type", "media_id", "called_method", "stream_file_state"), + [ + pytest.param( + MediaType.MUSIC, + _MUSIC_URL, + "stream_file", + FeatureState.Available, + id="music_via_raop", + ), + pytest.param( + MediaType.VIDEO, + _VIDEO_URL, + "play_url", + FeatureState.Unsupported, + id="video_via_airplay", + ), + ], +) +async def test_play_media_selects_streaming_method( + hass: HomeAssistant, + mock_atv: AsyncMock, + media_type: MediaType, + media_id: str, + called_method: str, + stream_file_state: FeatureState, +) -> None: + """Streaming path is selected from device feature state, not _playing.""" + mock_atv.features.set_state(FeatureName.StreamFile, stream_file_state) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: media_id, + }, + blocking=True, + ) + + getattr(mock_atv.stream, called_method).assert_awaited_once_with(media_id) + + +async def test_play_media_falls_back_to_play_url( + hass: HomeAssistant, + mock_atv: AsyncMock, +) -> None: + """When StreamFile is unavailable, play_url is used for video.""" + mock_atv.features.set_state(FeatureName.StreamFile, FeatureState.Unsupported) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.VIDEO, + ATTR_MEDIA_CONTENT_ID: _VIDEO_URL, + }, + blocking=True, + ) + + mock_atv.stream.play_url.assert_awaited_once_with(_VIDEO_URL) + mock_atv.stream.stream_file.assert_not_called() + + +async def test_play_media_raises_when_no_streaming_method( + hass: HomeAssistant, + mock_atv: AsyncMock, +) -> None: + """Raise HomeAssistantError when no streaming method is available.""" + mock_atv.features.set_state(FeatureName.StreamFile, FeatureState.Unsupported) + mock_atv.features.set_state(FeatureName.PlayUrl, FeatureState.Unsupported) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: _MUSIC_URL, + }, + blocking=True, + ) + + assert exc_info.value.translation_key == "streaming_not_supported" + assert exc_info.value.translation_domain == DOMAIN + mock_atv.stream.stream_file.assert_not_called() + mock_atv.stream.play_url.assert_not_called() + + +@pytest.mark.parametrize( + ("stream_attr", "media_type", "media_id", "stream_file_state"), + [ + ( + "stream_file", + MediaType.MUSIC, + _MUSIC_URL, + FeatureState.Available, + ), + ( + "play_url", + MediaType.VIDEO, + _VIDEO_URL, + FeatureState.Unsupported, + ), + ], +) +@pytest.mark.parametrize( + ("exc_class", "expected_translation_key"), + [ + (BlockedStateError, "stream_failed"), + (ConnectionLostError, "stream_failed"), + (InvalidStateError, "stream_failed"), + (NotSupportedError, "streaming_not_supported"), + (OperationTimeoutError, "stream_failed"), + (PlaybackError, "stream_failed"), + (ProtocolError, "stream_failed"), + ], +) +async def test_play_media_raises_ha_error_on_pyatv_failure( + hass: HomeAssistant, + mock_atv: AsyncMock, + stream_attr: str, + media_type: MediaType, + media_id: str, + stream_file_state: FeatureState, + exc_class: type[Exception], + expected_translation_key: str, +) -> None: + """Pyatv streaming exceptions surface as a translated HomeAssistantError.""" + mock_atv.features.set_state(FeatureName.StreamFile, stream_file_state) + getattr(mock_atv.stream, stream_attr).side_effect = exc_class("error") + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: media_id, + }, + blocking=True, + ) + + assert exc_info.value.translation_key == expected_translation_key + assert exc_info.value.translation_domain == DOMAIN + + +async def test_browse_media_uses_media_source( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """async_browse_media routes to media_source when streaming is available.""" + browse_result = BrowseMedia( + title="Media", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="", + can_play=False, + can_expand=True, + children=[], + ) + + with patch( + "homeassistant.components.apple_tv.media_player.media_source.async_browse_media", + new_callable=AsyncMock, + return_value=browse_result, + ) as mock_browse: + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + } + ) + response = await client.receive_json() + + assert response["success"] + mock_browse.assert_called_once() From add75622d6b808b29da6e2be107d9f34c53d42d8 Mon Sep 17 00:00:00 2001 From: mhuiskes <92112296+mhuiskes@users.noreply.github.com> Date: Fri, 29 May 2026 11:26:27 +0200 Subject: [PATCH 004/153] Fix zeversolar coordinator to raise UpdateFailed on errors (#170507) --- .../components/zeversolar/coordinator.py | 7 +++-- tests/components/zeversolar/test_sensor.py | 29 +++++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zeversolar/coordinator.py b/homeassistant/components/zeversolar/coordinator.py index 89a1e176a59e9..66d3317a095fe 100644 --- a/homeassistant/components/zeversolar/coordinator.py +++ b/homeassistant/components/zeversolar/coordinator.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -35,4 +35,7 @@ def __init__(self, hass: HomeAssistant, entry: ZeversolarConfigEntry) -> None: async def _async_update_data(self) -> zeversolar.ZeverSolarData: """Fetch the latest data from the source.""" - return await self.hass.async_add_executor_job(self._client.get_data) + try: + return await self.hass.async_add_executor_job(self._client.get_data) + except zeversolar.ZeverSolarError as err: + raise UpdateFailed(err) from err diff --git a/tests/components/zeversolar/test_sensor.py b/tests/components/zeversolar/test_sensor.py index 83e2a619906b7..e3733208cef31 100644 --- a/tests/components/zeversolar/test_sensor.py +++ b/tests/components/zeversolar/test_sensor.py @@ -1,14 +1,17 @@ """Test the sensor classes.""" -from unittest.mock import patch +from datetime import timedelta +from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion +from zeversolar.exceptions import ZeverSolarError -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_sensors( @@ -25,3 +28,23 @@ async def test_sensors( await snapshot_platform( hass, entity_registry, snapshot, init_integration.entry_id ) + + +async def test_sensor_update_failed( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_zeversolar_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities become unavailable after a failed coordinator update.""" + assert hass.states.get("sensor.zeversolar_sensor_energy_today").state is not None + + mock_zeversolar_client.get_data.side_effect = ZeverSolarError + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.zeversolar_sensor_energy_today").state + == STATE_UNAVAILABLE + ) From 46f2ad9eb29651ddcb62cfa8ac8f729dfbc0bc91 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 1 Jun 2026 04:34:16 -0400 Subject: [PATCH 005/153] During onboarding, ensure Supervisor is up to date during hassio setup (#171129) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- homeassistant/components/hassio/__init__.py | 25 +++++++- homeassistant/components/hassio/strings.json | 3 + tests/components/hassio/test_init.py | 63 +++++++++++++++++++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 8c06abaf7ab4b..b65bea04e2a60 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -8,7 +8,7 @@ import struct from typing import Any -from aiohasupervisor import SupervisorError +from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import ( GreenOptions, HomeAssistantOptions, @@ -25,6 +25,7 @@ CONF_SERVER_PORT, CONF_SSL_CERTIFICATE, ) +from homeassistant.components.onboarding import async_is_onboarded from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, @@ -301,6 +302,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_key="supervisor_not_connected", ) from err + # During onboarding, Supervisor may be out of date. Attempt an update now + # so that core loads against an up-to-date Supervisor. A + # SupervisorBadRequestError means there is no update available, proceed + # normally. No exception means an update was triggered and we must wait for + # it to complete. Any other SupervisorError means something unexpected went + # wrong and we cannot proceed right now. + if not async_is_onboarded(hass): + try: + await supervisor_client.supervisor.update() + except SupervisorBadRequestError: + pass # No update available, proceed normally. + except SupervisorError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="supervisor_not_connected", + ) from err + else: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="supervisor_update_pending", + ) + # Get or create a refresh token for the Supervisor user user = hass.data[DATA_HASSIO_SUPERVISOR_USER] if user.refresh_tokens: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 6d20743cc5747..533bf4d0a91f0 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -55,6 +55,9 @@ }, "supervisor_not_connected": { "message": "Not connected with the supervisor / system too busy" + }, + "supervisor_update_pending": { + "message": "Supervisor was out-of-date during onboarding. Update triggered, will retry when complete" } }, "issues": { diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f32d8173ddd18..e6f2dd34a4bb4 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import ANY, AsyncMock, Mock, call, patch from uuid import uuid4 -from aiohasupervisor import SupervisorError +from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import ( AddonsStats, AddonStage, @@ -191,6 +191,67 @@ async def test_setup_api_ping_fails( assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_onboarding_supervisor_update( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test that during onboarding, supervisor.update() success triggers retry.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch("homeassistant.components.hassio.async_is_onboarded", return_value=False), + ): + result = await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + assert result + assert is_hassio(hass) + entry = hass.config_entries.async_entries("hassio")[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + supervisor_client.supervisor.update.assert_called_once() + + +async def test_setup_onboarding_supervisor_no_update( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test that during onboarding, SupervisorBadRequestError means no update needed.""" + supervisor_client.supervisor.update.side_effect = SupervisorBadRequestError + + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch("homeassistant.components.hassio.async_is_onboarded", return_value=False), + ): + result = await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + assert result + assert is_hassio(hass) + entry = hass.config_entries.async_entries("hassio")[0] + assert entry.state is ConfigEntryState.LOADED + supervisor_client.supervisor.update.assert_called_once() + + +async def test_setup_onboarding_supervisor_update_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test that during onboarding, an unknown SupervisorError causes retry.""" + supervisor_client.supervisor.update.side_effect = SupervisorError + + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch("homeassistant.components.hassio.async_is_onboarded", return_value=False), + ): + result = await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + assert result + assert is_hassio(hass) + entry = hass.config_entries.async_entries("hassio")[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + supervisor_client.supervisor.update.assert_called_once() + + async def test_setup_app_panel(hass: HomeAssistant) -> None: """Test app panel is registered.""" with patch.dict(os.environ, MOCK_ENVIRON): From d5be54fd40de7c035066cbc706fc2052cd0639c9 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 28 May 2026 14:42:25 -0400 Subject: [PATCH 006/153] Migrate analytics integration to config entry setup (#171801) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/analytics/__init__.py | 57 ++++++--- .../components/analytics/analytics.py | 16 +-- .../components/analytics/config_flow.py | 19 +++ .../components/analytics/manifest.json | 4 +- .../components/analytics/strings.json | 5 + homeassistant/generated/config_flows.py | 1 + tests/components/analytics/test_init.py | 117 +++++++++++++++++- 7 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/analytics/config_flow.py diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 34327304888f0..2cbf3497d1d98 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -5,8 +5,12 @@ import voluptuous as vol from homeassistant.components import labs, websocket_api -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.components.hassio import HassioNotReadyError +from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -49,6 +53,7 @@ ) DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN) +_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url") LABS_SNAPSHOT_FEATURE = "snapshots" @@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the analytics integration.""" analytics_config = config.get(DOMAIN, {}) + snapshots_url: str | None = None if CONF_SNAPSHOTS_URL in analytics_config: await labs.async_update_preview_feature( hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True ) snapshots_url = analytics_config[CONF_SNAPSHOTS_URL] - else: - snapshots_url = None + hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url + + discovery_flow.async_create_flow( + hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} + ) + + websocket_api.async_register_command(hass, websocket_analytics) + websocket_api.async_register_command(hass, websocket_analytics_preferences) + + hass.http.register_view(AnalyticsDevicesView) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Analytics from a config entry.""" + snapshots_url = hass.data[_DATA_SNAPSHOTS_URL] analytics = Analytics(hass, snapshots_url) - # Load stored data - await analytics.load() + try: + await analytics.load() + except HassioNotReadyError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="supervisor_not_ready", + ) from err started = False @@ -80,8 +106,8 @@ async def _async_handle_labs_update( if started: await analytics.async_schedule() - async def start_schedule(_event: Event) -> None: - """Start the send schedule after the started event.""" + async def start_schedule(hass: HomeAssistant) -> None: + """Start the send schedule once Home Assistant has started.""" nonlocal started started = True await analytics.async_schedule() @@ -89,12 +115,7 @@ async def start_schedule(_event: Event) -> None: labs.async_subscribe_preview_feature( hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) - - websocket_api.async_register_command(hass, websocket_analytics) - websocket_api.async_register_command(hass, websocket_analytics_preferences) - - hass.http.register_view(AnalyticsDevicesView) + async_at_started(hass, start_schedule) hass.data[DATA_COMPONENT] = analytics return True @@ -109,7 +130,9 @@ def websocket_analytics( msg: dict[str, Any], ) -> None: """Return analytics preferences.""" - analytics = hass.data[DATA_COMPONENT] + if (analytics := hass.data.get(DATA_COMPONENT)) is None: + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded") + return connection.send_result( msg["id"], {ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded}, @@ -130,8 +153,10 @@ async def websocket_analytics_preferences( msg: dict[str, Any], ) -> None: """Update analytics preferences.""" + if (analytics := hass.data.get(DATA_COMPONENT)) is None: + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded") + return preferences = msg[ATTR_PREFERENCES] - analytics = hass.data[DATA_COMPONENT] await analytics.save_preferences(preferences) await analytics.async_schedule() diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 752ed02de5973..920c36d97b7ba 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -299,12 +299,8 @@ async def load(self) -> None: self._data = AnalyticsData.from_dict(stored) if self.supervisor and not self.onboarded: - # This may raise HassioNotReadyError if Supervisor was unreachable - # during setup of the Supervisor integration. That will fail setup - # of this integration. However there is no better option at this time - # since we need to get the diagnostic setting from Supervisor to correctly - # setup this integration and we can't raise ConfigEntryNotReady to - # trigger a retry from async_setup. + # This may raise HassioNotReadyError if Supervisor was unreachable. + # The caller is responsible for handling this and triggering a retry. supervisor_info = hassio.get_supervisor_info(self._hass) # User have not configured analytics, get this setting from the supervisor @@ -349,10 +345,10 @@ async def send_analytics(self, _: datetime | None = None) -> None: await self._save() if self.supervisor: - # get_supervisor_info was called during setup so we can't get here - # if it raised. The others may raise HassioNotReadyError if only some - # data was successfully fetched from Supervisor - supervisor_info = hassio.get_supervisor_info(hass) + # Try to pull Supervisor information, but don't fail if some or all + # of it is unavailable due to setup failures in the hassio integration. + with contextlib.suppress(hassio.HassioNotReadyError): + supervisor_info = hassio.get_supervisor_info(hass) with contextlib.suppress(hassio.HassioNotReadyError): operating_system_info = hassio.get_os_info(hass) with contextlib.suppress(hassio.HassioNotReadyError): diff --git a/homeassistant/components/analytics/config_flow.py b/homeassistant/components/analytics/config_flow.py new file mode 100644 index 0000000000000..cf64b30c8ba34 --- /dev/null +++ b/homeassistant/components/analytics/config_flow.py @@ -0,0 +1,19 @@ +"""Config flow for Analytics integration.""" + +from typing import Any + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DOMAIN + + +class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Analytics.""" + + VERSION = 1 + + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + return self.async_create_entry(title="Analytics", data={}) diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index e7357bc4a375e..b021fcf849066 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -3,6 +3,7 @@ "name": "Analytics", "after_dependencies": ["energy", "hassio", "recorder"], "codeowners": ["@home-assistant/core"], + "config_flow": true, "dependencies": ["api", "websocket_api", "http"], "documentation": "https://www.home-assistant.io/integrations/analytics", "integration_type": "system", @@ -14,5 +15,6 @@ "report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new" } }, - "quality_scale": "internal" + "quality_scale": "internal", + "single_config_entry": true } diff --git a/homeassistant/components/analytics/strings.json b/homeassistant/components/analytics/strings.json index 00f8e0d4164ee..abff985475ffd 100644 --- a/homeassistant/components/analytics/strings.json +++ b/homeassistant/components/analytics/strings.json @@ -1,4 +1,9 @@ { + "exceptions": { + "supervisor_not_ready": { + "message": "Supervisor was not ready during setup, will retry" + } + }, "preview_features": { "snapshots": { "description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0bc899ef9877a..7653c9c77cb18 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -59,6 +59,7 @@ "amberelectric", "ambient_network", "ambient_station", + "analytics", "analytics_insights", "android_ip_webcam", "androidtv", diff --git a/tests/components/analytics/test_init.py b/tests/components/analytics/test_init.py index 2459a7320ed2e..589ae9a5b514e 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -6,15 +6,18 @@ import pytest -from homeassistant.components.analytics import LABS_SNAPSHOT_FEATURE +from homeassistant.components.analytics import CONF_SNAPSHOTS_URL, LABS_SNAPSHOT_FEATURE from homeassistant.components.analytics.const import ( BASIC_ENDPOINT_URL, + BASIC_ENDPOINT_URL_DEV, DOMAIN, SNAPSHOT_DEFAULT_URL, SNAPSHOT_URL_PATH, STORAGE_KEY, ) +from homeassistant.components.hassio import HassioNotReadyError from homeassistant.components.labs import async_update_preview_feature +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,6 +40,118 @@ async def test_setup(hass: HomeAssistant) -> None: assert DOMAIN in hass.data +async def test_setup_with_snapshots_url( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup with snapshots_url in YAML config sends snapshots to that URL.""" + custom_url = "https://custom-snapshot-endpoint.example.com" + snapshot_endpoint = custom_url + SNAPSHOT_URL_PATH + aioclient_mock.post(snapshot_endpoint, status=200, json={}) + + with patch( + "homeassistant.components.analytics.analytics._async_snapshot_payload", + return_value={"mock": {}}, + ): + assert await async_setup_component(hass, "labs", {}) + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_SNAPSHOTS_URL: custom_url}} + ) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id( + {"type": "analytics/preferences", "preferences": {"snapshots": True}} + ) + assert (await ws_client.receive_json())["success"] + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25)) + await hass.async_block_till_done() + + assert any(str(call[1]) == snapshot_endpoint for call in aioclient_mock.mock_calls) + + +async def test_setup_entry_supervisor_not_ready(hass: HomeAssistant) -> None: + """Test that HassioNotReadyError raises ConfigEntryNotReady.""" + with ( + patch( + "homeassistant.components.analytics.analytics.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.hassio.get_supervisor_info", + side_effect=HassioNotReadyError, + ), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_schedule_starts_and_sends_analytics( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that the analytics schedule fires and sends analytics after time travel.""" + aioclient_mock.post(BASIC_ENDPOINT_URL, status=200) + aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION): + await ws_client.send_json_auto_id( + {"type": "analytics/preferences", "preferences": {"base": True}} + ) + assert (await ws_client.receive_json())["success"] + + assert len(aioclient_mock.mock_calls) == 0 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901)) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("ws_type", "ws_options"), + [("analytics", {}), ("analytics/preferences", {"preferences": {"base": True}})], +) +async def test_websocket_not_loaded( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + ws_type: str, + ws_options: dict[str, Any], +) -> None: + """Test websocket returns error when analytics entry failed to load.""" + with ( + patch( + "homeassistant.components.analytics.analytics.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.hassio.get_supervisor_info", + side_effect=HassioNotReadyError, + ), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": ws_type} | ws_options) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "not_found" + + @pytest.mark.usefixtures("mock_snapshot_payload") async def test_labs_feature_toggle( hass: HomeAssistant, From a87083b6c1d37eaf7cc0b86eaf888a4b1a4a13ed Mon Sep 17 00:00:00 2001 From: tlpeter <30437052+tlpeter@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:33:33 +0200 Subject: [PATCH 007/153] Bump renault-api to 0.5.11 (#172333) Co-authored-by: Ariel Ebersberger --- .../components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- .../renault/snapshots/test_button.ambr | 100 ------------------ 3 files changed, 2 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 781e86e54fcd7..a11c1ad36d266 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.5.10"] + "requirements": ["renault-api==0.5.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84a442b8f6806..faf5a7180186e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.10 +renault-api==0.5.11 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index dc8f676ae3adb..441fb6ad16034 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -1799,106 +1799,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[zoe_50][button.reg_zoe_50_flash_lights-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.reg_zoe_50_flash_lights', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Flash lights', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Flash lights', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'flash_lights', - 'unique_id': 'vf1zoe50vin_flash_lights', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[zoe_50][button.reg_zoe_50_flash_lights-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-ZOE-50 Flash lights', - }), - 'context': , - 'entity_id': 'button.reg_zoe_50_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[zoe_50][button.reg_zoe_50_sound_horn-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.reg_zoe_50_sound_horn', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sound horn', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sound horn', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sound_horn', - 'unique_id': 'vf1zoe50vin_sound_horn', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[zoe_50][button.reg_zoe_50_sound_horn-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-ZOE-50 Sound horn', - }), - 'context': , - 'entity_id': 'button.reg_zoe_50_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[zoe_50][button.reg_zoe_50_start_air_conditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 190350aec39741621d93af9017134146162c07bd Mon Sep 17 00:00:00 2001 From: Linkplay2020 <65423368+Linkplay2020@users.noreply.github.com> Date: Thu, 28 May 2026 17:38:22 +0800 Subject: [PATCH 008/153] Bump wiim to 1.0.4 (#172334) Co-authored-by: Tao Jiang --- homeassistant/components/wiim/manifest.json | 2 +- homeassistant/components/wiim/media_player.py | 3 --- requirements_all.txt | 2 +- tests/components/wiim/__init__.py | 1 + 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index 1cd3840a33128..a93652c0e978e 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "bronze", - "requirements": ["wiim==0.1.2"], + "requirements": ["wiim==0.1.4"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index aa832c37a81ea..c4bcc9e18549a 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -349,15 +349,12 @@ def _handle_sdk_av_transport_event( sdk_status_str, ) else: - self._device.playing_status = sdk_status if sdk_status == SDKPlayingStatus.STOPPED: LOGGER.debug( "Device %s: TransportState is STOPPED." " Resetting media position and metadata", self.entity_id, ) - self._device.current_position = 0 - self._device.current_track_duration = 0 self._attr_media_position_updated_at = None self._attr_media_duration = None self._attr_media_position = None diff --git a/requirements_all.txt b/requirements_all.txt index faf5a7180186e..ecc79bfc7acae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3357,7 +3357,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wiim -wiim==0.1.2 +wiim==0.1.4 # homeassistant.components.wirelesstag wirelesstagpy==0.8.1 diff --git a/tests/components/wiim/__init__.py b/tests/components/wiim/__init__.py index 3aff9fe1b9b20..048ed16f2fb01 100644 --- a/tests/components/wiim/__init__.py +++ b/tests/components/wiim/__init__.py @@ -38,5 +38,6 @@ async def fire_transport_update( """Trigger the registered AVTransport callback on the mock device.""" assert mock_device.av_transport_event_callback is not None mock_device.event_data = {"TransportState": transport_state.value} + mock_device.playing_status = transport_state mock_device.av_transport_event_callback(MagicMock(), []) await hass.async_block_till_done() From 2c2e70a11c24cbdcbcf08cfa34d6fe3ff9a97692 Mon Sep 17 00:00:00 2001 From: torben-iometer Date: Wed, 27 May 2026 22:19:20 +0200 Subject: [PATCH 009/153] bump iometer version to 1.0.1 (#172338) --- homeassistant/components/iometer/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/iometer/fixtures/reading.json | 1 + tests/components/iometer/fixtures/status.json | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iometer/manifest.json b/homeassistant/components/iometer/manifest.json index 2d05508795f04..e9e4fdf66b143 100644 --- a/homeassistant/components/iometer/manifest.json +++ b/homeassistant/components/iometer/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["iometer==0.4.0"], + "requirements": ["iometer==1.0.1"], "zeroconf": ["_iometer._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ecc79bfc7acae..946e084eb9504 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1371,7 +1371,7 @@ insteon-frontend-home-assistant==0.6.2 intellifire4py==4.4.0 # homeassistant.components.iometer -iometer==0.4.0 +iometer==1.0.1 # homeassistant.components.iotty iottycloud==0.3.0 diff --git a/tests/components/iometer/fixtures/reading.json b/tests/components/iometer/fixtures/reading.json index 2b4462290f0ed..b02f1c70e6a7f 100644 --- a/tests/components/iometer/fixtures/reading.json +++ b/tests/components/iometer/fixtures/reading.json @@ -1,5 +1,6 @@ { "__typename": "iometer.reading.v1", + "installationId": "658c2b34-2017-45f2-a12b-731235f8bb97", "meter": { "number": "1ISK0000000000", "reading": { diff --git a/tests/components/iometer/fixtures/status.json b/tests/components/iometer/fixtures/status.json index 4d3001d8454a1..857d26c25885d 100644 --- a/tests/components/iometer/fixtures/status.json +++ b/tests/components/iometer/fixtures/status.json @@ -1,5 +1,6 @@ { "__typename": "iometer.status.v1", + "installationId": "658c2b34-2017-45f2-a12b-731235f8bb97", "meter": { "number": "1ISK0000000000" }, From 38f25c4b419c809961d4b06c298a6f4c2272060f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 27 May 2026 16:55:07 -0400 Subject: [PATCH 010/153] Bump ZHA to 1.4.0 (#172357) --- .../components/zha/alarm_control_panel.py | 10 +- homeassistant/components/zha/button.py | 2 +- homeassistant/components/zha/climate.py | 8 +- homeassistant/components/zha/const.py | 4 + homeassistant/components/zha/cover.py | 16 +- homeassistant/components/zha/device_action.py | 164 ++++++++---------- homeassistant/components/zha/entity.py | 2 +- homeassistant/components/zha/fan.py | 8 +- homeassistant/components/zha/helpers.py | 111 ++++++------ homeassistant/components/zha/light.py | 4 +- homeassistant/components/zha/lock.py | 12 +- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/number.py | 2 +- homeassistant/components/zha/select.py | 2 +- homeassistant/components/zha/siren.py | 27 ++- homeassistant/components/zha/switch.py | 4 +- homeassistant/components/zha/update.py | 2 +- homeassistant/components/zha/websocket_api.py | 114 ++++-------- requirements_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 34 +--- tests/components/zha/test_cover.py | 7 +- tests/components/zha/test_device_action.py | 49 +----- tests/components/zha/test_siren.py | 33 ++-- tests/components/zha/test_websocket_api.py | 35 ++-- 24 files changed, 255 insertions(+), 399 deletions(-) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 0e9ac369ff92e..140d0304dbd7d 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -80,31 +80,31 @@ def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self.entity_data.entity.code_arm_required - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self.entity_data.entity.async_alarm_disarm(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self.entity_data.entity.async_alarm_arm_home(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.entity_data.entity.async_alarm_arm_away(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.entity_data.entity.async_alarm_arm_night(code) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" await self.entity_data.entity.async_alarm_trigger(code) diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 2c5291a5c9101..619b2de151fb5 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -52,7 +52,7 @@ def __init__(self, entity_data: EntityData) -> None: self.entity_data.entity.info_object.device_class ) - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_press(self) -> None: """Send out a update command.""" await self.entity_data.entity.async_press() diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index ce3fc93054c5c..21be4a82e136a 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -203,25 +203,25 @@ def _handle_entity_events(self, event: Any) -> None: ) super()._handle_entity_events(event) - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" await self.entity_data.entity.async_set_fan_mode(fan_mode=fan_mode) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" await self.entity_data.entity.async_set_hvac_mode(hvac_mode=hvac_mode) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self.entity_data.entity.async_set_temperature( diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 4859fb1aa9380..f27fc8583c31d 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -75,3 +75,7 @@ ZHA_ALARM_OPTIONS = "zha_alarm_options" ZHA_OPTIONS = "zha_options" + +# Dispatcher signal carrying device reconfigure progress events (bind result, +# attribute reporting result, configure complete) to the websocket subscriber. +SIGNAL_DEVICE_RECONFIGURE_EVENT = "zha_device_reconfigure_event" diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0d9628995845a..f6e9aa60a3af6 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -122,31 +122,31 @@ def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" return self.entity_data.entity.current_cover_tilt_position - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.entity_data.entity.async_open_cover() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" await self.entity_data.entity.async_open_cover_tilt() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.entity_data.entity.async_close_cover() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" await self.entity_data.entity.async_close_cover_tilt() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.entity_data.entity.async_set_cover_position( @@ -154,7 +154,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" await self.entity_data.entity.async_set_cover_tilt_position( @@ -162,13 +162,13 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.entity_data.entity.async_stop_cover() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self.entity_data.entity.async_stop_cover_tilt() diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 3cdfed0538408..3c53271315617 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -3,36 +3,26 @@ from typing import Any import voluptuous as vol -from zha.exceptions import ZHAException -from zha.zigbee.cluster_handlers.const import ( - CLUSTER_HANDLER_IAS_WD, - CLUSTER_HANDLER_INOVELLI, -) -from zha.zigbee.cluster_handlers.manufacturerspecific import ( - AllLEDEffectType, - SingleLEDEffectType, -) +from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType +from zigpy.zcl.clusters.security import IasWd from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import DOMAIN -from .helpers import async_get_zha_device_proxy +from .helpers import async_get_zha_device_proxy, convert_zha_error_to_ha_error from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics +INOVELLI_CLUSTER_ID = 0xFC31 + ACTION_SQUAWK = "squawk" ACTION_WARN = "warn" -ATTR_DATA = "data" ATTR_IEEE = "ieee" -CONF_ZHA_ACTION_TYPE = "zha_action_type" -ZHA_ACTION_TYPE_SERVICE_CALL = "service_call" -ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND = "cluster_handler_command" INOVELLI_ALL_LED_EFFECT = "issue_all_led_effect" INOVELLI_INDIVIDUAL_LED_EFFECT = "issue_individual_led_effect" @@ -73,24 +63,18 @@ DEFAULT_ACTION_SCHEMA, ) -DEVICE_ACTIONS = { - CLUSTER_HANDLER_IAS_WD: [ +# Maps a cluster_id the device must expose to the available actions. +DEVICE_ACTIONS_BY_CLUSTER_ID: dict[int, list[dict[str, str]]] = { + IasWd.cluster_id: [ {CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN}, ], - CLUSTER_HANDLER_INOVELLI: [ + INOVELLI_CLUSTER_ID: [ {CONF_TYPE: INOVELLI_ALL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: INOVELLI_INDIVIDUAL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, ], } -DEVICE_ACTION_TYPES = { - ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL, - ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL, - INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, - INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, -} - DEVICE_ACTION_SCHEMAS = { INOVELLI_ALL_LED_EFFECT: vol.Schema( { @@ -116,11 +100,6 @@ ACTION_WARN: SERVICE_WARNING_DEVICE_WARN, } -CLUSTER_HANDLER_MAPPINGS = { - INOVELLI_ALL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, - INOVELLI_INDIVIDUAL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, -} - async def async_call_action_from_config( hass: HomeAssistant, @@ -129,9 +108,9 @@ async def async_call_action_from_config( context: Context | None, ) -> None: """Perform an action based on configuration.""" - await ZHA_ACTION_TYPES[DEVICE_ACTION_TYPES[config[CONF_TYPE]]]( - hass, config, variables, context - ) + action_type = config[CONF_TYPE] + handler = ACTION_HANDLERS[action_type] + await handler(hass, config, context) async def async_validate_action_config( @@ -150,19 +129,18 @@ async def async_get_actions( zha_device = async_get_zha_device_proxy(hass, device_id).device except KeyError, AttributeError: return [] - cluster_handlers = [ - ch.name - for endpoint in zha_device.endpoints.values() - for ch in endpoint.claimed_cluster_handlers.values() - ] - actions = [ - action - for cluster_handler, cluster_handler_actions in DEVICE_ACTIONS.items() - for action in cluster_handler_actions - if cluster_handler in cluster_handlers - ] - for action in actions: - action[CONF_DEVICE_ID] = device_id + cluster_ids = { + cluster_id + for ep_id, endpoint in zha_device.device.endpoints.items() + if ep_id != 0 + for cluster_id in endpoint.in_clusters + } + actions: list[dict[str, str]] = [] + for required_cluster_id, cluster_actions in DEVICE_ACTIONS_BY_CLUSTER_ID.items(): + if required_cluster_id in cluster_ids: + actions.extend( + {**action, CONF_DEVICE_ID: device_id} for action in cluster_actions + ) return actions @@ -175,69 +153,75 @@ async def async_get_action_capabilities( return {"extra_fields": fields} -async def _execute_service_based_action( +async def _execute_siren_service( hass: HomeAssistant, config: dict[str, Any], - variables: TemplateVarsType, context: Context | None, ) -> None: - action_type = config[CONF_TYPE] - service_name = SERVICE_NAMES[action_type] try: zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device except KeyError, AttributeError: return - - service_data = {ATTR_IEEE: str(zha_device.ieee)} - await hass.services.async_call( - DOMAIN, service_name, service_data, blocking=True, context=context + DOMAIN, + SERVICE_NAMES[config[CONF_TYPE]], + {ATTR_IEEE: str(zha_device.ieee)}, + blocking=True, + context=context, ) -async def _execute_cluster_handler_command_based_action( - hass: HomeAssistant, - config: dict[str, Any], - variables: TemplateVarsType, - context: Context | None, -) -> None: - action_type = config[CONF_TYPE] - cluster_handler_name = CLUSTER_HANDLER_MAPPINGS[action_type] +def _find_inovelli_cluster(hass: HomeAssistant, config: dict[str, Any]) -> Any: try: zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device - except KeyError, AttributeError: - return + except (KeyError, AttributeError) as err: + raise InvalidDeviceAutomationConfig( + f"ZHA device {config[CONF_DEVICE_ID]} not found" + ) from err + try: + return zha_device.device.find_cluster(cluster_id=INOVELLI_CLUSTER_ID) + except ValueError as err: + raise InvalidDeviceAutomationConfig( + f"Device does not expose Inovelli cluster 0x{INOVELLI_CLUSTER_ID:04x}" + ) from err - action_cluster_handler = None - for endpoint in zha_device.endpoints.values(): - for cluster_handler in endpoint.all_cluster_handlers.values(): - if cluster_handler.name == cluster_handler_name: - action_cluster_handler = cluster_handler - break - if action_cluster_handler is None: - raise InvalidDeviceAutomationConfig( - f"Unable to execute cluster handler action -" - f" cluster handler: {cluster_handler_name} action:" - f" {action_type}" +async def _execute_inovelli_all_led_effect( + hass: HomeAssistant, + config: dict[str, Any], + context: Context | None, +) -> None: + cluster = _find_inovelli_cluster(hass, config) + + async with convert_zha_error_to_ha_error(): + await cluster.led_effect( + led_effect=config["effect_type"], + led_color=config["color"], + led_level=config["level"], + led_duration=config["duration"], ) - if not hasattr(action_cluster_handler, action_type): - raise InvalidDeviceAutomationConfig( - f"Unable to execute cluster handler -" - f" cluster handler: {cluster_handler_name} action:" - f" {action_type}" - ) - try: - await getattr(action_cluster_handler, action_type)(**config) - except ZHAException as err: - raise HomeAssistantError(err) from err +async def _execute_inovelli_individual_led_effect( + hass: HomeAssistant, + config: dict[str, Any], + context: Context | None, +) -> None: + cluster = _find_inovelli_cluster(hass, config) + + async with convert_zha_error_to_ha_error(): + await cluster.individual_led_effect( + led_effect=config["effect_type"], + led_color=config["color"], + led_level=config["level"], + led_duration=config["duration"], + led_number=config["led_number"], + ) -ZHA_ACTION_TYPES = { - ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action, - ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: ( - _execute_cluster_handler_command_based_action - ), +ACTION_HANDLERS = { + ACTION_SQUAWK: _execute_siren_service, + ACTION_WARN: _execute_siren_service, + INOVELLI_ALL_LED_EFFECT: _execute_inovelli_all_led_effect, + INOVELLI_INDIVIDUAL_LED_EFFECT: _execute_inovelli_individual_led_effect, } diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index a133b8f42a718..a885320a33561 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -206,7 +206,7 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() self.remove_future.set_result(True) - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_update(self) -> None: """Update the entity.""" await self.entity_data.entity.async_update() diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 61315f91a53e9..c37b2d063359e 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -92,7 +92,7 @@ def speed_count(self) -> int: """Return the number of speeds the fan supports.""" return self.entity_data.entity.speed_count - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on( self, percentage: int | None = None, @@ -105,19 +105,19 @@ async def async_turn_on( ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_data.entity.async_turn_off() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" await self.entity_data.entity.async_set_percentage(percentage=percentage) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the fan.""" await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index ec7ef8e1fde76..3d4d7f2deecc1 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -2,23 +2,23 @@ import asyncio import collections -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping +from contextlib import asynccontextmanager import copy import dataclasses import enum -import functools import itertools import logging import queue import re import time from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast +from typing import TYPE_CHECKING, Any, NamedTuple, cast from zoneinfo import ZoneInfo import voluptuous as vol +from zha.application import Platform as ZhaPlatform from zha.application.const import ( - ATTR_CLUSTER_ID, ATTR_DEVICE_IEEE, ATTR_TYPE, ATTR_UNIQUE_ID, @@ -28,11 +28,6 @@ CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, - ZHA_CLUSTER_HANDLER_CFG_DONE, - ZHA_CLUSTER_HANDLER_MSG, - ZHA_CLUSTER_HANDLER_MSG_BIND, - ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, - ZHA_CLUSTER_HANDLER_MSG_DATA, ZHA_EVENT, ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -71,10 +66,11 @@ from zha.event import EventBase from zha.exceptions import ZHAException from zha.mixins import LogMixin -from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent from zha.zigbee.device import ( - ClusterHandlerConfigurationComplete, + ClusterBindEvent, + ClusterConfigureReportingEvent, Device, + DeviceConfiguredEvent, DeviceEntityAddedEvent, DeviceEntityRemovedEvent, DeviceFirmwareInfoUpdatedEvent, @@ -126,9 +122,7 @@ from .const import ( ATTR_ACTIVE_COORDINATOR, - ATTR_ATTRIBUTES, ATTR_AVAILABLE, - ATTR_CLUSTER_NAME, ATTR_DEVICE_TYPE, ATTR_ENDPOINT_NAMES, ATTR_EXPOSES_FEATURES, @@ -144,7 +138,6 @@ ATTR_ROUTES, ATTR_RSSI, ATTR_SIGNATURE, - ATTR_SUCCESS, CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, @@ -168,6 +161,7 @@ DEFAULT_DATABASE_NAME, DEVICE_PAIRING_STATUS, DOMAIN, + SIGNAL_DEVICE_RECONFIGURE_EVENT, ZHA_ALARM_OPTIONS, ZHA_OPTIONS, ) @@ -450,50 +444,46 @@ def handle_zha_event(self, zha_event: ZHAEvent) -> None: ) @callback - def handle_zha_channel_configure_reporting( - self, event: ClusterConfigureReportingEvent - ) -> None: - """Handle a ZHA cluster configure reporting event.""" + def handle_zha_cluster_bind(self, event: ClusterBindEvent) -> None: + """Forward a cluster bind result to the reconfigure websocket.""" async_dispatcher_send( self.gateway_proxy.hass, - ZHA_CLUSTER_HANDLER_MSG, + SIGNAL_DEVICE_RECONFIGURE_EVENT, { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, - ZHA_CLUSTER_HANDLER_MSG_DATA: { - ATTR_CLUSTER_NAME: event.cluster_name, - ATTR_CLUSTER_ID: event.cluster_id, - ATTR_ATTRIBUTES: event.attributes, + "type": "zha_channel_bind", + "zha_channel_msg_data": { + "cluster_name": event.cluster_name, + "cluster_id": event.cluster_id, + "success": event.success, }, }, ) @callback - def handle_zha_channel_cfg_done( - self, event: ClusterHandlerConfigurationComplete + def handle_zha_cluster_configure_reporting( + self, event: ClusterConfigureReportingEvent ) -> None: - """Handle a ZHA cluster configure reporting event.""" + """Forward a cluster reporting-configured result to the reconfigure websocket.""" async_dispatcher_send( self.gateway_proxy.hass, - ZHA_CLUSTER_HANDLER_MSG, + SIGNAL_DEVICE_RECONFIGURE_EVENT, { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_CFG_DONE, + "type": "zha_channel_configure_reporting", + "zha_channel_msg_data": { + "cluster_name": event.cluster_name, + "cluster_id": event.cluster_id, + "attributes": event.attributes, + }, }, ) @callback - def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None: - """Handle a ZHA cluster bind event.""" + def handle_zha_device_configured(self, event: DeviceConfiguredEvent) -> None: + """Forward the device configuration-complete signal to the reconfigure websocket.""" async_dispatcher_send( self.gateway_proxy.hass, - ZHA_CLUSTER_HANDLER_MSG, - { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, - ZHA_CLUSTER_HANDLER_MSG_DATA: { - ATTR_CLUSTER_NAME: event.cluster_name, - ATTR_CLUSTER_ID: event.cluster_id, - ATTR_SUCCESS: event.success, - }, - }, + SIGNAL_DEVICE_RECONFIGURE_EVENT, + {"type": "zha_channel_cfg_done"}, ) @callback @@ -501,6 +491,9 @@ def handle_zha_device_entity_added_event( self, event: DeviceEntityAddedEvent ) -> None: """Handle a new entity being added to a device at runtime.""" + if event.platform is ZhaPlatform.VIRTUAL: + return + key = (event.platform, event.unique_id) if (entity := self.device.platform_entities.get(key)) is None: return @@ -515,6 +508,9 @@ def handle_zha_device_entity_removed_event( self, event: DeviceEntityRemovedEvent ) -> None: """Handle an entity being removed from a device at runtime.""" + if event.platform is ZhaPlatform.VIRTUAL: + return + if not event.remove: # Soft remove: signal the entity to unload; registry entry stays async_dispatcher_send( @@ -911,6 +907,9 @@ def _create_entity_metadata( if isinstance(proxy_object, ZHADeviceProxy): for entity in proxy_object.device.platform_entities.values(): + if entity.PLATFORM is ZhaPlatform.VIRTUAL: + continue + ha_zha_data.platforms[Platform(entity.PLATFORM)].append( EntityData( entity=entity, device_proxy=proxy_object, group_proxy=None @@ -918,6 +917,9 @@ def _create_entity_metadata( ) else: for entity in proxy_object.group.group_entities.values(): + if entity.PLATFORM is ZhaPlatform.VIRTUAL: + continue + ha_zha_data.platforms[Platform(entity.PLATFORM)].append( EntityData( entity=entity, @@ -1386,19 +1388,24 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData: ) -def convert_zha_error_to_ha_error[**_P, _EntityT: ZHAEntity]( - func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], -) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: +@asynccontextmanager +async def convert_zha_error_to_ha_error() -> AsyncGenerator[None]: """Decorate ZHA commands and re-raises ZHAException as HomeAssistantError.""" - - @functools.wraps(func) - async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: - try: - return await func(self, *args, **kwargs) - except ZHAException as err: - raise HomeAssistantError(err) from err - - return handler + try: + yield + except TimeoutError as exc: + raise HomeAssistantError( + "Failed to send request: device did not respond" + ) from exc + except zigpy.exceptions.ZigbeeException as exc: + message = "Failed to send request" + + if str(exc): + message = f"{message}: {exc}" + + raise HomeAssistantError(message) from exc + except ZHAException as err: + raise HomeAssistantError(err) from err def exclude_none_values(obj: Mapping[str, Any]) -> dict[str, Any]: diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 4d1c0cd9c71c9..9c9c1aea6d0e3 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -171,7 +171,7 @@ def effect(self) -> str | None: """Return the current effect.""" return self.entity_data.entity.effect - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" color_temp = ( @@ -189,7 +189,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_data.entity.async_turn_off( diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index dc27ec7a6fa79..53c959a78514e 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -94,19 +94,19 @@ def is_locked(self) -> bool: """Return true if entity is locked.""" return self.entity_data.entity.is_locked - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self.entity_data.entity.async_lock() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self.entity_data.entity.async_unlock() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None: """Set the user_code to index X on the lock.""" await self.entity_data.entity.async_set_lock_user_code( @@ -114,19 +114,19 @@ async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_enable_lock_user_code(self, code_slot: int) -> None: """Enable user_code at index X on the lock.""" await self.entity_data.entity.async_enable_lock_user_code(code_slot=code_slot) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_disable_lock_user_code(self, code_slot: int) -> None: """Disable user_code at index X on the lock.""" await self.entity_data.entity.async_disable_lock_user_code(code_slot=code_slot) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_clear_lock_user_code(self, code_slot: int) -> None: """Clear the user_code at index X on the lock.""" await self.entity_data.entity.async_clear_lock_user_code(code_slot=code_slot) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 83282af03028b..bba334981a1d9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.3.1"], + "requirements": ["zha==1.4.0"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index eba532010d5b8..b6bca9a3ccc92 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -78,7 +78,7 @@ def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self.entity_data.entity.native_unit_of_measurement - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" await self.entity_data.entity.async_set_native_value(value=value) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 72dbad2013727..d0e97155b10b6 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -58,7 +58,7 @@ def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self.entity_data.entity.current_option - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_data.entity.async_select_option(option=option) diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index aa083b5e227f5..e88a0053a9aa9 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -3,15 +3,10 @@ import functools from typing import Any -from zha.application.const import ( - WARNING_DEVICE_MODE_BURGLAR, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_MODE_EMERGENCY_PANIC, - WARNING_DEVICE_MODE_FIRE, - WARNING_DEVICE_MODE_FIRE_PANIC, - WARNING_DEVICE_MODE_POLICE_PANIC, +from zha.application.platforms.siren import ( + SirenEntityFeature as ZHASirenEntityFeature, + WarningMode, ) -from zha.application.platforms.siren import SirenEntityFeature as ZHASirenEntityFeature from homeassistant.components.siren import ( ATTR_DURATION, @@ -59,12 +54,12 @@ class ZHASiren(ZHAEntity, SirenEntity): """Representation of a ZHA siren.""" _attr_available_tones: list[int | str] | dict[int, str] | None = { - WARNING_DEVICE_MODE_BURGLAR: "Burglar", - WARNING_DEVICE_MODE_FIRE: "Fire", - WARNING_DEVICE_MODE_EMERGENCY: "Emergency", - WARNING_DEVICE_MODE_POLICE_PANIC: "Police Panic", - WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", - WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", + WarningMode.Burglar: "Burglar", + WarningMode.Fire: "Fire", + WarningMode.Emergency: "Emergency", + WarningMode.Police_Panic: "Police Panic", + WarningMode.Fire_Panic: "Fire Panic", + WarningMode.Emergency_Panic: "Emergency Panic", } def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: @@ -92,7 +87,7 @@ def is_on(self) -> bool: """Return True if entity is on.""" return self.entity_data.entity.is_on - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" await self.entity_data.entity.async_turn_on( @@ -102,7 +97,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off siren.""" await self.entity_data.entity.async_turn_off() diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 46f4bb3099805..c86f82bbdcf18 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -49,13 +49,13 @@ def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" return self.entity_data.entity.is_on - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self.entity_data.entity.async_turn_on() self.async_write_ha_state() - @convert_zha_error_to_ha_error + @convert_zha_error_to_ha_error() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_data.entity.async_turn_off() diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 0b63aab62a8e8..d5364fccb50f5 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -181,7 +181,7 @@ def release_url(self) -> str | None: return self.entity_data.entity.release_url # We explicitly convert ZHA exceptions to HA exceptions here so there is no need to - # use the `@convert_zha_error_to_ha_error` decorator. + # use the `@convert_zha_error_to_ha_error()` decorator. async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 985e4bf01ab9d..bbca47f09ded8 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -29,12 +29,6 @@ CLUSTER_COMMANDS_SERVER, CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, - ZHA_CLUSTER_HANDLER_MSG, ZHA_GW_MSG, ) from zha.application.gateway import Gateway @@ -44,7 +38,14 @@ get_matched_clusters, qr_to_install_code, ) -from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD +from zha.application.platforms.siren import ( + BaseSiren, + SirenLevel, + SquawkMode, + Strobe, + StrobeLevel, + WarningMode, +) from zha.zigbee.group import GroupMemberReference import zigpy.backups from zigpy.config import CONF_DEVICE @@ -59,7 +60,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -79,6 +80,7 @@ GROUP_IDS, GROUP_NAME, MFG_CLUSTER_ID_START, + SIGNAL_DEVICE_RECONFIGURE_EVENT, ZHA_ALARM_OPTIONS, ZHA_OPTIONS, ) @@ -180,13 +182,13 @@ def _ensure_list_if_present[_T](value: _T | None) -> list[_T] | list[Any] | None { vol.Required(ATTR_IEEE): IEEE_SCHEMA, vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ATTR_WARNING_DEVICE_MODE, default=SquawkMode.Armed ): cv.positive_int, vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe ): cv.positive_int, vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ATTR_LEVEL, default=SirenLevel.High_level_sound ): cv.positive_int, } ), @@ -194,20 +196,21 @@ def _ensure_list_if_present[_T](value: _T | None) -> list[_T] | list[Any] | None { vol.Required(ATTR_IEEE): IEEE_SCHEMA, vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ATTR_WARNING_DEVICE_MODE, default=WarningMode.Emergency ): cv.positive_int, vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe ): cv.positive_int, vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ATTR_LEVEL, default=SirenLevel.High_level_sound ): cv.positive_int, vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, vol.Optional( ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 ): cv.positive_int, vol.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + default=StrobeLevel.High_level_strobe, ): cv.positive_int, } ), @@ -424,10 +427,7 @@ async def websocket_get_groupable_devices( ), } for entity_ref in entity_refs - if list(entity_ref.entity_data.entity.cluster_handlers.values())[ - 0 - ].cluster.endpoint.endpoint_id - == ep_id + if entity_ref.entity_data.entity.endpoint.id == ep_id ], "device": device.zha_device_info, } @@ -649,7 +649,7 @@ async def forward_messages(data): connection.send_message(websocket_api.event_message(msg["id"], data)) remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CLUSTER_HANDLER_MSG, forward_messages + hass, SIGNAL_DEVICE_RECONFIGURE_EVENT, forward_messages ) @callback @@ -1480,15 +1480,6 @@ async def issue_zigbee_group_command(service: ServiceCall) -> None: schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], ) - def _get_ias_wd_cluster_handler(zha_device): - """Get the IASWD cluster handler for a device.""" - cluster_handlers = { - ch.name: ch - for endpoint in zha_device.endpoints.values() - for ch in endpoint.claimed_cluster_handlers.values() - } - return cluster_handlers.get(CLUSTER_HANDLER_IAS_WD) - async def warning_device_squawk(service: ServiceCall) -> None: """Issue the squawk command for an IAS warning device.""" ieee: EUI64 = service.data[ATTR_IEEE] @@ -1496,31 +1487,10 @@ async def warning_device_squawk(service: ServiceCall) -> None: strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] level: int = service.data[ATTR_LEVEL] - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if cluster_handler := _get_ias_wd_cluster_handler(zha_device): - await cluster_handler.issue_squawk(mode, strobe, level) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing" - " the required IASWD cluster handler!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) + device = zha_gateway.get_device(ieee) + siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) + + await siren.async_squawk(mode=mode, strobe=strobe, squawk_level=level) async_register_admin_service( hass, @@ -1540,32 +1510,16 @@ async def warning_device_warn(service: ServiceCall) -> None: duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if cluster_handler := _get_ias_wd_cluster_handler(zha_device): - await cluster_handler.issue_start_warning( - mode, strobe, level, duration, duty_mode, intensity - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] is missing" - " the required IASWD cluster handler!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, + device = zha_gateway.get_device(ieee) + siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) + + await siren.async_turn_on( + tone=mode, + volume_level=level, + duration=duration, + strobe=strobe, + strobe_duty_cycle=duty_mode, + strobe_intensity=intensity, ) async_register_admin_service( diff --git a/requirements_all.txt b/requirements_all.txt index 946e084eb9504..0f4d746559a96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3442,7 +3442,7 @@ zeroconf==0.149.16 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.3.1 +zha==1.4.0 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 6ebee4ee365ae..2f8c8550d87b0 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -246,29 +246,13 @@ 'routes': list([ ]), 'rssi': None, - 'version': 1, + 'version': 2, 'zha_lib_entities': dict({ 'alarm_control_panel': list([ dict({ 'info_object': dict({ 'available': True, 'class_name': 'AlarmControlPanel', - 'cluster_handlers': list([ - dict({ - 'class_name': 'IasAceClientClusterHandler', - 'cluster': dict({ - 'id': 1281, - 'name': 'IAS Ancillary Control Equipment', - 'type': 'client', - }), - 'endpoint_id': 1, - 'generic_id': 'cluster_handler_0x0501_client', - 'id': '1:0x0501_client', - 'status': 'INITIALIZED', - 'unique_id': '**REDACTED**', - 'value_attribute': None, - }), - ]), 'code_arm_required': False, 'code_format': 'number', 'device_class': None, @@ -302,22 +286,6 @@ 'attribute_name': 'zone_status', 'available': True, 'class_name': 'IASZone', - 'cluster_handlers': list([ - dict({ - 'class_name': 'IASZoneClusterHandler', - 'cluster': dict({ - 'id': 1280, - 'name': 'IAS Zone', - 'type': 'server', - }), - 'endpoint_id': 1, - 'generic_id': 'cluster_handler_0x0500', - 'id': '1:0x0500', - 'status': 'INITIALIZED', - 'unique_id': '**REDACTED**', - 'value_attribute': None, - }), - ]), 'device_class': None, 'device_ieee': '**REDACTED**', 'enabled': True, diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 6d7739243b4b7..eb61627813338 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -102,12 +102,7 @@ async def test_cover( entity_id = find_entity_id(Platform.COVER, zha_device_proxy, hass) assert entity_id is not None - assert ( - not zha_device_proxy.device.endpoints[1] - .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] - .inverted - ) - assert cluster.read_attributes.call_count == 3 + assert cluster.read_attributes.call_count == 2 assert ( WCAttrs.current_position_lift_percentage.name in cluster.read_attributes.call_args[0][0] diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index d1bb4406ad018..05211ce4ceee7 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -202,12 +202,9 @@ async def test_action( await hass.async_block_till_done() calls = async_mock_service(hass, DOMAIN, "warning_device_warn") - cluster_handler = ( - gateway.get_device(zigpy_device.ieee) - .endpoints[1] - .client_cluster_handlers["1:0x0006_client"] + zigpy_device.endpoints[1].out_clusters[general.OnOff.cluster_id].listener_event( + "zha_send_event", COMMAND_SINGLE, [] ) - cluster_handler.zha_send_event(COMMAND_SINGLE, []) await hass.async_block_till_done() assert len(calls) == 1 @@ -216,48 +213,6 @@ async def test_action( assert calls[0].data["ieee"] == ieee_address -async def test_invalid_zha_event_type( - hass: HomeAssistant, - setup_zha: Callable[..., Coroutine[None]], - zigpy_device_mock: Callable[..., Device], -) -> None: - """Test that unexpected types are not passed to `zha_send_event`.""" - await setup_zha() - gateway = get_zha_gateway(hass) - - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [ - general.Basic.cluster_id, - security.IasZone.cluster_id, - security.IasWd.cluster_id, - ], - SIG_EP_OUTPUT: [general.OnOff.cluster_id], - SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zha.PROFILE_ID, - } - } - ) - zigpy_device.device_automation_triggers = { - (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE} - } - - gateway.get_or_create_device(zigpy_device) - await gateway.async_device_initialized(zigpy_device) - await hass.async_block_till_done(wait_background_tasks=True) - - cluster_handler = ( - gateway.get_device(zigpy_device.ieee) - .endpoints[1] - .client_cluster_handlers["1:0x0006_client"] - ) - - # `zha_send_event` accepts only zigpy responses, lists, and dicts - with pytest.raises(TypeError): - cluster_handler.zha_send_event(COMMAND_SINGLE, 123) - - async def test_client_unique_id_suffix_stripped( hass: HomeAssistant, setup_zha: Callable[..., Coroutine[None]], diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 75380d5166897..8fd03d73c2096 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -5,10 +5,7 @@ from unittest.mock import ANY, call, patch import pytest -from zha.application.const import ( - WARNING_DEVICE_MODE_EMERGENCY_PANIC, - WARNING_DEVICE_SOUND_MEDIUM, -) +from zha.application.platforms.siren import SirenLevel, WarningMode from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from zigpy.device import Device from zigpy.profiles import zha @@ -108,12 +105,12 @@ async def test_siren( False, 0, ANY, - 50, # bitmask for default args - 5, # duration in seconds - 0, - 2, manufacturer=None, expect_reply=True, + warning=50, # bitmask for default args + warning_duration=5, + strobe_duty_cycle=0, + stobe_level=2, ) ] @@ -142,12 +139,12 @@ async def test_siren( False, 0, ANY, - 2, # bitmask for default args - 5, # duration in seconds - 0, - 2, manufacturer=None, expect_reply=True, + warning=2, # bitmask for default args + warning_duration=5, + strobe_duty_cycle=0, + stobe_level=2, ) ] @@ -173,8 +170,8 @@ async def test_siren( { "entity_id": entity_id, ATTR_DURATION: 10, - ATTR_TONE: WARNING_DEVICE_MODE_EMERGENCY_PANIC, - ATTR_VOLUME_LEVEL: WARNING_DEVICE_SOUND_MEDIUM, + ATTR_TONE: WarningMode.Emergency_Panic, + ATTR_VOLUME_LEVEL: SirenLevel.Medium_level_sound, }, blocking=True, ) @@ -184,12 +181,12 @@ async def test_siren( False, 0, ANY, - 97, # bitmask for passed args - 10, # duration in seconds - 0, - 2, manufacturer=None, expect_reply=True, + warning=97, # bitmask for passed args + warning_duration=10, + strobe_duty_cycle=0, + stobe_level=2, ) ] # test that the state has changed to on diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index ba9ca95dea6dc..fb59ac08deb43 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -20,8 +20,12 @@ ATTR_TYPE, CLUSTER_TYPE_IN, ) -from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent -from zha.zigbee.device import ClusterHandlerConfigurationComplete, Device +from zha.zigbee.device import ( + ClusterBindEvent, + ClusterConfigureReportingEvent, + Device, + DeviceConfiguredEvent, +) import zigpy.backups from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE import zigpy.profiles.zha @@ -1179,10 +1183,12 @@ async def test_websocket_reconfigure( zha_device_proxy = get_zha_gateway_proxy(hass).get_device_proxy(zha_device.ieee) async def mock_reinterview(ieee: EUI64) -> None: - zha_device_proxy.handle_zha_channel_configure_reporting( + zha_device_proxy.handle_zha_cluster_configure_reporting( ClusterConfigureReportingEvent( - cluster_name="Window Covering", + device_ieee=zha_device_proxy.device.ieee, + endpoint_id=1, cluster_id=258, + cluster_name="Window Covering", attributes={ "current_position_lift_percentage": { "min": 0, @@ -1201,30 +1207,21 @@ async def mock_reinterview(ieee: EUI64) -> None: "status": "SUCCESS", }, }, - cluster_handler_unique_id="28:2c:02:bf:ff:ea:05:68:1:0x0102", - event_type="zha_channel_message", - event="zha_channel_configure_reporting", ) ) - zha_device_proxy.handle_zha_channel_bind( + zha_device_proxy.handle_zha_cluster_bind( ClusterBindEvent( - cluster_name="Window Covering", + device_ieee=zha_device_proxy.device.ieee, + endpoint_id=1, cluster_id=1, + cluster_name="Window Covering", success=True, - cluster_handler_unique_id="28:2c:02:bf:ff:ea:05:68:1:0x0012", - event_type="zha_channel_message", - event="zha_channel_bind", ) ) - zha_device_proxy.handle_zha_channel_cfg_done( - ClusterHandlerConfigurationComplete( - device_ieee="28:2c:02:bf:ff:ea:05:68", - unique_id="28:2c:02:bf:ff:ea:05:68", - event_type="zha_channel_message", - event="zha_channel_cfg_done", - ) + zha_device_proxy.handle_zha_device_configured( + DeviceConfiguredEvent(device_ieee=zha_device_proxy.device.ieee) ) with patch.object( From 7e178efe637d99e9c62254427df2cbde3f497c03 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 28 May 2026 10:19:06 +0200 Subject: [PATCH 011/153] Reject backup uploads with unsafe inner name (#172368) Co-authored-by: Claude Opus 4.7 (1M context) --- homeassistant/components/backup/backup.py | 19 +++++++++++-- homeassistant/components/backup/manager.py | 8 +++++- homeassistant/components/backup/util.py | 13 +++++++-- tests/components/backup/test_manager.py | 30 ++++++++++++++++++++ tests/components/backup/test_util.py | 32 ++++++++++++++++++++++ 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index 4573fe0fefd86..e52db0bdded6c 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback from .const import DOMAIN, LOGGER -from .models import AgentBackup, BackupNotFound +from .models import AgentBackup, BackupNotFound, InvalidBackupFilename from .util import read_backup, suggested_filename @@ -54,7 +54,13 @@ def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]: try: backup = read_backup(backup_path) backups[backup.backup_id] = (backup, backup_path) - except (OSError, TarError, json.JSONDecodeError, KeyError) as err: + except ( + OSError, + TarError, + json.JSONDecodeError, + KeyError, + InvalidBackupFilename, + ) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups @@ -122,7 +128,14 @@ def get_backup_path(self, backup_id: str) -> Path: def get_new_backup_path(self, backup: AgentBackup) -> Path: """Return the local path to a new backup.""" - return self._backup_dir / suggested_filename(backup) + candidate = self._backup_dir / suggested_filename(backup) + # suggested_filename does not strip separators; refuse paths that would + # land outside the backup directory. + if candidate.parent != self._backup_dir: + raise InvalidBackupFilename( + f"Refusing to write outside {self._backup_dir}: {candidate}" + ) + return candidate async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index ea5b7be8f0e42..88ec3d44cf84a 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1978,7 +1978,13 @@ async def async_receive_backup( try: backup = await async_add_executor_job(read_backup, temp_file) - except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err: + except ( + OSError, + tarfile.TarError, + json.JSONDecodeError, + KeyError, + InvalidBackupFilename, + ) as err: LOGGER.warning("Unable to parse backup %s: %s", temp_file, err) raise diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 197a12336099f..953b4ab4c102e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, replace from io import BytesIO import json -from pathlib import Path, PurePath +from pathlib import Path, PurePath, PureWindowsPath from queue import SimpleQueue import tarfile import threading @@ -34,7 +34,7 @@ from homeassistant.util.json import JsonObjectType, json_loads_object from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION -from .models import AddonInfo, AgentBackup, Folder +from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename class DecryptError(HomeAssistantError): @@ -109,6 +109,13 @@ def read_backup(backup_path: Path) -> AgentBackup: extra_metadata = cast(dict[str, bool | str], data.get("extra", {})) date = extra_metadata.get("supervisor.backup_request_date", data["date"]) + name = cast(str, data["name"]) + # The name is used to derive the on-disk filename via suggested_filename; + # reject anything that could escape the backup directory. + safe_name = PureWindowsPath(name).name + if safe_name != name or name in ("", ".", ".."): + raise InvalidBackupFilename(f"Invalid backup name: {name!r}") + return AgentBackup( addons=addons, backup_id=cast(str, data["slug"]), @@ -118,7 +125,7 @@ def read_backup(backup_path: Path) -> AgentBackup: folders=folders, homeassistant_included=homeassistant_included, homeassistant_version=homeassistant_version, - name=cast(str, data["name"]), + name=name, protected=cast(bool, data.get("protected", False)), size=backup_path.stat().st_size, ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index bdb42910d7587..ffd059b50b822 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2088,6 +2088,36 @@ async def test_receive_backup_path_traversal( assert resp.status == 400 +@pytest.mark.parametrize( + "name", + [ + "/absolute/path", + "../parent", + "with/slash", + ], +) +async def test_receive_backup_rejects_unsafe_inner_name( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + name: str, +) -> None: + """Test receive backup rejects an inner name that would escape the backup dir.""" + await setup_backup_integration(hass) + client = await hass_client() + + backup = replace(TEST_BACKUP_ABC123, name=name) + with patch( + "homeassistant.components.backup.manager.read_backup", + return_value=backup, + ): + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + + assert resp.status == 400 + + async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index fcb4a8533afd8..b2be5c257997e 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -14,6 +14,7 @@ import securetar from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.models import InvalidBackupFilename from homeassistant.components.backup.util import ( DecryptedBackupStreamer, EncryptedBackupStreamer, @@ -158,6 +159,37 @@ def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) - assert backup == expected_backup +@pytest.mark.parametrize( + "name", + [ + "/absolute/path", + "../parent", + "with/slash", + "with\\backslash", + "C:\\drive\\path", + "", + ".", + "..", + ], +) +def test_read_backup_rejects_unsafe_name(name: str) -> None: + """Test that read_backup rejects names that could escape the backup directory.""" + backup_json_content = ( + b'{"compressed":true,"date":"2024-12-02T07:23:58.261875-05:00","homeassistant":' + b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"' + + name.encode().replace(b"\\", b"\\\\") + + b'","protected":true,"slug":"455645fe","type":"partial","version":2}' + ) + mock_path = Mock() + mock_path.stat.return_value.st_size = 1234 + + with patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar: + tar_ctx = mock_open_tar.return_value.__enter__.return_value + tar_ctx.extractfile.return_value.read.return_value = backup_json_content + with pytest.raises(InvalidBackupFilename): + read_backup(mock_path) + + @pytest.mark.parametrize( ("backup", "password", "validation_result", "expected_messages"), [ From b7e36e297beb7e6fa327abf141bd8031f98afdc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 17:16:36 -0500 Subject: [PATCH 012/153] Bump dbus-fast to 5.0.16 (#172378) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 39140394cbcbc..50455d05682e8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==2.3.0", "bluetooth-auto-recovery==1.6.4", "bluetooth-data-tools==1.29.18", - "dbus-fast==5.0.15", + "dbus-fast==5.0.16", "habluetooth==6.7.9" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6450c3a3cec59..2e6b23c7c0fcb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 cryptography==48.0.0 -dbus-fast==5.0.15 +dbus-fast==5.0.16 file-read-backwards==2.0.0 fnv-hash-fast==2.0.3 go2rtc-client==0.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0f4d746559a96..f3ec8f22164a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -797,7 +797,7 @@ datadog==0.52.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==5.0.15 +dbus-fast==5.0.16 # homeassistant.components.debugpy debugpy==1.8.17 From 2e4c6c4370d9c302b584a912cee333d50ae38da3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 May 2026 23:16:23 +0200 Subject: [PATCH 013/153] Bump aioamazondevices to 13.8.1 (#172382) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index ab150473deaf0..1c7842f6cac5c 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.8.0"] + "requirements": ["aioamazondevices==13.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f3ec8f22164a4..1ca128e78885f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.8.0 +aioamazondevices==13.8.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 9d60fce72e4e6be1e1eab2fce29634a789425928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 28 May 2026 07:29:53 +0100 Subject: [PATCH 014/153] Fix OMIE sensors not updating on setup (#172383) --- homeassistant/components/omie/sensor.py | 5 +++++ tests/components/omie/test_sensor.py | 24 ++++++++++++------------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/omie/sensor.py b/homeassistant/components/omie/sensor.py index e1c69df7d4aa3..3e7d591d58d2c 100644 --- a/homeassistant/components/omie/sensor.py +++ b/homeassistant/components/omie/sensor.py @@ -52,6 +52,11 @@ def __init__( self._attr_unique_id = pyomie_series_name self._pyomie_series_name = pyomie_series_name + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + @callback def _handle_coordinator_update(self) -> None: """Update this sensor's state from the coordinator results.""" diff --git a/tests/components/omie/test_sensor.py b/tests/components/omie/test_sensor.py index 28a39f6c2d7e5..20330d46bfa47 100644 --- a/tests/components/omie/test_sensor.py +++ b/tests/components/omie/test_sensor.py @@ -10,35 +10,35 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from . import spot_price_fetcher from tests.common import MockConfigEntry, async_fire_time_changed +@freeze_time("2024-01-15T14:01:00Z") async def test_sensor_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyomie: MagicMock, - entity_registry: er.EntityRegistry, + mock_omie_results_jan15: OMIEResults, ) -> None: """Test sensor platform setup.""" + mock_pyomie.spot_price.side_effect = spot_price_fetcher( + { + "2024-01-15": mock_omie_results_jan15, + } + ) + mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - entities = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - # Should have 2 sensors (PT and ES) - assert len(entities) == 2 - - unique_ids = {entity.unique_id for entity in entities} - expected_ids = {"pt_spot_price", "es_spot_price"} - assert unique_ids == expected_ids + assert (pt_state := hass.states.get("sensor.omie_portugal_spot_price")) + assert (es_state := hass.states.get("sensor.omie_spain_spot_price")) + assert pt_state.state == "351151500.0" + assert es_state.state == "34151500.0" @pytest.mark.usefixtures("hass_lisbon") From cdeafdfd42ce56a611b676d76a6ced1dfc61ba98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 22:01:07 -0500 Subject: [PATCH 015/153] Bump yalexs to 9.2.1 (#172389) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 3bfdbb158599e..8088322a5f9eb 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -30,5 +30,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"] + "requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index d8eea99f1e979..1617603faec1c 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -14,5 +14,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"] + "requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1ca128e78885f..29288f09bdeb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3403,7 +3403,7 @@ yalexs-ble==3.3.0 # homeassistant.components.august # homeassistant.components.yale -yalexs==9.2.0 +yalexs==9.2.1 # homeassistant.components.yeelight yeelight==0.7.16 From 7247f95b05254bc1677c7f27068aedd0032d2a5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2026 22:00:56 -0500 Subject: [PATCH 016/153] Bump onvif-zeep-async to 4.1.1 (#172391) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 9ff6689654d8f..9456dee1e60ea 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -14,7 +14,7 @@ "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], "requirements": [ - "onvif-zeep-async==4.1.0", + "onvif-zeep-async==4.1.1", "onvif_parsers==2.3.0", "WSDiscovery==2.1.2" ] diff --git a/requirements_all.txt b/requirements_all.txt index 29288f09bdeb3..479e0b7a5465a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1737,7 +1737,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.1.7 # homeassistant.components.onvif -onvif-zeep-async==4.1.0 +onvif-zeep-async==4.1.1 # homeassistant.components.onvif onvif_parsers==2.3.0 From 6b1ee57bd5ba0f1a76bd7ea04e83d17b6b7c3a82 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 28 May 2026 12:58:51 +0200 Subject: [PATCH 017/153] Fix index error in DuckDNS integration (#172392) --- homeassistant/components/duckdns/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/duckdns/coordinator.py b/homeassistant/components/duckdns/coordinator.py index 55c91ce6131c8..048ab20171fd7 100644 --- a/homeassistant/components/duckdns/coordinator.py +++ b/homeassistant/components/duckdns/coordinator.py @@ -50,7 +50,7 @@ async def _async_update_data(self) -> None: """Update Duck DNS.""" retry_after = BACKOFF_INTERVALS[ - min(self.failed, len(BACKOFF_INTERVALS)) + min(self.failed, len(BACKOFF_INTERVALS) - 1) ].total_seconds() try: From 28d6eab2ddfb875f9c1668312e3057e93e5743c2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 28 May 2026 12:05:35 +0200 Subject: [PATCH 018/153] Improve MQTT protocol deprecation repair message (#172404) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index de32c14d754d5..7cd3fc1955583 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1140,7 +1140,7 @@ }, "step": { "confirm": { - "description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.", + "description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.", "title": "MQTT protocol change required" } } From c20405484754e291e7e9d3cd6a08540bb0507f7d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 12:05:19 +0200 Subject: [PATCH 019/153] Convert yamaha_musiccast sw_version to string (#172411) --- homeassistant/components/yamaha_musiccast/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py index cf13af76de4f0..9a6a2c6a5cdaf 100644 --- a/homeassistant/components/yamaha_musiccast/entity.py +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -63,7 +63,7 @@ def device_info(self) -> DeviceInfo: }, manufacturer=BRAND, model=self.coordinator.data.model_name, - sw_version=self.coordinator.data.system_version, + sw_version=str(self.coordinator.data.system_version), ) if self._zone_id == DEFAULT_ZONE: From 50a3ab115d59e56b6c4426e05b26a419f302b4ea Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 13:48:19 +0200 Subject: [PATCH 020/153] Fix iZone integration broken by python-izone 1.2.10 API change (#172427) --- homeassistant/components/izone/climate.py | 2 +- homeassistant/components/izone/config_flow.py | 5 +++-- tests/components/izone/conftest.py | 8 ++++---- tests/components/izone/test_config_flow.py | 6 +++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 34234cc4704bf..2ccf8f622834f 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -94,7 +94,7 @@ def init_controller(ctrl: Controller): async_add_entities(device.zones.values()) # create any components not yet created - for controller in disco.pi_disco.controllers.values(): + for controller in (await disco.pi_disco.fetch_controllers()).values(): init_controller(controller) # connect to register any further components diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index d56fb93d4e699..1fe044123c2ae 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -29,12 +29,13 @@ def dispatch_discovered(_): async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() - if not disco.pi_disco.controllers: + controllers = await disco.pi_disco.fetch_controllers() + if not controllers: await async_stop_discovery_service(hass) _LOGGER.debug("No controllers found") return False - _LOGGER.debug("Controllers %s", disco.pi_disco.controllers) + _LOGGER.debug("Controllers %s", controllers) return True diff --git a/tests/components/izone/conftest.py b/tests/components/izone/conftest.py index 61b6ac2637db4..289f3780a9dcb 100644 --- a/tests/components/izone/conftest.py +++ b/tests/components/izone/conftest.py @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: def mock_pizone_discovery_service() -> Mock: """Create a mock pizone discovery service.""" disco = Mock() - disco.controllers = {} + disco.fetch_controllers = AsyncMock(return_value={}) disco.start_discovery = AsyncMock() disco.close = AsyncMock() return disco @@ -96,9 +96,9 @@ async def mock_discovery( "homeassistant.components.izone.discovery.pizone.discovery", autospec=True ) as mock_disco: mock_disco.return_value.start_discovery = AsyncMock() - mock_disco.return_value.controllers = { - mock_controller.device_uid: mock_controller - } + mock_disco.return_value.fetch_controllers = AsyncMock( + return_value={mock_controller.device_uid: mock_controller} + ) mock_disco.return_value.close = AsyncMock() yield mock_disco diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 3c9707b34c6c9..b19e4df002d7a 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -18,7 +18,7 @@ def mock_disco() -> Mock: """Mock discovery service.""" disco = Mock() disco.pi_disco = Mock() - disco.pi_disco.controllers = {} + disco.pi_disco.fetch_controllers = AsyncMock(return_value={}) return disco @@ -60,7 +60,7 @@ async def test_not_found(hass: HomeAssistant, mock_disco: Mock) -> None: async def test_found(hass: HomeAssistant, mock_disco: Mock) -> None: """Test not finding iZone controller.""" - mock_disco.pi_disco.controllers["blah"] = object() + mock_disco.pi_disco.fetch_controllers = AsyncMock(return_value={"blah": object()}) with ( patch( From f2f29c07c7df7310f7a7280985442bca496420d0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 16:27:57 +0200 Subject: [PATCH 021/153] Fix SmartThings light checking wrong component for capabilities (#172430) Co-authored-by: Joostlek --- homeassistant/components/smartthings/light.py | 6 +- tests/components/smartthings/__init__.py | 1 + .../device_status/fibaro_dimmer_2.json | 70 +++++++++++ .../fixtures/devices/fibaro_dimmer_2.json | 110 +++++++++++++++++ .../smartthings/snapshots/test_event.ambr | 110 +++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 31 +++++ .../smartthings/snapshots/test_light.ambr | 60 +++++++++ .../smartthings/snapshots/test_sensor.ambr | 116 ++++++++++++++++++ 8 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/fibaro_dimmer_2.json create mode 100644 tests/components/smartthings/fixtures/devices/fibaro_dimmer_2.json diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 9f845b0320478..d5b5003029a5a 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -72,8 +72,10 @@ async def async_setup_entry( for device in entry_data.devices.values() for component in device.status if ( - Capability.SWITCH in device.status[MAIN] - and any(capability in device.status[MAIN] for capability in CAPABILITIES) + Capability.SWITCH in device.status[component] + and any( + capability in device.status[component] for capability in CAPABILITIES + ) and Capability.SAMSUNG_CE_LAMP not in device.status[component] ) ] diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index e792afab2c293..95029f0c0a35f 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -39,6 +39,7 @@ "copper_water_meter_v03", "base_electric_meter", "smart_plug", + "fibaro_dimmer_2", "vd_stv_2017_k", "c2c_arlo_pro_3_switch", "yale_push_button_deadbolt_lock", diff --git a/tests/components/smartthings/fixtures/device_status/fibaro_dimmer_2.json b/tests/components/smartthings/fixtures/device_status/fibaro_dimmer_2.json new file mode 100644 index 0000000000000..b12f1adb2f39b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/fibaro_dimmer_2.json @@ -0,0 +1,70 @@ +{ + "components": { + "button2": { + "button": { + "button": { + "value": null + }, + "numberOfButtons": { + "value": null + }, + "supportedButtonValues": { + "value": null + } + } + }, + "main": { + "powerMeter": { + "power": { + "value": 0.0, + "unit": "W", + "timestamp": "2026-05-28T12:21:31.639Z" + } + }, + "energyMeter": { + "energy": { + "value": 36.89, + "unit": "kWh", + "timestamp": "2026-05-28T12:32:57.766Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 0, + "unit": "%", + "timestamp": "2026-05-28T10:20:37.674Z" + } + }, + "legendabsolute60149.forcedOnLevel": { + "forcedOnLevel": { + "value": 5, + "unit": "%", + "timestamp": "2026-05-21T15:10:35.371Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "off", + "timestamp": "2026-05-28T10:20:37.616Z" + } + } + }, + "button1": { + "button": { + "button": { + "value": null + }, + "numberOfButtons": { + "value": null + }, + "supportedButtonValues": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/fibaro_dimmer_2.json b/tests/components/smartthings/fixtures/devices/fibaro_dimmer_2.json new file mode 100644 index 0000000000000..bd2fb8d00aa15 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/fibaro_dimmer_2.json @@ -0,0 +1,110 @@ +{ + "items": [ + { + "deviceId": "df9f5405-f930-46aa-9693-14c570b35c83", + "name": "fibaro-dimmer-2", + "label": "Dimmer entr\u00e9 1 1", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "b1b79065-1923-3e7a-b016-9abcc3d1d416", + "deviceManufacturerCode": "010F-0102-1001", + "locationId": "c85a9f8a-5d2e-4cdd-8bdb-bc49ba4a3544", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "f4084bf6-2985-47cc-b3c9-3907d098ec0c", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "legendabsolute60149.forcedOnLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "button1", + "label": "button1", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "button2", + "label": "button2", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2023-04-23T06:35:16.202Z", + "parentDeviceId": "4869d882-e898-40c3-a198-7611b72187a5", + "profile": { + "id": "c6edf569-c7bb-38e7-a442-fe397ebe0cae" + }, + "zwave": { + "networkId": "0A", + "driverId": "17c05c19-f008-42e2-aa12-f12f1aae5612", + "executingLocally": true, + "hubId": "4869d882-e898-40c3-a198-7611b72187a5", + "networkSecurityLevel": "ZWAVE_S0_LEGACY", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 271, + "productType": 258, + "productId": 4097 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_event.ambr b/tests/components/smartthings/snapshots/test_event.ambr index 1303388717dcb..1f4c540c96852 100644 --- a/tests/components/smartthings/snapshots/test_event.ambr +++ b/tests/components/smartthings/snapshots/test_event.ambr @@ -1,4 +1,114 @@ # serializer version: 1 +# name: test_all_entities[fibaro_dimmer_2][event.dimmer_entre_1_1_button1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.dimmer_entre_1_1_button1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'button1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': 'df9f5405-f930-46aa-9693-14c570b35c83_button1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fibaro_dimmer_2][event.dimmer_entre_1_1_button1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': None, + 'friendly_name': 'Dimmer entré 1 1 button1', + }), + 'context': , + 'entity_id': 'event.dimmer_entre_1_1_button1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[fibaro_dimmer_2][event.dimmer_entre_1_1_button2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.dimmer_entre_1_1_button2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'button2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button2', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': 'df9f5405-f930-46aa-9693-14c570b35c83_button2_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fibaro_dimmer_2][event.dimmer_entre_1_1_button2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': None, + 'friendly_name': 'Dimmer entré 1 1 button2', + }), + 'context': , + 'entity_id': 'event.dimmer_entre_1_1_button2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 90d326d1a1a9b..b827e2183c0f7 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1828,6 +1828,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[fibaro_dimmer_2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'df9f5405-f930-46aa-9693-14c570b35c83', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Dimmer entré 1 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[gas_detector] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 3e8f614b213b1..0ef795e667636 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -542,6 +542,66 @@ 'state': 'on', }) # --- +# name: test_all_entities[fibaro_dimmer_2][light.dimmer_entre_1_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_entre_1_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'df9f5405-f930-46aa-9693-14c570b35c83_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fibaro_dimmer_2][light.dimmer_entre_1_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer entré 1 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_entre_1_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[ge_in_wall_smart_dimmer][light.theater_basement_exit_light-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index d5015d17fff29..ca67688ef67ab 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -19725,6 +19725,122 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[fibaro_dimmer_2][sensor.dimmer_entre_1_1_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dimmer_entre_1_1_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'df9f5405-f930-46aa-9693-14c570b35c83_main_energyMeter_energy_energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[fibaro_dimmer_2][sensor.dimmer_entre_1_1_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dimmer entré 1 1 Energy', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.dimmer_entre_1_1_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.89', + }) +# --- +# name: test_all_entities[fibaro_dimmer_2][sensor.dimmer_entre_1_1_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dimmer_entre_1_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'df9f5405-f930-46aa-9693-14c570b35c83_main_powerMeter_power_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[fibaro_dimmer_2][sensor.dimmer_entre_1_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dimmer entré 1 1 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.dimmer_entre_1_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[gas_detector][sensor.gas_detector_link_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From b6c40ba3fc0bd3f3e069bc15130c5a9eda1eadd8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 13:06:10 +0200 Subject: [PATCH 022/153] Fix Jellyfin media source crash when entry is not loaded (#172437) --- .../components/jellyfin/media_source.py | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index d181601e172ff..8a9a437e966c3 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -52,11 +52,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Jellyfin media source.""" - # Currently only a single Jellyfin server is supported - entry: JellyfinConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = entry.runtime_data - - return JellyfinSource(hass, coordinator.api_client, entry) + return JellyfinSource(hass) class JellyfinSource(MediaSource): @@ -64,21 +60,28 @@ class JellyfinSource(MediaSource): name: str = "Jellyfin" - def __init__( - self, hass: HomeAssistant, client: JellyfinClient, entry: JellyfinConfigEntry - ) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Jellyfin media source.""" super().__init__(DOMAIN) - self.hass = hass + self.entry: JellyfinConfigEntry + self.client: JellyfinClient + self.api: Any + self.url: str + + def _ensure_loaded(self) -> None: + """Ensure the Jellyfin integration is loaded and set up instance state.""" + if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)): + raise BrowseError("Jellyfin integration not loaded") + entry: JellyfinConfigEntry = entries[0] self.entry = entry - - self.client = client - self.api = client.jellyfin - self.url = jellyfin_url(client, "") + self.client = entry.runtime_data.api_client + self.api = self.client.jellyfin + self.url = jellyfin_url(self.client, "") async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Return a streamable URL and associated mime type.""" + self._ensure_loaded() media_item = await self.hass.async_add_executor_job( self.api.get_item, item.identifier ) @@ -94,6 +97,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Return a browsable Jellyfin media source.""" + self._ensure_loaded() if not item.identifier: return await self._build_libraries() From ba8b33e1a9c3271838f648d40004f73fc5c0fbb9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 1 Jun 2026 10:57:09 +0200 Subject: [PATCH 023/153] Fix Shelly sensor restore when not initialized (#172441) --- homeassistant/components/shelly/entity.py | 13 ++++++----- tests/components/shelly/test_sensor.py | 27 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 72519b5756188..6ed6834345a43 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -244,7 +244,8 @@ def async_restore_rpc_attribute_entities( sensor_class: Callable, ) -> None: """Restore RPC attributes entities.""" - entities = [] + entities: list[Entity] = [] + sleep_period = config_entry.data[CONF_SLEEP_PERIOD] ent_reg = er.async_get(hass) entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) @@ -259,11 +260,13 @@ def async_restore_rpc_attribute_entities( attribute = entry.unique_id.split("-")[-1] if description := sensors.get(attribute): - entities.append( - get_entity_class(sensor_class, description)( - coordinator, key, attribute, description, entry + entity_class = get_entity_class(sensor_class, description) + if sleep_period: + entities.append( + entity_class(coordinator, key, attribute, description, entry) ) - ) + else: + entities.append(entity_class(coordinator, key, attribute, description)) if not entities: return diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 1a1c99adbde5b..ed394d349b842 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -728,6 +728,33 @@ async def test_rpc_restored_sleeping_sensor( assert state.state == "22.9" +async def test_rpc_restored_sensor_when_not_initialized( + hass: HomeAssistant, + mock_rpc_device: Mock, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test restored mains-powered RPC sensor when device is not initialized.""" + entry = await init_integration(hass, 2, skip_setup=True) + device = register_device(device_registry, entry) + entity_id = register_entity( + hass, + SENSOR_DOMAIN, + "test_name_temperature", + "temperature:0-temperature_tc", + entry, + device_id=device.id, + ) + + monkeypatch.setattr(mock_rpc_device, "initialized", False) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + async def test_rpc_restored_sleeping_sensor_no_last_state( hass: HomeAssistant, mock_rpc_device: Mock, From e86a54f81c393a2b41c82be9e94a317383e8e974 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 13:50:45 +0200 Subject: [PATCH 024/153] Fix Hue light ZeroDivisionError when mirek value is zero (#172442) --- homeassistant/components/hue/v2/light.py | 16 +++++++++------ tests/components/hue/test_light_v2.py | 26 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 041c05579b5eb..5b687eff196e6 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -178,17 +178,21 @@ def color_temp_kelvin(self) -> int | None: @property def max_color_temp_mireds(self) -> int: """Return the warmest color_temp in mireds that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_maximum - # return a fallback value if the light doesn't provide limits + if (color_temp := self.resource.color_temperature) and ( + mirek_max := color_temp.mirek_schema.mirek_maximum + ): + return mirek_max + # return a fallback value if the light doesn't provide valid limits return FALLBACK_MAX_MIREDS @property def min_color_temp_mireds(self) -> int: """Return the coldest color_temp in mireds that this light supports.""" - if color_temp := self.resource.color_temperature: - return color_temp.mirek_schema.mirek_minimum - # return a fallback value if the light doesn't provide limits + if (color_temp := self.resource.color_temperature) and ( + mirek_min := color_temp.mirek_schema.mirek_minimum + ): + return mirek_min + # return a fallback value if the light doesn't provide valid limits return FALLBACK_MIN_MIREDS @property diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 937177dc43174..dac308cb97882 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -1046,3 +1046,29 @@ async def test_light_turn_on_service_deprecation( blocking=True, ) assert mock_bridge_v2.mock_requests[0]["json"]["effects"]["effect"] == "no_effect" + + +async def test_light_with_zero_mirek( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test light doesn't crash when bridge reports zero mirek values. + + Regression test for https://github.com/home-assistant/core/issues/116258 + """ + # Patch the fixture data to have zero mirek values before loading + for resource in v2_resources_test_data: + if resource.get("type") == "light" and "color_temperature" in resource: + resource["color_temperature"]["mirek_schema"]["mirek_minimum"] = 0 + resource["color_temperature"]["mirek_schema"]["mirek_maximum"] = 0 + break + + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + # Should not raise ZeroDivisionError during setup + await setup_platform(hass, mock_bridge_v2, Platform.LIGHT) + + test_light = hass.states.get("light.hue_light_with_color_and_color_temperature_1") + assert test_light is not None + # Should fall back to defaults instead of crashing + assert test_light.attributes["max_color_temp_kelvin"] == 6535 + assert test_light.attributes["min_color_temp_kelvin"] == 2000 From 951cd717416d17f1166faefb70c068e2da653919 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 28 May 2026 19:42:19 +0200 Subject: [PATCH 025/153] Discard old events for Alexa Devices (#172446) --- homeassistant/components/alexa_devices/event.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/event.py b/homeassistant/components/alexa_devices/event.py index ec0c39972ba32..c785f385bd716 100644 --- a/homeassistant/components/alexa_devices/event.py +++ b/homeassistant/components/alexa_devices/event.py @@ -53,7 +53,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity): _attr_event_types = [EVENT_TYPE] coordinator: AmazonDevicesCoordinator - _last_seen_timestamp: int | None = None + _last_seen_timestamp: int = 0 # January 1, 1970 at 12:00:00 AM @callback def _handle_coordinator_update(self) -> None: @@ -71,7 +71,8 @@ def _handle_coordinator_update(self) -> None: ) return - if vocal_record.timestamp == self._last_seen_timestamp: + if vocal_record.timestamp <= self._last_seen_timestamp: + # Discard old events that have already been processed return self._last_seen_timestamp = vocal_record.timestamp From 342b364af6c409c59d3878320f00e91d5ddf3bec Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Thu, 28 May 2026 19:00:43 +0200 Subject: [PATCH 026/153] Fix Duco regression where entities become unavailable when LAN info fetch fails (#172448) --- homeassistant/components/duco/coordinator.py | 14 ++++++++++-- tests/components/duco/test_init.py | 23 ++++++++++++++++++++ tests/components/duco/test_sensor.py | 22 ++++++++++++++----- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index 219c696a63b1c..c68fe7e9e0552 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -86,7 +86,6 @@ async def _async_update_data(self) -> DucoData: """Fetch node data from the Duco box.""" try: nodes = await self.client.async_get_nodes() - lan_info = await self.client.async_get_lan_info() except DucoConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, @@ -100,7 +99,18 @@ async def _async_update_data(self) -> DucoData: translation_placeholders={"error": repr(err)}, ) from err + # LAN info only backs the diagnostic RSSI sensor, so failures on this + # supplemental endpoint, including connection failures, should not make + # the primary node entities unavailable. + rssi_wifi = self.data.rssi_wifi if self.data else None + try: + lan_info = await self.client.async_get_lan_info() + except DucoError as err: + _LOGGER.debug("Could not fetch Duco LAN info", exc_info=err) + else: + rssi_wifi = lan_info.rssi_wifi + return DucoData( nodes={node.node_id: node for node in nodes}, - rssi_wifi=lan_info.rssi_wifi, + rssi_wifi=rssi_wifi, ) diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 08cbb79bf5283..17b07c25739f4 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -98,6 +98,29 @@ async def test_setup_entry_success( assert init_integration.state is ConfigEntryState.LOADED +@pytest.mark.parametrize( + "exception", + [ + pytest.param(DucoError("lan info error"), id="duco_error"), + pytest.param(DucoConnectionError("lan info offline"), id="connection_error"), + ], +) +async def test_setup_entry_ignores_lan_info_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + exception: Exception, +) -> None: + """Test setup succeeds when the supplemental LAN info endpoint fails.""" + mock_duco_client.async_get_lan_info.side_effect = exception + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + @pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS) async def test_setup_entry_unsupported_board_info( hass: HomeAssistant, diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 33cd8535acb24..b216900b39332 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -122,24 +122,34 @@ async def test_coordinator_update_duco_error_marks_unavailable( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "exception", + [ + pytest.param(DucoError("lan info error"), id="duco_error"), + pytest.param(DucoConnectionError("lan info offline"), id="connection_error"), + ], +) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") -async def test_lan_info_duco_error_marks_unavailable( +async def test_lan_info_failures_keep_node_entities_available( hass: HomeAssistant, mock_duco_client: AsyncMock, freezer: FrozenDateTimeFactory, + exception: Exception, ) -> None: - """Test entities become unavailable when async_get_lan_info raises DucoError.""" - mock_duco_client.async_get_lan_info = AsyncMock( - side_effect=DucoError("lan info error") - ) + """Test node entities stay available when LAN info retrieval fails.""" + mock_duco_client.async_get_lan_info = AsyncMock(side_effect=exception) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.office_co2_carbon_dioxide") + assert state is not None + assert state.state == "405" + state = hass.states.get("sensor.living_signal_strength") assert state is not None - assert state.state == STATE_UNAVAILABLE + assert state.state == "-60" @pytest.mark.parametrize( From d9b4b5b3d0a00e33f53c73b3a6e57991e8bada1b Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 28 May 2026 19:25:36 +0200 Subject: [PATCH 027/153] Fix Matter BLE proxy blocking startup (#172456) --- homeassistant/components/matter/ble_proxy.py | 4 +++- tests/components/matter/test_ble_proxy.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/ble_proxy.py b/homeassistant/components/matter/ble_proxy.py index 99bf345bbf974..9e6a93d7080ca 100644 --- a/homeassistant/components/matter/ble_proxy.py +++ b/homeassistant/components/matter/ble_proxy.py @@ -108,5 +108,7 @@ def create_matter_ble_proxy(hass: HomeAssistant, ws_url: str) -> MatterBleProxy: ws_url=ws_url, scan_source=HaBluetoothScanSource(hass), device_resolver=HaBluetoothDeviceResolver(hass), - task_factory=hass.async_create_task, + task_factory=lambda coro: hass.async_create_background_task( + coro, name="matter_ble_proxy" + ), ) diff --git a/tests/components/matter/test_ble_proxy.py b/tests/components/matter/test_ble_proxy.py index f8a05b77bab65..e6c7c2b3059b3 100644 --- a/tests/components/matter/test_ble_proxy.py +++ b/tests/components/matter/test_ble_proxy.py @@ -69,9 +69,15 @@ def test_create_matter_ble_proxy_wires_ha_backends(hass: HomeAssistant) -> None: assert kwargs["ws_url"] == "ws://localhost:5580/ble" assert isinstance(kwargs["scan_source"], HaBluetoothScanSource) assert isinstance(kwargs["device_resolver"], HaBluetoothDeviceResolver) - assert kwargs["task_factory"] == hass.async_create_task assert result is proxy_cls.return_value + coro = MagicMock() + with patch.object(hass, "async_create_background_task") as bg_task: + task = kwargs["task_factory"](coro) + + bg_task.assert_called_once_with(coro, name="matter_ble_proxy") + assert task is bg_task.return_value + async def test_scan_source_start_registers_passive_callback( hass: HomeAssistant, From 6f3fb5c7bd6864ead6d9b8ddd1f410adab9f67d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 28 May 2026 18:52:55 +0200 Subject: [PATCH 028/153] Add lg_tv_rs232 to LG brand (#172458) Co-authored-by: Claude Co-authored-by: Joostlek --- homeassistant/brands/lg.json | 1 + homeassistant/generated/integrations.json | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/brands/lg.json b/homeassistant/brands/lg.json index 65fc54da8bd57..bc28cf93a7a1b 100644 --- a/homeassistant/brands/lg.json +++ b/homeassistant/brands/lg.json @@ -6,6 +6,7 @@ "lg_netcast", "lg_soundbar", "lg_thinq", + "lg_tv_rs232", "webostv" ] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e5368a6c9bcb2..c25fac49108ea 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3759,6 +3759,12 @@ "iot_class": "cloud_push", "name": "LG ThinQ" }, + "lg_tv_rs232": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "LG TV via Serial" + }, "webostv": { "integration_type": "device", "config_flow": true, @@ -3767,12 +3773,6 @@ } } }, - "lg_tv_rs232": { - "name": "LG TV via Serial", - "integration_type": "device", - "config_flow": true, - "iot_class": "local_polling" - }, "libre_hardware_monitor": { "name": "Libre Hardware Monitor", "integration_type": "device", From 47bca8d8c21dfa993eddff3ed51a1c34e850b8f5 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 28 May 2026 19:52:47 +0200 Subject: [PATCH 029/153] Bump frontend to 20260527.1 (#172462) Co-authored-by: Joostlek --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7fb9ed5658b60..222a33816f833 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260527.0"] + "requirements": ["home-assistant-frontend==20260527.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2e6b23c7c0fcb..2b78353a61c9f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.7.9 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260527.0 +home-assistant-frontend==20260527.1 home-assistant-intents==2026.5.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 67fcca937f8f9..090bb31ed4154 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260527.0" +FRONTEND_VERSION: Final[str] = "20260527.1" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index 479e0b7a5465a..89fad0c9ee351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1266,7 +1266,7 @@ hole==0.9.0 holidays==0.97 # homeassistant.components.frontend -home-assistant-frontend==20260527.0 +home-assistant-frontend==20260527.1 # homeassistant.components.conversation home-assistant-intents==2026.5.5 From 1828579f03c1f32ede7d4f1ff2668c3ba8033f36 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 May 2026 10:50:55 +0200 Subject: [PATCH 030/153] Fix Volvo lock crash when API field is missing from coordinator data (#172465) --- homeassistant/components/volvo/lock.py | 4 ++++ tests/components/volvo/test_lock.py | 32 ++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/volvo/lock.py b/homeassistant/components/volvo/lock.py index 85686ca232043..8c7f55131e871 100644 --- a/homeassistant/components/volvo/lock.py +++ b/homeassistant/components/volvo/lock.py @@ -75,6 +75,10 @@ async def async_unlock(self, **kwargs: Any) -> None: def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: """Update the state of the entity.""" + if api_field is None: + self._attr_is_locked = None + return + assert isinstance(api_field, VolvoCarsValue) self._attr_is_locked = api_field.value == "LOCKED" diff --git a/tests/components/volvo/test_lock.py b/tests/components/volvo/test_lock.py index dfabc82d6547e..e63bd2f3d213d 100644 --- a/tests/components/volvo/test_lock.py +++ b/tests/components/volvo/test_lock.py @@ -1,8 +1,10 @@ """Test Volvo locks.""" from collections.abc import Awaitable, Callable +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from volvocarsapi.api import VolvoCarsApi @@ -14,7 +16,8 @@ SERVICE_UNLOCK, LockState, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.components.volvo.coordinator import FAST_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -22,7 +25,7 @@ from . import configure_mock from .const import DEFAULT_VIN -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("mock_api", "full_model") @@ -134,3 +137,28 @@ async def test_unlock_failure( ) await hass.async_block_till_done() assert hass.states.get(entity_id).state == LockState.LOCKED + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +@pytest.mark.usefixtures("full_model") +async def test_lock_unavailable_when_api_field_missing( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test lock becomes unavailable when centralLock is missing from API response.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]): + assert await setup_integration() + + entity_id = "lock.volvo_xc40_lock" + assert hass.states.get(entity_id).state == LockState.LOCKED + + # Simulate API returning doors data without centralLock + configure_mock(mock_api.async_get_doors_status, return_value={}) + freezer.tick(timedelta(minutes=FAST_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE From 9dddf76548618382df867610716e98302dce12d0 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 28 May 2026 19:02:14 +0200 Subject: [PATCH 031/153] Name the Broadlink RF transmitter entity (#172468) --- homeassistant/components/broadlink/radio_frequency.py | 2 +- homeassistant/components/broadlink/strings.json | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/radio_frequency.py b/homeassistant/components/broadlink/radio_frequency.py index 04f54582f1255..915de70ffc284 100644 --- a/homeassistant/components/broadlink/radio_frequency.py +++ b/homeassistant/components/broadlink/radio_frequency.py @@ -92,7 +92,7 @@ class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity): """Representation of a Broadlink RF transmitter.""" _attr_has_entity_name = True - _attr_name = None + _attr_translation_key = "rf_transmitter" def __init__(self, device: BroadlinkDevice) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index c291e7a77a0c6..6bfb43e86ade8 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -54,6 +54,11 @@ "name": "IR emitter" } }, + "radio_frequency": { + "rf_transmitter": { + "name": "RF transmitter" + } + }, "select": { "day_of_week": { "name": "Day of week", From 3bd979e9766b65bfb7946c3d001682956dceff0d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 28 May 2026 19:02:30 +0200 Subject: [PATCH 032/153] Bump samsungtvws to 3.0.5 (#172471) --- homeassistant/components/samsungtv/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 60d9abcb35ccc..d1a3a01ded8cd 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -38,7 +38,7 @@ "requirements": [ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.7.2", + "samsungtvws[async,encrypted]==3.0.5", "wakeonlan==3.3.0", "async-upnp-client==0.46.2" ], diff --git a/requirements_all.txt b/requirements_all.txt index 89fad0c9ee351..42dc3f8fafe37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2929,7 +2929,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.7.2 +samsungtvws[async,encrypted]==3.0.5 # homeassistant.components.sanix sanix==1.0.6 From aa87295a1e75ec5ea1f17bb04758a0e56567bdb5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 08:54:13 +0200 Subject: [PATCH 033/153] Fix Growatt setup failure on API rate limit (#172472) --- homeassistant/components/growatt_server/__init__.py | 11 ++++++++++- tests/components/growatt_server/test_init.py | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index c0218ea31c51f..c160b253fb934 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -34,7 +34,11 @@ from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -55,6 +59,7 @@ PLATFORMS, SUPPORTED_DEVICE_TYPES, V1_API_ERROR_NO_PRIVILEGE, + V1_API_ERROR_RATE_LIMITED, V1_DEVICE_TYPES, ) from .coordinator import GrowattConfigEntry, GrowattCoordinator @@ -264,6 +269,10 @@ def get_device_list_v1( raise ConfigEntryAuthFailed( f"Authentication failed for Growatt API: {e.error_msg or str(e)}" ) from e + if e.error_code == V1_API_ERROR_RATE_LIMITED: + raise ConfigEntryNotReady( + f"Growatt API rate limited, will retry: {e.error_msg or str(e)}" + ) from e raise ConfigEntryError( f"API error during device list: {e.error_msg or str(e)}" f" (Code: {e.error_code})" diff --git a/tests/components/growatt_server/test_init.py b/tests/components/growatt_server/test_init.py index 76a3ca7cdddb5..7b91caa7e5b5f 100644 --- a/tests/components/growatt_server/test_init.py +++ b/tests/components/growatt_server/test_init.py @@ -20,6 +20,7 @@ DEVICE_SCAN_INTERVAL, DOMAIN, LOGIN_INVALID_AUTH_CODE, + V1_API_ERROR_RATE_LIMITED, V1_API_ERROR_WRONG_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -79,6 +80,14 @@ async def test_device_info( json.decoder.JSONDecodeError("Invalid JSON", "", 0), ConfigEntryState.SETUP_ERROR, ), + ( + growattServer.GrowattV1ApiError( + message="Rate limited", + error_code=V1_API_ERROR_RATE_LIMITED, + error_msg="Access frequency limit", + ), + ConfigEntryState.SETUP_RETRY, + ), ], ) async def test_setup_error_on_api_failure( From 7ab402618dcad55b16d3606b253faf5b50bea876 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 19:53:20 +0200 Subject: [PATCH 034/153] Handle DAVError in CalDAV get_supported_components (#172479) --- homeassistant/components/caldav/api.py | 3 ++- tests/components/caldav/test_calendar.py | 25 ++++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index 567a7a6f34fed..2f91d76aba9a2 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -3,6 +3,7 @@ import logging import caldav +from caldav.lib.error import DAVError from homeassistant.core import HomeAssistant @@ -26,7 +27,7 @@ def _get_calendars() -> tuple[ for calendar in client.principal().calendars(): try: supported_components = calendar.get_supported_components() - except KeyError: + except KeyError, DAVError: needs_warning.append((str(calendar.url), calendar.name, component)) if component in ASSUMED_COMPONENTS: diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index cb4082967e715..39c3350d4b971 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, Mock, patch import zoneinfo +from caldav.lib.error import NotFoundError from caldav.objects import Event from freezegun.api import FrozenDateTimeFactory import pytest @@ -1328,15 +1329,23 @@ async def test_add_vevent( assert calendars[0].add_event.call_args[1] == expected_ics_fields +@pytest.mark.parametrize( + "exception", + [ + pytest.param(KeyError(), id="key_error"), + pytest.param(NotFoundError(), id="not_found_error"), + ], +) async def test_missing_supported_components( hass: HomeAssistant, calendars: list[Mock], setup_platform_cb: Callable[[], Awaitable[None]], caplog: pytest.LogCaptureFixture, + exception: Exception, ) -> None: - """Test setup works when calendar raises KeyError on get_supported_components.""" + """Test setup works when calendar raises on get_supported_components.""" caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api") - calendars[0].get_supported_components.side_effect = KeyError() + calendars[0].get_supported_components.side_effect = exception await setup_platform_cb() assert hass.states.get(TEST_ENTITY) @@ -1367,14 +1376,22 @@ async def test_missing_supported_components( assert vjournal_warning in caplog.text +@pytest.mark.parametrize( + "exception", + [ + pytest.param(KeyError(), id="key_error"), + pytest.param(NotFoundError(), id="not_found_error"), + ], +) async def test_missing_supported_components_not_assumed( hass: HomeAssistant, calendars: list[Mock], caplog: pytest.LogCaptureFixture, + exception: Exception, ) -> None: - """Test get_calendars excludes calendars on KeyError.""" + """Test get_calendars excludes calendars when components unavailable.""" caplog.set_level(logging.WARNING, logger="homeassistant.components.caldav.api") - calendars[0].get_supported_components.side_effect = KeyError() + calendars[0].get_supported_components.side_effect = exception client = MagicMock() client.principal().calendars.return_value = calendars From 7a7ef85db2baed465152d641a34c80c9cb1187da Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 28 May 2026 20:36:39 +0200 Subject: [PATCH 035/153] Move MQTT protocol setting to main options (#172482) --- homeassistant/components/mqtt/config_flow.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 7a86028482cc7..2e9993867de13 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5457,7 +5457,6 @@ async def _async_validate_broker_settings( or current_client_certificate or current_client_key or current_tls_insecure - or current_protocol != DEFAULT_PROTOCOL or current_config.get(SET_CA_CERT, "off") != "off" or current_config.get(SET_CLIENT_CERT) or current_transport == TRANSPORT_WEBSOCKETS @@ -5466,6 +5465,12 @@ async def _async_validate_broker_settings( # Build form fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR + fields[ + vol.Optional( + CONF_PROTOCOL, + description={"suggested_value": current_protocol}, + ) + ] = PROTOCOL_SELECTOR fields[ vol.Optional( CONF_USERNAME, @@ -5556,12 +5561,6 @@ async def _async_validate_broker_settings( description={"suggested_value": current_tls_insecure}, ) ] = BOOLEAN_SELECTOR - fields[ - vol.Optional( - CONF_PROTOCOL, - description={"suggested_value": current_protocol}, - ) - ] = PROTOCOL_SELECTOR fields[ vol.Optional( CONF_TRANSPORT, From 323ce99fdab94127b6ee10f36aa0742e16846ee8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 22:06:24 +0200 Subject: [PATCH 036/153] Fix Tado config flow crash on device activation polling (#172486) --- homeassistant/components/tado/config_flow.py | 10 +++--- tests/components/tado/test_config_flow.py | 37 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index cd1fb307eb1e1..eeb90e7ca56e6 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -40,6 +40,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): login_task: asyncio.Task | None = None refresh_token: str | None = None tado: Tado | None = None + tado_device_url: str = "" + user_code: str = "" async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -69,8 +71,8 @@ async def async_step_user( _LOGGER.exception("Error while initiating Tado") return self.async_abort(reason="cannot_connect") assert self.tado is not None - tado_device_url = self.tado.device_verification_url() - user_code = URL(tado_device_url).query["user_code"] + self.tado_device_url = self.tado.device_verification_url() + self.user_code = URL(self.tado_device_url).query["user_code"] async def _wait_for_login() -> None: """Wait for the user to login.""" @@ -119,8 +121,8 @@ async def _wait_for_login() -> None: step_id="user", progress_action="wait_for_device", description_placeholders={ - "url": tado_device_url, - "code": user_code, + "url": self.tado_device_url, + "code": self.user_code, }, progress_task=self.login_task, ) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 6e7cb7dd928de..5367dcc437738 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -231,6 +231,43 @@ async def test_options_flow( assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} +async def test_show_progress_polling( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test progress step re-entry while login task is still running.""" + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["description_placeholders"]["url"] is not None + assert result["description_placeholders"]["code"] == "TEST" + + # Poll again while task is still running — this re-enters async_step_user + # with self.tado already set + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["description_placeholders"]["url"] is not None + assert result["description_placeholders"]["code"] == "TEST" + + # Now complete the login + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: """Test that we abort from homekit if tado is already setup.""" From 114c9bbafab8823e77988d79965076c7dad10efe Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 22:41:54 +0200 Subject: [PATCH 037/153] Increase ConfigEntryNotReady retry backoff cap from 80s to 10 minutes (#172487) --- homeassistant/config_entries.py | 4 +++- tests/test_config_entries.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 66d56b73db920..681fe41c9f139 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -138,6 +138,8 @@ DISCOVERY_COOLDOWN = 1 +SETUP_RETRY_MAX_WAIT = 600 # 10 minutes + ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision" UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 @@ -836,7 +838,7 @@ async def __async_setup_with_context( error_reason_translation_key, error_reason_translation_placeholders, ) - wait_time = 2 ** min(self._tries, 4) * 5 + ( + wait_time = min(2**self._tries * 5, SETUP_RETRY_MAX_WAIT) + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) self._tries += 1 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 0edc0335b7f43..51dafd7683911 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1702,6 +1702,44 @@ async def test_setup_raise_not_ready( assert entry.reason is None +async def test_setup_not_ready_exponential_backoff( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup retry uses exponential backoff capped at 10 minutes.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + + attempts = 0 + + async def _mock_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + nonlocal attempts + attempts += 1 + raise ConfigEntryNotReady + + mock_integration(hass, MockModule("test", async_setup_entry=_mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + await manager.async_setup(entry.entry_id) + assert attempts == 1 + + expected_waits = [5, 10, 20, 40, 80, 160, 320, 600, 600] + for i, wait in enumerate(expected_waits): + # Advance to just before the retry should fire + freezer.tick(wait - 1) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert attempts == i + 1, f"Retry {i + 1} fired too early" + + # Advance past the retry point (+ 1s for jitter) + freezer.tick(2) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert attempts == i + 2, f"Retry {i + 1} did not fire" + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + async def test_setup_raise_not_ready_from_exception( hass: HomeAssistant, manager: config_entries.ConfigEntries, From c6d696db0c4ba31992a36eb4e098f0311c846b25 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 1 Jun 2026 06:59:54 +0200 Subject: [PATCH 038/153] Remove redundant definitions in Alexa Devices (#172488) --- homeassistant/components/alexa_devices/button.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py index 9a735f550fc1e..1eebe201c821f 100644 --- a/homeassistant/components/alexa_devices/button.py +++ b/homeassistant/components/alexa_devices/button.py @@ -39,11 +39,8 @@ def _check_routines() -> None: class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): """Button entity for Alexa routine.""" - _attr_has_entity_name = True - def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None: """Initialize the routine button entity.""" - self._coordinator = coordinator self._routine = routine super().__init__( coordinator, @@ -52,4 +49,4 @@ def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None: async def async_press(self) -> None: """Handle button press action.""" - await self._coordinator.api.call_routine(self._routine) + await self.coordinator.api.call_routine(self._routine) From c13822b776315b9fb5ffe6f49e4ef833c85d19c3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 29 May 2026 13:22:26 +0200 Subject: [PATCH 039/153] Handle FileNotFoundError in Immich upload_file action (#172490) --- homeassistant/components/immich/services.py | 2 +- tests/components/immich/test_services.py | 37 ++++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/immich/services.py b/homeassistant/components/immich/services.py index 499b3b475a459..60c98d1e76f26 100644 --- a/homeassistant/components/immich/services.py +++ b/homeassistant/components/immich/services.py @@ -67,7 +67,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None: await coordinator.api.albums.async_add_assets_to_album( target_album, [upload_result.asset_id] ) - except ImmichError as ex: + except (ImmichError, FileNotFoundError) as ex: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="upload_failed", diff --git a/tests/components/immich/test_services.py b/tests/components/immich/test_services.py index d907061dd9957..4bfb43db81936 100644 --- a/tests/components/immich/test_services.py +++ b/tests/components/immich/test_services.py @@ -1,5 +1,6 @@ """Test the Immich services.""" +import re from unittest.mock import Mock, patch from aioimmich.exceptions import ImmichError, ImmichNotFoundError @@ -210,8 +211,30 @@ async def test_upload_file_album_not_found( ) +@pytest.mark.parametrize( + ("side_effect", "expected_err_message"), + [ + ( + ImmichError( + { + "message": "Boom! Upload failed", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ), + "Boom! Upload failed (error: 'Bad Request' code: '400' correlation_id: 'nyzxjkno')", + ), + ( + FileNotFoundError(2, "No such file or directory", "/media/screenshot.jpg"), + "[Errno 2] No such file or directory: '/media/screenshot.jpg'", + ), + ], +) async def test_upload_file_upload_failed( hass: HomeAssistant, + side_effect: Exception, + expected_err_message: str, mock_immich: Mock, mock_config_entry: MockConfigEntry, mock_media_source: Mock, @@ -219,16 +242,12 @@ async def test_upload_file_upload_failed( """Test upload_file service raising upload_failed.""" await setup_integration(hass, mock_config_entry) - mock_immich.assets.async_upload_asset.side_effect = ImmichError( - { - "message": "Boom! Upload failed", - "error": "Bad Request", - "statusCode": 400, - "correlationId": "nyzxjkno", - } - ) + mock_immich.assets.async_upload_asset.side_effect = side_effect with pytest.raises( - ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed" + ServiceValidationError, + match=re.escape( + f"Upload of file `/media/screenshot.jpg` failed ({expected_err_message})" + ), ): await hass.services.async_call( DOMAIN, From df65132268a2e7e979d8b5df105d3a1fe2c22ce3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 22:21:53 +0200 Subject: [PATCH 040/153] Add prog operating mode to Overkiz Atlantic heater HVAC mapping (#172491) --- ...tic_electrical_heater_with_adjustable_temperature_setpoint.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 577a045ffe033..7330973b6935b 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -57,6 +57,7 @@ OverkizCommandParam.STANDBY: HVACMode.OFF, # main command OverkizCommandParam.AUTO: HVACMode.AUTO, OverkizCommandParam.EXTERNAL: HVACMode.AUTO, + OverkizCommandParam.PROG: HVACMode.AUTO, OverkizCommandParam.INTERNAL: HVACMode.AUTO, # main command } From f728a1bf0950c22f53c3bac93047d5211ee1b4fd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 12:32:39 +0200 Subject: [PATCH 041/153] Add missing Flexit BACnet transient operation modes to preset map (#172493) --- .../components/flexit_bacnet/const.py | 4 +++ .../components/flexit_bacnet/test_climate.py | 31 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py index 19de54ddd203d..0ebda4b0e2a20 100644 --- a/homeassistant/components/flexit_bacnet/const.py +++ b/homeassistant/components/flexit_bacnet/const.py @@ -2,10 +2,12 @@ from flexit_bacnet import ( OPERATION_MODE_AWAY, + OPERATION_MODE_COOKER_HOOD, OPERATION_MODE_FIREPLACE, OPERATION_MODE_HIGH, OPERATION_MODE_HOME, OPERATION_MODE_OFF, + OPERATION_MODE_TEMPORARY_HIGH, VENTILATION_MODE_AWAY, VENTILATION_MODE_HIGH, VENTILATION_MODE_HOME, @@ -28,7 +30,9 @@ OPERATION_MODE_AWAY: PRESET_AWAY, OPERATION_MODE_HOME: PRESET_HOME, OPERATION_MODE_HIGH: PRESET_HIGH, + OPERATION_MODE_COOKER_HOOD: PRESET_HIGH, OPERATION_MODE_FIREPLACE: PRESET_FIREPLACE, + OPERATION_MODE_TEMPORARY_HIGH: PRESET_HIGH, } # Map preset to ventilation mode (for setting standard modes) diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 6eb82df0b9e7f..4b99faf790f9e 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -4,6 +4,8 @@ from unittest.mock import AsyncMock from flexit_bacnet import ( + OPERATION_MODE_COOKER_HOOD, + OPERATION_MODE_TEMPORARY_HIGH, VENTILATION_MODE_AWAY, VENTILATION_MODE_HOME, VENTILATION_MODE_STOP, @@ -24,7 +26,10 @@ HVACAction, HVACMode, ) -from homeassistant.components.flexit_bacnet.const import PRESET_TO_VENTILATION_MODE_MAP +from homeassistant.components.flexit_bacnet.const import ( + PRESET_HIGH, + PRESET_TO_VENTILATION_MODE_MAP, +) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -223,3 +228,27 @@ async def test_set_temperature( }, blocking=True, ) + + +@pytest.mark.parametrize( + ("operation_mode", "expected_preset"), + [ + pytest.param(OPERATION_MODE_TEMPORARY_HIGH, PRESET_HIGH, id="temporary_high"), + pytest.param(OPERATION_MODE_COOKER_HOOD, PRESET_HIGH, id="cooker_hood"), + ], +) +async def test_transient_operation_modes( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, + operation_mode: int, + expected_preset: str, +) -> None: + """Test that transient operation modes report the correct preset.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_flexit_bacnet.operation_mode = operation_mode + await entity_component.async_update_entity(hass, ENTITY_ID) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_PRESET_MODE] == expected_preset From 5f9872886da06ed8c8de4b5f889a7fbd73627103 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 May 2026 23:13:08 +0200 Subject: [PATCH 042/153] Convert Roomba hw_version to string for device registry (#172497) --- homeassistant/components/roomba/entity.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index aaa2c89637361..3f4559f2188ef 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -29,7 +29,11 @@ def __init__(self, roomba, blid) -> None: model=self.vacuum_state.get("sku"), name=str(self.vacuum_state.get("name")), sw_version=self.vacuum_state.get("softwareVer"), - hw_version=self.vacuum_state.get("hardwareRev"), + hw_version=( + str(hw_rev) + if (hw_rev := self.vacuum_state.get("hardwareRev")) is not None + else None + ), ) if mac_address := self.vacuum_state.get("hwPartsRev", {}).get( From 2776e966ff30d2067132f68f7ada0a365d7530d6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 May 2026 02:18:14 +0200 Subject: [PATCH 043/153] Reduce Wyoming satellite disconnect log to debug level (#172499) --- homeassistant/components/wyoming/assist_satellite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 546edea009cef..c2edf1ed8f63a 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -468,7 +468,7 @@ async def run(self) -> None: async def on_restart(self) -> None: """Block until pipeline loop will be restarted.""" - _LOGGER.warning( + _LOGGER.debug( "Satellite has been disconnected. Reconnecting in %s second(s)", _RECONNECT_SECONDS, ) From 57259132d99cdc701cc46c9d3818ba3122222a52 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 29 May 2026 17:33:11 +0200 Subject: [PATCH 044/153] Silent migrate MQTT protocol version to version 5 if the broker supports it or raise an issue (#172500) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mqtt/__init__.py | 63 ++++-- homeassistant/components/mqtt/client.py | 37 ++++ homeassistant/components/mqtt/config_flow.py | 40 +--- homeassistant/components/mqtt/repairs.py | 57 +----- homeassistant/components/mqtt/strings.json | 16 +- tests/components/mqtt/conftest.py | 20 +- tests/components/mqtt/test_client.py | 15 +- tests/components/mqtt/test_config_flow.py | 4 +- tests/components/mqtt/test_init.py | 125 ++++++++++++ tests/components/mqtt/test_repairs.py | 194 +------------------ 10 files changed, 246 insertions(+), 325 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 1592895936dac..72f8966ad7189 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_DISCOVERY, CONF_PLATFORM, + CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD, ) @@ -50,6 +51,7 @@ async_subscribe_internal, publish, subscribe, + try_connection, ) from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA from .config_integration import CONFIG_SCHEMA_BASE @@ -79,14 +81,15 @@ CONFIG_ENTRY_VERSION, DEFAULT_DISCOVERY, DEFAULT_ENCODING, + DEFAULT_PORT, DEFAULT_PREFIX, - DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, + PROTOCOL_5, PROTOCOL_311, TEMPLATE_ERRORS, Platform, @@ -496,25 +499,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" mqtt_data: MqttData - if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL: - broker: str = entry.data[CONF_BROKER] - async_create_issue( - hass, - DOMAIN, - "protocol_5_migration", - issue_domain=DOMAIN, - is_fixable=True, - breaks_in_ha_version="2027.1.0", - severity=IssueSeverity.WARNING, - learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol", - data={ - "entry_id": entry.entry_id, - "broker": broker, - "protocol": protocol, - }, - translation_placeholders={"broker": broker, "protocol": protocol}, - translation_key="protocol_5_migration", - ) + if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != PROTOCOL_5: + # Automatically migrate the broker protocol to v5 if possible + # Can be removed with HA Core 2027.1 + new_entry_data = entry.data.copy() + new_entry_data[CONF_PROTOCOL] = PROTOCOL_5 + # Try the connection with protocol version 5 + # And update the protocol if successful + if await hass.async_add_executor_job( + try_connection, + {CONF_PORT: DEFAULT_PORT} | new_entry_data, + ): + hass.config_entries.async_update_entry( + entry, + data=new_entry_data, + ) + ir.async_delete_issue(hass, DOMAIN, "protocol_5_migration") + _LOGGER.info( + "The MQTT protocol version was successfully updated to version 5" + ) + else: + broker: str = entry.data[CONF_BROKER] + async_create_issue( + hass, + DOMAIN, + "protocol_5_migration", + issue_domain=DOMAIN, + is_fixable=False, + breaks_in_ha_version="2027.1.0", + severity=IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/integrations/mqtt/" + "#mqtt-protocol", + data={ + "entry_id": entry.entry_id, + "broker": broker, + "protocol": protocol, + }, + translation_placeholders={"broker": broker, "protocol": protocol}, + translation_key="protocol_5_migration", + ) async def _setup_client() -> tuple[MqttData, dict[str, Any]]: """Set up the MQTT client.""" diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index cc60d489b906e..338bebd897c44 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -9,6 +9,7 @@ from itertools import chain, groupby import logging from operator import attrgetter +import queue import socket import ssl import time @@ -92,6 +93,8 @@ _LOGGER = logging.getLogger(__name__) +MQTT_TIMEOUT = 5 + MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB @@ -433,6 +436,40 @@ def client(self) -> AsyncMQTTClient: return self._client +def try_connection( + user_input: dict[str, Any], +) -> bool: + """Test if we can connect to an MQTT broker.""" + mqtt_client_setup = MqttClientSetup(user_input) + mqtt_client_setup.setup() + client = mqtt_client_setup.client + + result: queue.Queue[bool] = queue.Queue(maxsize=1) + + def on_connect( + _mqttc: mqtt.Client, + _userdata: None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, + ) -> None: + """Handle connection result.""" + result.put(not reason_code.is_failure) + + client.on_connect = on_connect + + client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT]) + client.loop_start() + + try: + return result.get(timeout=MQTT_TIMEOUT) + except queue.Empty: + return False + finally: + client.disconnect() + client.loop_stop() + + class MQTT: """Home Assistant MQTT client.""" diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 2e9993867de13..6bfadb63155a1 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -8,7 +8,6 @@ from enum import IntEnum import json import logging -import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast @@ -22,7 +21,6 @@ load_pem_private_key, ) from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate -import paho.mqtt.client as mqtt import voluptuous as vol import yaml @@ -143,7 +141,7 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .addon import get_addon_manager -from .client import MqttClientSetup +from .client import try_connection from .const import ( ALARM_CONTROL_PANEL_SUPPORTED_FEATURES, ATTR_PAYLOAD, @@ -444,8 +442,6 @@ CONF_CLIENT_KEY_PASSWORD = "client_key_password" -MQTT_TIMEOUT = 5 - ADVANCED_OPTIONS = "advanced_options" SET_CA_CERT = "set_ca_cert" SET_CLIENT_CERT = "set_client_cert" @@ -5581,40 +5577,6 @@ async def _async_validate_broker_settings( return False -def try_connection( - user_input: dict[str, Any], -) -> bool: - """Test if we can connect to an MQTT broker.""" - mqtt_client_setup = MqttClientSetup(user_input) - mqtt_client_setup.setup() - client = mqtt_client_setup.client - - result: queue.Queue[bool] = queue.Queue(maxsize=1) - - def on_connect( - _mqttc: mqtt.Client, - _userdata: None, - _connect_flags: mqtt.ConnectFlags, - reason_code: mqtt.ReasonCode, - _properties: mqtt.Properties | None = None, - ) -> None: - """Handle connection result.""" - result.put(not reason_code.is_failure) - - client.on_connect = on_connect - - client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT]) - client.loop_start() - - try: - return result.get(timeout=MQTT_TIMEOUT) - except queue.Empty: - return False - finally: - client.disconnect() - client.loop_stop() - - def check_certicate_chain() -> str | None: """Check the MQTT certificates.""" if client_certificate := get_file_path(CONF_CLIENT_CERT): diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py index 38ac997d073e8..75dfac16cf662 100644 --- a/homeassistant/components/mqtt/repairs.py +++ b/homeassistant/components/mqtt/repairs.py @@ -5,12 +5,10 @@ import voluptuous as vol from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult -from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .config_flow import try_connection -from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5 +from .const import DOMAIN URL_MQTT_BROKER_CONFIGURATION = ( "https://www.home-assistant.io/integrations/mqtt/#broker-configuration" @@ -55,55 +53,6 @@ async def async_step_confirm( ) -class MQTTProtocolV5Migration(RepairsFlow): - """Handler to migrate to MQTT protocol version 5.""" - - def __init__(self, entry_id: str, broker: str, protocol: str) -> None: - """Initialize the flow.""" - self.entry_id = entry_id - self.broker = broker - self.protocol = protocol - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> RepairsFlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> RepairsFlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is not None: - entry = self.hass.config_entries.async_get_entry(self.entry_id) - if TYPE_CHECKING: - assert entry is not None - new_entry_data = entry.data.copy() - new_entry_data[CONF_PROTOCOL] = PROTOCOL_5 - # Try the connection with protocol version 5 - if await self.hass.async_add_executor_job( - try_connection, - {CONF_PORT: DEFAULT_PORT} | new_entry_data, - ): - self.hass.config_entries.async_update_entry(entry, data=new_entry_data) - return self.async_create_entry(data={}) - - return self.async_abort( - reason="mqtt_broker_migration_to_v5_failed", - description_placeholders={ - "broker": self.broker, - "protocol": self.protocol, - "url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION, - }, - ) - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders={"broker": self.broker, "protocol": self.protocol}, - ) - - async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -113,10 +62,6 @@ async def async_create_fix_flow( if TYPE_CHECKING: assert data is not None entry_id: str = data["entry_id"] # type: ignore[assignment] - if issue_id == "protocol_5_migration": - broker: str = data["broker"] # type: ignore[assignment] - protocol: str = data["protocol"] # type: ignore[assignment] - return MQTTProtocolV5Migration(entry_id, broker, protocol) subentry_id: str = data["subentry_id"] # type: ignore[assignment] name: str = data["name"] # type: ignore[assignment] return MQTTDeviceEntryMigration( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 7cd3fc1955583..730a039163a1a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -56,7 +56,7 @@ "keepalive": "A value less than 90 seconds is advised.", "password": "The password to log in to your MQTT broker.", "port": "The port your MQTT broker listens to. For example 1883.", - "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", + "protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.", "set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.", "set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", @@ -1134,18 +1134,8 @@ "title": "Invalid config found for MQTT {domain} item" }, "protocol_5_migration": { - "fix_flow": { - "abort": { - "mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue." - }, - "step": { - "confirm": { - "description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.", - "title": "MQTT protocol change required" - } - } - }, - "title": "Deprecated MQTT protocol {protocol} in use" + "description": "The automatic migration to MQTT protocol version 5 failed. The currently configured protocol version for MQTT broker {broker} is {protocol}, but this protocol version is deprecated, and support for it will be removed.\n\nMake sure your broker supports protocol version 5. Update your MQTT broker's connection settings, and restart Home Assistant to fix this issue.", + "title": "MQTT protocol migration failed" }, "subentry_migration_discovery": { "fix_flow": { diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index efe5d0f1a4e39..b0d2c42803bd4 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -5,7 +5,7 @@ from pathlib import Path from random import getrandbits from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -33,6 +33,24 @@ def patch_hass_config(mock_hass_config: None) -> None: """Patch configuration.yaml.""" +@pytest.fixture +def mock_v5_protocol_check() -> bool: + """Fixture to mock a v5 protocol test result.""" + return True + + +@pytest.fixture(autouse=True) +def mock_try_connection_protocol_check( + hass: HomeAssistant, mock_v5_protocol_check: bool +) -> Generator[MagicMock]: + """Patch try_connection.""" + with patch( + "homeassistant.components.mqtt.try_connection", + return_value=mock_v5_protocol_check, + ) as mock_try_connection: + yield mock_try_connection + + @pytest.fixture def temp_dir_prefix() -> str: """Set an alternate temp dir prefix.""" diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index a99963372bc25..046e3bb155815 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -230,6 +230,7 @@ async def test_publish( assert publish_mock.call_args[0][4].json() == {"MessageExpiryInterval": 60} +@pytest.mark.parametrize("mock_v5_protocol_check", [False]) @pytest.mark.parametrize( ("mqtt_config_entry_options", "mqtt_config_entry_data", "protocol"), [ @@ -1257,7 +1258,12 @@ async def test_restore_subscriptions_on_reconnect( @pytest.mark.parametrize( ("mqtt_config_entry_data", "mqtt_config_entry_options"), - [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], + [ + ( + {mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_PROTOCOL: "5"}, + {mqtt.CONF_DISCOVERY: False}, + ) + ], ) async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1277,7 +1283,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( # the subscription with the highest QoS should survive expected = [ - call([("test/state", 2)], properties=None), + call([("test/state", 2)], properties=ANY), ] assert mqtt_client_mock.subscribe.mock_calls == expected @@ -1291,7 +1297,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( # wait for cooldown await mock_debouncer.wait() - expected.append(call([("test/state", 1)], properties=None)) + expected.append(call([("test/state", 1)], properties=ANY)) for expected_call in expected: assert mqtt_client_mock.subscribe.hass_call(expected_call) @@ -1549,6 +1555,7 @@ def _callback(args) -> None: assert callbacks[0].payload == "test-payload" +@pytest.mark.parametrize("mock_v5_protocol_check", [False]) @pytest.mark.parametrize( ("mqtt_config_entry_data", "protocol", "clean_session"), [ @@ -1582,7 +1589,6 @@ def _callback(args) -> None: async def test_setup_mqtt_client_clean_session_and_protocol( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - mqtt_client_mock: MqttMockPahoClient, protocol: int, clean_session: bool | None, ) -> None: @@ -1597,6 +1603,7 @@ async def test_setup_mqtt_client_clean_session_and_protocol( assert mock_client.call_args[1]["protocol"] == protocol +@pytest.mark.parametrize("mock_v5_protocol_check", [False]) @pytest.mark.parametrize( ("mqtt_config_entry_data", "connect_args"), [ diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ac99acc5c82b3..eec430050455d 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -130,6 +130,7 @@ MOCK_ENTRY_DATA = { mqtt.CONF_BROKER: "test-broker", + CONF_PROTOCOL: "5", CONF_PORT: 1234, CONF_USERNAME: "user", CONF_PASSWORD: "pass", @@ -273,7 +274,7 @@ def mock_try_connection_time_out() -> Generator[MagicMock]: # Patch prevent waiting 5 sec for a timeout with ( patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client, - patch("homeassistant.components.mqtt.config_flow.MQTT_TIMEOUT", 0), + patch("homeassistant.components.mqtt.client.MQTT_TIMEOUT", 0), ): mock_client().loop_start = lambda *args: 1 yield mock_client() @@ -1756,6 +1757,7 @@ async def test_step_hassio_reauth( mock_try_connection.assert_called_once_with( { "broker": "core-mosquitto", + CONF_PROTOCOL: "5", "port": 1883, "username": "mock-user", "password": "mock-pass", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a9e19494e3197..9335af092a3e2 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from functools import partial import json +import logging import time from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, Mock, mock_open, patch @@ -28,6 +29,7 @@ from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import ( ATTR_ASSUMED_STATE, + CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD, STATE_UNAVAILABLE, @@ -53,6 +55,7 @@ MockEntity, MockEntityPlatform, MockMqttReasonCode, + async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -2470,3 +2473,125 @@ async def test_yaml_config_with_active_mqtt_config_entry( state = hass.states.get("sensor.mqtt_sensor") assert state is not None assert issue is None + + +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + }, + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PORT: 1883, + }, + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1.1", + CONF_PORT: 1883, + }, + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1", + CONF_PORT: 1883, + }, + ], + ids=[ + "entry_without_protocol_without_port", + "entry_without_protocol_with_port", + "entry_with_protocol_3.1.1", + "entry_with_protocol_3.1", + ], +) +async def test_mqtt_protocol_successful_migration_to_v5( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the silent MQTT protocol migration is successful.""" + assert await async_setup_component(hass, "repairs", {}) + + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + + with caplog.at_level(logging.INFO): + await mqtt_mock_entry() + assert len(events) == 0 + assert ( + "The MQTT protocol version was successfully updated to version 5" + in caplog.text + ) + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + assert entry.data[mqtt.CONF_PROTOCOL] == mqtt.PROTOCOL_5 + + +@pytest.mark.parametrize("mock_v5_protocol_check", [False]) +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "current_protocol"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PORT: 1883, + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1.1", + CONF_PORT: 1883, + }, + "3.1.1", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1", + CONF_PORT: 1883, + }, + "3.1", + ), + ], + ids=[ + "entry_without_protocol_without_port", + "entry_without_protocol_with_port", + "entry_with_protocol_3.1.1", + "entry_with_protocol_3.1", + ], +) +async def test_mqtt_protocol_failed_migration_to_v5( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + current_protocol: str, +) -> None: + """Test failed silent MQTT protocol migration creates a repair issue.""" + assert await async_setup_component(hass, "repairs", {}) + + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + + await mqtt_mock_entry() + assert len(events) == 1 + assert events[0].data["issue_id"] == "protocol_5_migration" + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration") + assert issue is not None + assert issue.translation_key == "protocol_5_migration" + assert issue.translation_placeholders == { + "broker": "mock-broker", + "protocol": current_protocol, + } + assert ( + "The MQTT protocol version was successfully updated to version 5" + not in caplog.text + ) + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + assert entry.data.get(mqtt.CONF_PROTOCOL, mqtt.PROTOCOL_311) == current_protocol diff --git a/tests/components/mqtt/test_repairs.py b/tests/components/mqtt/test_repairs.py index b7da34c445019..bc7b9dd429459 100644 --- a/tests/components/mqtt/test_repairs.py +++ b/tests/components/mqtt/test_repairs.py @@ -1,19 +1,18 @@ """Test repairs for MQTT.""" -from collections.abc import Coroutine, Generator +from collections.abc import Coroutine from copy import deepcopy from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from homeassistant.components import mqtt from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData -from homeassistant.const import CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, issue_registry as ir -from homeassistant.setup import async_setup_component from homeassistant.util.yaml import parse_yaml from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message @@ -28,13 +27,6 @@ from tests.typing import MqttMockHAClientGenerator -@pytest.fixture -def mock_try_connection() -> Generator[MagicMock]: - """Mock the try connection method.""" - with patch("homeassistant.components.mqtt.repairs.try_connection") as mock_try: - yield mock_try - - async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None: """Help to set up an exported MQTT device via YAML.""" with patch( @@ -185,183 +177,3 @@ async def test_subentry_reconfigure_export_settings( device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) assert device.config_entries_subentries[config_entry.entry_id] == {None} assert device is not None - - -@pytest.mark.parametrize( - ("mqtt_config_entry_data", "current_protocol"), - [ - ( - { - mqtt.CONF_BROKER: "mock-broker", - }, - "3.1.1", - ), - ( - { - mqtt.CONF_BROKER: "mock-broker", - CONF_PORT: 1883, - }, - "3.1.1", - ), - ( - { - mqtt.CONF_BROKER: "mock-broker", - CONF_PROTOCOL: "3.1.1", - CONF_PORT: 1883, - }, - "3.1.1", - ), - ( - { - mqtt.CONF_BROKER: "mock-broker", - CONF_PROTOCOL: "3.1", - CONF_PORT: 1883, - }, - "3.1", - ), - ], - ids=[ - "entry_without_protocol_without_port", - "entry_without_protocol_with_port", - "entry_with_protocol_3.1.1", - "entry_with_protocol_3.1", - ], -) -async def test_mqtt_protocol_successful_migration_to_v5( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - hass_client: ClientSessionGenerator, - mqtt_config_entry_data: dict[str, Any], - current_protocol: str, - mock_try_connection: MagicMock, -) -> None: - """Test the MQTT protocol migration repair flow is successful.""" - assert await async_setup_component(hass, "repairs", {}) - - events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - - await mqtt_mock_entry() - assert len(events) == 1 - assert events[0].data["issue_id"] == "protocol_5_migration" - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration") - assert issue is not None - assert issue.translation_key == "protocol_5_migration" - assert issue.translation_placeholders == { - "broker": "mock-broker", - "protocol": current_protocol, - } - - await async_process_repairs_platforms(hass) - client = await hass_client() - - data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration") - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "broker": "mock-broker", - "protocol": current_protocol, - } - assert data["step_id"] == "confirm" - - mock_try_connection.side_effect = lambda x: True - data = await process_repair_fix_flow(client, flow_id) - assert data["type"] == "create_entry" - expected_entry_data: dict[str, Any] = mqtt_config_entry_data | {CONF_PROTOCOL: "5"} - mock_try_connection.assert_called_once_with(expected_entry_data | {CONF_PORT: 1883}) - entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - assert entry.data == expected_entry_data - await hass.async_block_till_done(wait_background_tasks=True) - - -@pytest.mark.parametrize( - ("mqtt_config_entry_data", "current_protocol"), - [ - ( - { - mqtt.CONF_BROKER: "mock-broker", - }, - "3.1.1", - ), - ( - { - mqtt.CONF_BROKER: "mock-broker", - CONF_PORT: 1883, - }, - "3.1.1", - ), - ( - { - mqtt.CONF_BROKER: "mock-broker", - CONF_PROTOCOL: "3.1.1", - CONF_PORT: 1883, - }, - "3.1.1", - ), - ( - { - mqtt.CONF_BROKER: "mock-broker", - CONF_PROTOCOL: "3.1", - CONF_PORT: 1883, - }, - "3.1", - ), - ], - ids=[ - "entry_without_protocol_without_port", - "entry_without_protocol_with_port", - "entry_with_protocol_3.1.1", - "entry_with_protocol_3.1", - ], -) -async def test_mqtt_protocol_failed_migration_to_v5( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - hass_client: ClientSessionGenerator, - current_protocol: str, - mock_try_connection: MagicMock, -) -> None: - """Test the MQTT protocol migration repair flow fails.""" - assert await async_setup_component(hass, "repairs", {}) - - events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - - await mqtt_mock_entry() - assert len(events) == 1 - assert events[0].data["issue_id"] == "protocol_5_migration" - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration") - assert issue is not None - assert issue.translation_key == "protocol_5_migration" - assert issue.translation_placeholders == { - "broker": "mock-broker", - "protocol": current_protocol, - } - - await async_process_repairs_platforms(hass) - client = await hass_client() - - data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration") - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "broker": "mock-broker", - "protocol": current_protocol, - } - assert data["step_id"] == "confirm" - - mock_try_connection.side_effect = lambda x: False - data = await process_repair_fix_flow(client, flow_id) - assert data["type"] == "abort" - assert data["reason"] == "mqtt_broker_migration_to_v5_failed" - assert data["description_placeholders"] == { - "broker": "mock-broker", - "protocol": current_protocol, - "url_mqtt_broker_configuration": "https://www.home-assistant.io/integrations/mqtt/#broker-configuration", - } - - await hass.async_block_till_done(wait_background_tasks=True) From b61559bdbb9d1650d21862b3bc082899318056ee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 12:58:03 +0200 Subject: [PATCH 045/153] Handle malformed response errors in Denon AVR error wrapper (#172502) --- .../components/denonavr/media_player.py | 13 ++++++ .../components/denonavr/test_media_player.py | 44 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index ee02e7390ebcf..cf697b993cec8 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -20,6 +20,8 @@ from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, + AvrIncompleteResponseError, + AvrInvalidResponseError, AvrNetworkError, AvrProcessingError, AvrTimoutError, @@ -191,6 +193,17 @@ async def wrapper( self._receiver.host, ) self._attr_available = False + except AvrInvalidResponseError, AvrIncompleteResponseError: + available = False + if self.available: + _LOGGER.warning( + ( + "Denon AVR receiver at host %s returned malformed response. " + "Device is unavailable" + ), + self._receiver.host, + ) + self._attr_available = False except AvrCommandError as err: available = False _LOGGER.error( diff --git a/tests/components/denonavr/test_media_player.py b/tests/components/denonavr/test_media_player.py index 24b12c085074b..662334a96968d 100644 --- a/tests/components/denonavr/test_media_player.py +++ b/tests/components/denonavr/test_media_player.py @@ -1,7 +1,10 @@ """The tests for the denonavr media player platform.""" +from datetime import timedelta from unittest.mock import patch +from denonavr.exceptions import AvrIncompleteResponseError, AvrInvalidResponseError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import media_player @@ -18,10 +21,10 @@ SERVICE_SET_DYNAMIC_EQ, SERVICE_UPDATE_AUDYSSEY, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MODEL +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MODEL, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed TEST_HOST = "1.2.3.4" TEST_NAME = "Test_Receiver" @@ -137,3 +140,40 @@ async def test_update_audyssey(hass: HomeAssistant, client) -> None: await hass.async_block_till_done() client.async_update_audyssey.assert_called_once() + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param( + AvrInvalidResponseError("XML parse error", "GET"), + id="invalid_response", + ), + pytest.param( + AvrIncompleteResponseError("Incomplete", "GET"), + id="incomplete_response", + ), + ], +) +async def test_malformed_response_marks_unavailable( + hass: HomeAssistant, + client, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test that malformed response errors mark the entity unavailable.""" + await setup_denonavr(hass) + + state = hass.states.get(ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + # Force polling by disabling telnet, then trigger the error + client.telnet_connected = False + client.telnet_healthy = False + client.async_update.side_effect = exception + freezer.tick(timedelta(seconds=11)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE From 26cf728165f7042bc6d82e85520836706004a802 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 11:42:46 +0200 Subject: [PATCH 046/153] Handle missing notAfter field in cert_expiry certificate data (#172503) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../components/cert_expiry/helper.py | 5 ++ tests/components/cert_expiry/test_sensor.py | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index eda6937c5dd28..ed0ac62b44d3d 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -66,5 +66,10 @@ async def get_cert_expiry_timestamp( except ssl.SSLError as err: raise ValidationFailure(err.args[0]) from err + if not cert or "notAfter" not in cert: + raise ValidationFailure( + f"No certificate expiration found for: {hostname}:{port}" + ) + ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) return dt_util.utc_from_timestamp(ts_seconds) diff --git a/tests/components/cert_expiry/test_sensor.py b/tests/components/cert_expiry/test_sensor.py index 002f32e772ccc..009cce247af49 100644 --- a/tests/components/cert_expiry/test_sensor.py +++ b/tests/components/cert_expiry/test_sensor.py @@ -286,3 +286,55 @@ async def test_error_attribute_is_none_when_cert_valid(hass: HomeAssistant) -> N state = hass.states.get("sensor.example_com_cert_expiry") assert state.attributes.get("error") is None assert state.attributes.get("is_valid") is True + + +async def test_async_setup_entry_empty_cert(hass: HomeAssistant) -> None: + """Test setup when peer certificate is empty (e.g. verify_mode=CERT_NONE).""" + assert hass.state is CoreState.running + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ) + + with patch( + "homeassistant.components.cert_expiry.helper.async_get_cert", + return_value={}, + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.example_com_cert_expiry") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.attributes.get("error") is not None + assert not state.attributes.get("is_valid") + + +async def test_non_default_port(hass: HomeAssistant) -> None: + """Test sensor naming and unique_id when a non-default port is used.""" + assert hass.state is CoreState.running + + non_default_port = 8443 + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: non_default_port}, + unique_id=f"{HOST}:{non_default_port}", + ) + + timestamp = future_timestamp(100) + + with patch( + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", + return_value=timestamp, + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.example_com_8443_cert_expiry") + assert state is not None + assert state.state == timestamp.isoformat() + assert entry.unique_id == f"{HOST}:{non_default_port}" From 589d2637c9608f9e9b0a27d18cbed584dbe7067a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 11:32:01 +0200 Subject: [PATCH 047/153] Fix ephember crash when zone mode is None (#172504) --- homeassistant/components/ephember/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 07e68ba40abff..af64e36b19936 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -196,4 +196,6 @@ def map_mode_hass_eph(operation_mode): @staticmethod def map_mode_eph_hass(operation_mode): """Map from eph mode to Home Assistant mode.""" + if operation_mode is None: + return HVACMode.HEAT_COOL return EPH_TO_HA_STATE.get(operation_mode.name, HVACMode.HEAT_COOL) From 8ec3eac705cff552be417a916bb747f4ddfcc1d0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 12:38:02 +0200 Subject: [PATCH 048/153] Fix Overkiz UnoIO cover reporting wrong movement direction (#172506) --- homeassistant/components/overkiz/cover.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 5b871b985b74e..3fb405d04bac7 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -854,6 +854,9 @@ def moving_offset(self) -> int | None: if current_value is None or target_value is None: return None + if current_value in (_POSITION_MY, _POSITION_UNKNOWN): + return None + return current_value - target_value From c95a39c26e64efe14a7d5d2f7ef3e86dda07beb4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 May 2026 08:12:25 +0200 Subject: [PATCH 049/153] Guard Shelly repairs checks for uninitialized RPC devices (#172509) --- homeassistant/components/shelly/repairs.py | 6 ++++++ tests/components/shelly/test_repairs.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index 7e3161ebde56a..479c29108a3aa 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -132,6 +132,9 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue( device = entry.runtime_data.rpc.device + if not device.initialized: + return + if ( (ws_config := device.config.get("ws")) and ws_config["enable"] @@ -169,6 +172,9 @@ def async_manage_open_wifi_ap_issue( device = entry.runtime_data.rpc.device + if not device.initialized: + return + # Check if WiFi AP is enabled and is open (no password) if ( (wifi_config := device.config.get("wifi")) diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index d95b1846f3ec3..9232530ef0df0 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch from aioshelly.const import MODEL_PLUG, MODEL_WALL_DISPLAY -from aioshelly.exceptions import DeviceConnectionError, RpcCallError +from aioshelly.exceptions import DeviceConnectionError, NotInitialized, RpcCallError import pytest from homeassistant.components.shelly.const import ( @@ -179,6 +179,22 @@ async def test_outbound_websocket_incorrectly_enabled_issue( assert len(issue_registry.issues) == 0 +async def test_repairs_skipped_when_device_not_initialized( + hass: HomeAssistant, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair checks are skipped when the RPC device is not initialized.""" + mock_rpc_device.initialized = False + type(mock_rpc_device).config = property( + lambda self: (_ for _ in ()).throw(NotInitialized) + ) + + await init_integration(hass, 2) + + assert len(issue_registry.issues) == 0 + + @pytest.mark.parametrize( "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] ) From 504e22ee3eec78c0bf72c2b052684aaf598abad5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 11:05:07 +0200 Subject: [PATCH 050/153] Raise errors instead of swallowing exceptions in Toon action handlers (#172511) --- homeassistant/components/toon/climate.py | 2 -- homeassistant/components/toon/helpers.py | 20 ++++++++++++-------- homeassistant/components/toon/strings.json | 6 ++++++ homeassistant/components/toon/switch.py | 4 ---- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 1ae2a9425f9c0..b5dded48c20dd 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -102,14 +102,12 @@ def extra_state_attributes(self) -> dict[str, Any]: """Return the current state of the burner.""" return {"heating_type": self.coordinator.data.agreement.heating_type} - # pylint: disable-next=home-assistant-action-swallowed-exception @toon_exception_handler async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the thermostat.""" temperature = kwargs.get(ATTR_TEMPERATURE) await self.coordinator.toon.set_current_setpoint(temperature) - # pylint: disable-next=home-assistant-action-swallowed-exception @toon_exception_handler async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index 74e5368f1283a..97e4606fb046a 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -1,14 +1,14 @@ """Helpers for Toon.""" from collections.abc import Callable, Coroutine -import logging from typing import Any, Concatenate from toonapi import ToonConnectionError, ToonError -from .entity import ToonEntity +from homeassistant.exceptions import HomeAssistantError -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .entity import ToonEntity def toon_exception_handler[_ToonEntityT: ToonEntity, **_P]( @@ -17,20 +17,24 @@ def toon_exception_handler[_ToonEntityT: ToonEntity, **_P]( """Decorate Toon calls to handle Toon exceptions. A decorator that wraps the passed in function, catches Toon errors, - and handles the availability of the device in the data coordinator. + and raises a translated ``HomeAssistantError``. """ async def handler(self: _ToonEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) self.coordinator.async_update_listeners() - except ToonConnectionError as error: - _LOGGER.error("Error communicating with API: %s", error) self.coordinator.last_update_success = False self.coordinator.async_update_listeners() - + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error except ToonError as error: - _LOGGER.error("Invalid response from API: %s", error) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_response", + ) from error return handler diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 809262e939074..3aeca0734aeaa 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -34,6 +34,12 @@ } }, "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Toon device." + }, + "invalid_response": { + "message": "Received an invalid response from the Toon device." + }, "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index 49cfc9a9eb886..c941a2a45f0f5 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -60,7 +60,6 @@ def is_on(self) -> bool: class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity): """Defines a Toon program switch.""" - # pylint: disable-next=home-assistant-action-swallowed-exception @toon_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Toon program switch.""" @@ -68,7 +67,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: ACTIVE_STATE_AWAY, PROGRAM_STATE_OFF ) - # pylint: disable-next=home-assistant-action-swallowed-exception @toon_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Toon program switch.""" @@ -80,7 +78,6 @@ async def async_turn_on(self, **kwargs: Any) -> None: class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): """Defines a Toon Holiday mode switch.""" - # pylint: disable-next=home-assistant-action-swallowed-exception @toon_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Toon holiday mode switch.""" @@ -88,7 +85,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: ACTIVE_STATE_AWAY, PROGRAM_STATE_ON ) - # pylint: disable-next=home-assistant-action-swallowed-exception @toon_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Toon holiday mode switch.""" From 2d74091a36e1b42f8cff4475c4a98abcc328ac39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Sun, 31 May 2026 11:55:30 +0200 Subject: [PATCH 051/153] Refresh WLED firmware releases on manual entity update (#172517) Co-authored-by: Claude Haiku 4.5 --- homeassistant/components/wled/update.py | 5 +++ tests/components/wled/test_update.py | 47 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index dd68b8cd6a816..c523872fa403a 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -112,3 +112,8 @@ async def async_install( version = cast(str, self.latest_version) await self.coordinator.wled.upgrade(version=version) await self.coordinator.async_refresh() + + async def async_update(self) -> None: + """Update the entity.""" + await super().async_update() + await self.releases_coordinator.async_request_refresh() diff --git a/tests/components/wled/test_update.py b/tests/components/wled/test_update.py index dc0f409ac7d61..c486230139a45 100644 --- a/tests/components/wled/test_update.py +++ b/tests/components/wled/test_update.py @@ -8,6 +8,10 @@ from syrupy.assertion import SnapshotAssertion from wled import Releases, WLEDError +from homeassistant.components.homeassistant import ( + DOMAIN as HOME_ASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.update import ( ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, @@ -25,6 +29,8 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -186,3 +192,44 @@ async def test_update_stay_beta( ) assert mock_wled.upgrade.call_count == 1 mock_wled.upgrade.assert_called_with(version="1.0.0b5") + + +async def test_update_entities( + hass: HomeAssistant, + mock_wled: MagicMock, + mock_wled_releases: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test update entity async_update method.""" + await async_setup_component(hass, HOME_ASSISTANT_DOMAIN, {}) + + assert (state := hass.states.get("update.wled_rgb_light_firmware")) + assert state.state == STATE_ON + assert state.attributes[ATTR_LATEST_VERSION] == "0.99.0" + mock_wled_releases.releases.assert_called_once() + + mock_wled_releases.releases.return_value = Releases( + beta="1.0.0b5", + nightly=None, + repo="wled/WLED", + stable="16.0.0", + ) + + await hass.services.async_call( + HOME_ASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + # Ensure we pass the debouncer interval to allow async_request_refresh to execute + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # releases() should be called twice: once on setup, once for the manual update + assert mock_wled_releases.releases.call_count == 2 + + assert (state := hass.states.get("update.wled_rgb_light_firmware")) + assert state.state == STATE_ON + assert state.attributes[ATTR_LATEST_VERSION] == "16.0.0" From 4b517fb164ad4e26411faaaa9d520bc0ec848d09 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 May 2026 11:17:00 +0200 Subject: [PATCH 052/153] Use state-based icon for Hue grouped light (#172535) --- homeassistant/components/hue/icons.json | 6 ++++++ homeassistant/components/hue/v2/group.py | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json index 33a129a1aa465..92079afce589e 100644 --- a/homeassistant/components/hue/icons.json +++ b/homeassistant/components/hue/icons.json @@ -1,6 +1,12 @@ { "entity": { "light": { + "hue_grouped_light": { + "default": "mdi:lightbulb-group", + "state": { + "off": "mdi:lightbulb-group-off" + } + }, "hue_light": { "state_attributes": { "effect": { diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 2471db9df64ee..797fab8fd14cc 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -85,7 +85,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity): entity_description = LightEntityDescription( key="hue_grouped_light", - icon="mdi:lightbulb-group", has_entity_name=True, name=None, ) From 796d82d6edadaa46eaaf88599fa39947a830fee9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 May 2026 11:17:36 +0200 Subject: [PATCH 053/153] Add missing ssdp dependency to BraviaTV manifest (#172536) --- homeassistant/components/braviatv/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index 194b14512e7d5..0c8f90ac3d7a7 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -3,6 +3,7 @@ "name": "Sony Bravia TV", "codeowners": ["@bieniu", "@Drafteed"], "config_flow": true, + "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/braviatv", "integration_type": "device", "iot_class": "local_polling", From 25f9cd9ab842cfe7e8318aa1df509f999495db4b Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 29 May 2026 10:30:52 +0200 Subject: [PATCH 054/153] Fix Yoto OAuth flow with cloud credentials (#172544) Co-authored-by: Joost Lekkerkerker --- .../yoto/application_credentials.py | 20 +++---------------- homeassistant/components/yoto/config_flow.py | 10 +++++++++- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/yoto/application_credentials.py b/homeassistant/components/yoto/application_credentials.py index 4622d561456df..816ba0b621ad9 100644 --- a/homeassistant/components/yoto/application_credentials.py +++ b/homeassistant/components/yoto/application_credentials.py @@ -6,8 +6,6 @@ LocalOAuth2ImplementationWithPkce, ) -from .const import YOTO_AUDIENCE, YOTO_SCOPES - AUTHORIZE_URL = "https://login.yotoplay.com/authorize" TOKEN_URL = "https://login.yotoplay.com/oauth/token" @@ -16,9 +14,9 @@ async def async_get_auth_implementation( hass: HomeAssistant, auth_domain: str, credential: ClientCredential, -) -> YotoOAuth2Implementation: - """Return a Yoto OAuth2 implementation backed by the user's credential.""" - return YotoOAuth2Implementation( +) -> LocalOAuth2ImplementationWithPkce: + """Return a Yoto OAuth2 implementation with PKCE.""" + return LocalOAuth2ImplementationWithPkce( hass, auth_domain, credential.client_id, @@ -26,15 +24,3 @@ async def async_get_auth_implementation( TOKEN_URL, credential.client_secret, ) - - -class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce): - """Yoto OAuth2 implementation with PKCE, audience and scopes.""" - - @property - def extra_authorize_data(self) -> dict: - """Append Yoto's audience and scopes to every authorize URL.""" - return super().extra_authorize_data | { - "audience": YOTO_AUDIENCE, - "scope": " ".join(YOTO_SCOPES), - } diff --git a/homeassistant/components/yoto/config_flow.py b/homeassistant/components/yoto/config_flow.py index 1cd258e15ae8c..19e5e10408938 100644 --- a/homeassistant/components/yoto/config_flow.py +++ b/homeassistant/components/yoto/config_flow.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow -from .const import _LOGGER, DOMAIN +from .const import _LOGGER, DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES class YotoOAuth2FlowHandler( @@ -23,6 +23,14 @@ def logger(self) -> logging.Logger: """Return the logger used for the OAuth2 flow.""" return _LOGGER + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Append Yoto's audience and scopes to the authorize URL.""" + return { + "audience": YOTO_AUDIENCE, + "scope": " ".join(YOTO_SCOPES), + } + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Identify the Yoto account from the access token.""" try: From ae278d3c80985667c88f563d7f90a1aabf9a0968 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 14:58:46 +0200 Subject: [PATCH 055/153] Sanitize surrogate characters in MeteoAlarm alert attributes (#172545) --- homeassistant/components/meteoalarm/binary_sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index fb39425e1ae12..876b22f06901f 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -80,5 +80,12 @@ def update(self) -> None: expiration_date = dt_util.parse_datetime(alert["expires"]) if expiration_date is not None and expiration_date > dt_util.utcnow(): - self._attr_extra_state_attributes = alert + self._attr_extra_state_attributes = { + key: ( + value.encode("utf-8", errors="replace").decode("utf-8") + if isinstance(value, str) + else value + ) + for key, value in alert.items() + } self._attr_is_on = True From c2b75b96345adf0733c8605f099a9f86770b7a6f Mon Sep 17 00:00:00 2001 From: "A. Gideonse" Date: Mon, 1 Jun 2026 10:23:59 +0200 Subject: [PATCH 056/153] Bugfix: Gen-1 Inverter sensor for Indevolt to display "N/A" when turned off (#172559) --- .../components/indevolt/binary_sensor.py | 2 + homeassistant/components/indevolt/sensor.py | 14 +++++- tests/components/indevolt/fixtures/gen_1.json | 2 +- .../snapshots/test_binary_sensor.ambr | 2 +- .../indevolt/snapshots/test_diagnostics.ambr | 2 +- tests/components/indevolt/test_sensor.py | 44 ++++++++++++++++++- 6 files changed, 61 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/indevolt/binary_sensor.py b/homeassistant/components/indevolt/binary_sensor.py index 109a9488f6bbd..1be4bf6364a50 100644 --- a/homeassistant/components/indevolt/binary_sensor.py +++ b/homeassistant/components/indevolt/binary_sensor.py @@ -46,6 +46,8 @@ class IndevoltBinarySensorEntityDescription(BinarySensorEntityDescription): key=IndevoltSystem.HEATING_STATE, generation=(1,), translation_key="electric_heating_state", + on_value=1000, + off_value=1001, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/indevolt/sensor.py b/homeassistant/components/indevolt/sensor.py index eb63791919384..1331d7b0356f3 100644 --- a/homeassistant/components/indevolt/sensor.py +++ b/homeassistant/components/indevolt/sensor.py @@ -939,12 +939,24 @@ def __init__( @property def available(self) -> bool: - """Return False when the device is not in the required energy mode.""" + """Return False for sensors in a non-applicable state.""" + + # Check whether device is not in the required energy mode if self.entity_description.energy_mode is not None: energy_mode = self.coordinator.data.get(IndevoltConfig.READ_ENERGY_MODE) if energy_mode != self.entity_description.energy_mode: return False + # Check whether inverter is reporting 0 degrees with heater not active (thus reporting to indicate "idle") + # Pending fix by Indevolt: https://discord.com/channels/1417471269942591571/1510277757689659522 + if self.entity_description.key == IndevoltBattery.GEN_1_INVERTER_TEMPERATURE: + inverter_temp = self.coordinator.data.get( + IndevoltBattery.GEN_1_INVERTER_TEMPERATURE + ) + heating_state = self.coordinator.data.get(IndevoltSystem.HEATING_STATE) + if inverter_temp == 0 and heating_state != 1000: + return False + return super().available @property diff --git a/tests/components/indevolt/fixtures/gen_1.json b/tests/components/indevolt/fixtures/gen_1.json index 6dcb05a0612ad..676d4ed1d221f 100644 --- a/tests/components/indevolt/fixtures/gen_1.json +++ b/tests/components/indevolt/fixtures/gen_1.json @@ -31,7 +31,7 @@ "6006": 277.16, "6007": 256.39, "7120": 1000, - "7121": 1, + "7121": 1001, "7600": 35.2, "7605": 28.1, "7620": 27.4, diff --git a/tests/components/indevolt/snapshots/test_binary_sensor.ambr b/tests/components/indevolt/snapshots/test_binary_sensor.ambr index c122ef7f1da72..e18c4c3425e19 100644 --- a/tests/components/indevolt/snapshots/test_binary_sensor.ambr +++ b/tests/components/indevolt/snapshots/test_binary_sensor.ambr @@ -46,7 +46,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_binary_sensor[1][binary_sensor.bk1600_meter_connected-entry] diff --git a/tests/components/indevolt/snapshots/test_diagnostics.ambr b/tests/components/indevolt/snapshots/test_diagnostics.ambr index 1e7a2cc4c9f37..6f143b00a5799 100644 --- a/tests/components/indevolt/snapshots/test_diagnostics.ambr +++ b/tests/components/indevolt/snapshots/test_diagnostics.ambr @@ -34,7 +34,7 @@ '667': 0, '7101': 5, '7120': 1000, - '7121': 1, + '7121': 1001, '7600': 35.2, '7605': 28.1, '7620': 27.4, diff --git a/tests/components/indevolt/test_sensor.py b/tests/components/indevolt/test_sensor.py index 247dc34899bb7..32597a0ecbb1c 100644 --- a/tests/components/indevolt/test_sensor.py +++ b/tests/components/indevolt/test_sensor.py @@ -4,7 +4,12 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from indevolt_api import IndevoltConfig, IndevoltEnergyMode +from indevolt_api import ( + IndevoltBattery, + IndevoltConfig, + IndevoltEnergyMode, + IndevoltSystem, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -115,6 +120,43 @@ async def test_realtime_sensor_energy_mode_availability( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("generation", [1], indirect=True) +async def test_inverter_sensor_temperature_availability( + hass: HomeAssistant, + mock_indevolt: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test inverter sensors are only available when temperature is reported.""" + with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + # Default fixture reports a valid temperature (35.2) with heating off, sensor should be available + assert hass.states.get("sensor.bk1600_inverter_temperature").state == "35.2" + + # Set temperature to 0 with heating off - sensor should become unavailable + mock_indevolt.fetch_data.return_value[ + IndevoltBattery.GEN_1_INVERTER_TEMPERATURE + ] = 0 + + freezer.tick(delta=timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.bk1600_inverter_temperature").state == STATE_UNAVAILABLE + ) + + # Switch heating on, sensor should be available again with value 0 + mock_indevolt.fetch_data.return_value[IndevoltSystem.HEATING_STATE] = 1000 + freezer.tick(delta=timedelta(seconds=SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.bk1600_inverter_temperature").state == "0" + + # In individual tests, you can override the mock behavior async def test_battery_pack_filtering( hass: HomeAssistant, From 3364096b2b54c14535c5ce1e982805b2687e18e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 08:50:26 -0500 Subject: [PATCH 057/153] Fix ESPHome update entity stuck on for project versions with build suffix (#172571) --- homeassistant/components/esphome/update.py | 13 +++ tests/components/esphome/test_update.py | 124 +++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 35e43f593ccd5..f5cf795197066 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -284,6 +284,19 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: UpdateDeviceClass, static_info.device_class ) + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return True if latest_version is newer than installed_version. + + ESPHome project versions can carry a build suffix (e.g. + 2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping + it the base comparison raises and the entity is forced on for every + build mismatch. Drop the suffix so the versions compare cleanly and we + only report genuinely newer firmware. + """ + return super().version_is_newer( + latest_version.partition("_")[0], installed_version.partition("_")[0] + ) + @property @esphome_state_property def installed_version(self) -> str: diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 0af9cc7d71e2d..1bdb4b4ac6375 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -5,6 +5,8 @@ from unittest.mock import patch from aioesphomeapi import APIClient, UpdateCommand, UpdateInfo, UpdateState +from awesomeversion import AwesomeVersion +from awesomeversion.exceptions import AwesomeVersionCompareException import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard @@ -547,6 +549,128 @@ async def test_generic_device_update_entity_has_update( ) +@pytest.mark.parametrize( + ("current_version", "latest_version"), + [ + ("2025.11.5_c51f7548", "2025.11.6_aabbccdd"), + ("2025.11.5_c51f7548", "2025.11.5_aabbccdd"), + ("2025.11.6_aabbccdd", "2025.11.5_c51f7548"), + ], + ids=["newer_base", "same_base_new_build", "older_base"], +) +def test_awesomeversion_cannot_compare_project_versions( + current_version: str, latest_version: str +) -> None: + """Prove AwesomeVersion raises on ESPHome project versions. + + ESPHome project versions carry a build suffix (e.g. 2025.11.5_c51f7548). + AwesomeVersion cannot parse these, so the base UpdateEntity comparison would + raise and force the entity on, which is why ESPHomeUpdateEntity mirrors the + device by comparing with a plain string inequality instead. + """ + with pytest.raises(AwesomeVersionCompareException): + assert AwesomeVersion(latest_version) > current_version + + +@pytest.mark.parametrize( + ("current_version", "latest_version", "expected_state"), + [ + ("2025.11.5_c51f7548", "2025.11.6_aabbccdd", STATE_ON), + ("2025.11.5_c51f7548", "2025.11.5_aabbccdd", STATE_OFF), + ("2025.11.6_aabbccdd", "2025.11.5_c51f7548", STATE_OFF), + ("2025.11.5_c51f7548", "2025.11.5_c51f7548", STATE_OFF), + ], + ids=["newer_base", "same_base_new_build", "older_base", "identical"], +) +async def test_generic_device_update_entity_project_version( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + current_version: str, + latest_version: str, + expected_state: str, +) -> None: + """Test version comparison for ESPHome project versions. + + AwesomeVersion cannot parse the build suffix, so the entity strips it and + compares the real versions: only a genuinely newer base version is offered; + a different build of the same version or an older version is not. + """ + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + ) + ] + states = [ + UpdateState( + key=1, + current_version=current_version, + latest_version=latest_version, + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +async def test_generic_device_update_entity_clears_after_ota( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test a project version update clears once the device runs the new build.""" + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + ) + ] + states = [ + UpdateState( + key=1, + current_version="2025.11.5_c51f7548", + latest_version="2025.11.6_aabbccdd", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + mock_device.set_state( + UpdateState( + key=1, + current_version="2025.11.6_aabbccdd", + latest_version="2025.11.6_aabbccdd", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + async def test_update_entity_release_notes( hass: HomeAssistant, mock_client: APIClient, From ce8875ae8cd37e8cca8dcd771d2e77c72f01e001 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 May 2026 11:11:51 -0500 Subject: [PATCH 058/153] Bump habluetooth to 6.8.0 (#172577) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 50455d05682e8..9cada8b157048 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.6.4", "bluetooth-data-tools==1.29.18", "dbus-fast==5.0.16", - "habluetooth==6.7.9" + "habluetooth==6.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2b78353a61c9f..a208df50cda37 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ file-read-backwards==2.0.0 fnv-hash-fast==2.0.3 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 -habluetooth==6.7.9 +habluetooth==6.8.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 42dc3f8fafe37..10e9399003e01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5 habiticalib==0.4.7 # homeassistant.components.bluetooth -habluetooth==6.7.9 +habluetooth==6.8.0 # homeassistant.components.hanna hanna-cloud==0.0.7 From d550d1da903c68d09b15d466bc92befdf7137807 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 08:49:56 -0500 Subject: [PATCH 059/153] Expose bluetooth address reachability diagnostics API (#172578) --- .../components/bluetooth/__init__.py | 4 ++ homeassistant/components/bluetooth/api.py | 9 ++++ tests/components/bluetooth/test_api.py | 53 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 61a7fa6de26fb..1f8caa91eedab 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -27,6 +27,7 @@ from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, + BluetoothReachabilityIntent, BluetoothScannerDevice, BluetoothScanningMode, HaBluetoothConnector, @@ -55,6 +56,7 @@ from .api import ( _get_manager, async_address_present, + async_address_reachability_diagnostics, async_ble_device_from_address, async_clear_address_from_match_history, async_clear_advertisement_history, @@ -108,12 +110,14 @@ "BluetoothCallback", "BluetoothCallbackMatcher", "BluetoothChange", + "BluetoothReachabilityIntent", "BluetoothScannerDevice", "BluetoothScanningMode", "BluetoothServiceInfo", "BluetoothServiceInfoBleak", "HaBluetoothConnector", "async_address_present", + "async_address_reachability_diagnostics", "async_ble_device_from_address", "async_clear_address_from_match_history", "async_clear_advertisement_history", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 5440bdf5a706e..dc5d5fff44c55 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -11,6 +11,7 @@ from bleak import BleakScanner from habluetooth import ( BaseHaScanner, + BluetoothReachabilityIntent, BluetoothScannerDevice, BluetoothScanningMode, HaBleakScannerWrapper, @@ -108,6 +109,14 @@ def async_ble_device_from_address( return _get_manager(hass).async_ble_device_from_address(address, connectable) +@hass_callback +def async_address_reachability_diagnostics( + hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent +) -> str: + """Return a human readable explanation of why an address may be unreachable.""" + return _get_manager(hass).async_address_reachability_diagnostics(address, intent) + + @hass_callback def async_scanner_devices_by_address( hass: HomeAssistant, address: str, connectable: bool = True diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index ab188f2799d49..dffdcf943d782 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -10,9 +10,11 @@ MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, + BluetoothReachabilityIntent, BluetoothScanningMode, BluetoothServiceInfo, HaBluetoothConnector, + async_address_reachability_diagnostics, async_clear_advertisement_history, async_request_active_scan, async_scanner_by_source, @@ -112,6 +114,57 @@ def inject_advertisement( cancel() +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_address_reachability_diagnostics(hass: HomeAssistant) -> None: + """Test the address reachability diagnostics passthrough.""" + # An address that was never seen reports as unknown. + assert "unknown" in async_address_reachability_diagnostics( + hass, "44:44:33:11:23:99", BluetoothReachabilityIntent.CONNECTION + ) + + manager = _get_manager() + + class FakeInjectableScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), + ) + + connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True) + scanner = FakeInjectableScanner("esp32", "esp32", connector, True) + unsetup = scanner.async_setup() + cancel = manager.async_register_scanner(scanner) + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) + switchbot_device_adv = generate_advertisement_data(local_name="wohand", rssi=-80) + scanner.inject_advertisement(switchbot_device, switchbot_device_adv) + + connection_diag = async_address_reachability_diagnostics( + hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.CONNECTION + ) + assert "in connectable history" in connection_diag + assert "esp32" in connection_diag + + # An advertisement intent does not report connectable paths or slots. + advertisement_diag = async_address_reachability_diagnostics( + hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT + ) + assert "advertising" in advertisement_diag + + unsetup() + cancel() + + @pytest.mark.usefixtures("enable_bluetooth") async def test_async_scanner_devices_by_address_non_connectable( hass: HomeAssistant, From de0a202c4e9e12fc05f178efb4178995ecdd0d41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 04:42:00 -0500 Subject: [PATCH 060/153] Explain why a Switchbot device could not be found (#172581) --- homeassistant/components/switchbot/__init__.py | 18 +++++++++++++++++- .../components/switchbot/strings.json | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 45f3cbdcf9406..9a2d8f4a47bc1 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -6,6 +6,7 @@ import switchbot from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.components.sensor import ConfigType from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -310,6 +311,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> translation_placeholders={ "sensor_type": entry.data[CONF_SENSOR_TYPE], "address": entry.data[CONF_ADDRESS], + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + entry.data[CONF_ADDRESS].upper(), + BluetoothReachabilityIntent.CONNECTION, + ), }, ) @@ -331,7 +337,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="device_not_found_error", - translation_placeholders={"sensor_type": sensor_type, "address": address}, + translation_placeholders={ + "sensor_type": sensor_type, + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION + if connectable + else BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT, + ), + }, ) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 5ddd6d561d778..076be9c30dbd0 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -384,7 +384,7 @@ "message": "The device ID {device_id} does not belong to SwitchBot integration." }, "device_not_found_error": { - "message": "Could not find Switchbot {sensor_type} with address {address}" + "message": "Could not find Switchbot {sensor_type} with address {address}: {reason}" }, "device_without_config_entry": { "message": "The device ID {device_id} is not associated with a config entry." From dd43b1135db93afb4c567fc0b4b4e87b7fbc7159 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:06:35 +0200 Subject: [PATCH 061/153] Update rf-protocols to 4.0.1 (#172597) --- homeassistant/components/radio_frequency/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/radio_frequency/manifest.json b/homeassistant/components/radio_frequency/manifest.json index ede2f718b4db6..049b7ddfc4fcc 100644 --- a/homeassistant/components/radio_frequency/manifest.json +++ b/homeassistant/components/radio_frequency/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/radio_frequency", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["rf-protocols==4.0.0"] + "requirements": ["rf-protocols==4.0.1"] } diff --git a/requirements.txt b/requirements.txt index f1b240f3ce6ec..edd4e1e10a537 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.34.2 -rf-protocols==4.0.0 +rf-protocols==4.0.1 securetar==2026.4.1 SQLAlchemy==2.0.49 standard-aifc==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 10e9399003e01..028fa837d86f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2881,7 +2881,7 @@ renson-endura-delta==1.7.2 reolink-aio==0.20.0 # homeassistant.components.radio_frequency -rf-protocols==4.0.0 +rf-protocols==4.0.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 From 24a5c75cf25d1ed6d3876e996c34c7b0b89ce8ca Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 30 May 2026 11:57:37 +0200 Subject: [PATCH 062/153] Show error about missing api permissions while browsing Immich media (#172609) --- .../components/immich/media_source.py | 46 +++++++++++++++- homeassistant/components/immich/strings.json | 3 + tests/components/immich/test_media_source.py | 55 +++++++++++++++---- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index 81ac70f0b7988..aab6df65c5baa 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -4,7 +4,7 @@ from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse from aioimmich.assets.models import ImmichAsset -from aioimmich.exceptions import ImmichError +from aioimmich.exceptions import ImmichError, ImmichForbiddenError from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import BrowseError, MediaClass @@ -79,7 +79,7 @@ async def async_browse_media( ], ) - async def _async_build_immich( + async def _async_build_immich( # noqa: C901 self, item: MediaSourceItem, entries: list[ConfigEntry] ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" @@ -137,6 +137,12 @@ async def _async_build_immich( LOGGER.debug("Render all albums for %s", entry.title) try: albums = await immich_api.albums.async_get_all_albums() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -158,6 +164,12 @@ async def _async_build_immich( LOGGER.debug("Render all tags for %s", entry.title) try: tags = await immich_api.tags.async_get_all_tags() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -178,6 +190,12 @@ async def _async_build_immich( LOGGER.debug("Render all people for %s", entry.title) try: people = await immich_api.people.async_get_all_people() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -211,6 +229,12 @@ async def _async_build_immich( identifier.collection_id ) assets = album_info.assets + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -223,6 +247,12 @@ async def _async_build_immich( assets = await immich_api.search.async_get_all_by_tag_ids( [identifier.collection_id] ) + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] @@ -235,12 +265,24 @@ async def _async_build_immich( assets = await immich_api.search.async_get_all_by_person_ids( [identifier.collection_id] ) + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] elif identifier.collection == "favorites": LOGGER.debug("Render all assets for favorites collection") try: assets = await immich_api.search.async_get_all_favorites() + except ImmichForbiddenError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="missing_api_permission", + translation_placeholders={"msg": str(err)}, + ) from err except ImmichError: return [] diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json index 4c465ea3d2bc8..3a1e61f89759c 100644 --- a/homeassistant/components/immich/strings.json +++ b/homeassistant/components/immich/strings.json @@ -102,6 +102,9 @@ "identifier_unresolvable": { "message": "Could not parse identifier: {identifier}" }, + "missing_api_permission": { + "message": "Missing API permission ({msg})." + }, "not_configured": { "message": "Immich is not configured." }, diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 37450d5a02273..faad3c5ee8e4a 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch from aiohttp import web -from aioimmich.exceptions import ImmichError +from aioimmich.exceptions import ImmichError, ImmichForbiddenError import pytest from homeassistant.components.immich.const import DOMAIN @@ -252,6 +252,12 @@ async def test_browse_media_collections_error( with patch("homeassistant.components.immich.PLATFORMS", []): await setup_integration(hass, mock_config_entry) + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) + source = await async_get_media_source(hass) + + # test generic ImmichError getattr( getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] ).side_effect = ImmichError( @@ -263,17 +269,26 @@ async def test_browse_media_collections_error( } ) - source = await async_get_media_source(hass) - - item = MediaSourceItem( - hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None - ) result = await source.async_browse_media(item) assert result assert result.identifier is None assert len(result.children) == 0 + # test specific ImmichForbiddenError + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichForbiddenError( + { + "message": "Missing required permission: asset.read", + "error": "Forbidden", + "statusCode": 403, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(BrowseError, match="Missing API permission"): + await source.async_browse_media(item) + @pytest.mark.parametrize( ("collection", "mocked_get_fn"), @@ -297,8 +312,15 @@ async def test_browse_media_collection_items_error( with patch("homeassistant.components.immich.PLATFORMS", []): await setup_integration(hass, mock_config_entry) + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) source = await async_get_media_source(hass) + # test generic ImmichError getattr( getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] ).side_effect = ImmichError( @@ -309,18 +331,27 @@ async def test_browse_media_collection_items_error( "correlationId": "e0hlizyl", } ) - item = MediaSourceItem( - hass, - DOMAIN, - f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) + result = await source.async_browse_media(item) assert result assert result.identifier is None assert len(result.children) == 0 + # test specific ImmichForbiddenError + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichForbiddenError( + { + "message": "Missing required permission: asset.read", + "error": "Forbidden", + "statusCode": 403, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(BrowseError, match="Missing API permission"): + await source.async_browse_media(item) + @pytest.mark.parametrize( ("collection", "collection_id", "children"), From 0d37319ba9f27b6cd63ea59b43833b479c97566b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren?= Date: Sat, 30 May 2026 16:49:36 +0200 Subject: [PATCH 063/153] Improve Avea Bluetooth discovery flow (#172623) --- homeassistant/components/avea/config_flow.py | 19 +++-- tests/components/avea/test_config_flow.py | 77 ++++++++++++++++++-- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/avea/config_flow.py b/homeassistant/components/avea/config_flow.py index 5a098f2b93b62..13b13be3d62fb 100644 --- a/homeassistant/components/avea/config_flow.py +++ b/homeassistant/components/avea/config_flow.py @@ -8,6 +8,7 @@ from bleak.exc import BleakError import voluptuous as vol +from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, @@ -66,6 +67,15 @@ def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool: return AVEA_SERVICE_UUID in discovery_info.service_uuids +def _discovery_label(discovery_info: BluetoothServiceInfoBleak) -> str: + """Return a label for a discovered Avea bulb.""" + if ( + name := _normalize_name(discovery_info.name) + ) and name != discovery_info.address: + return f"{name} ({discovery_info.address})" + return discovery_info.address + + class AveaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Avea.""" @@ -150,6 +160,7 @@ async def async_step_user( if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: + await bluetooth.async_request_active_scan(self.hass) current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( @@ -165,11 +176,10 @@ async def async_step_user( if self._discovery_info: disc = self._discovery_info - label = f"{disc.name or disc.address} ({disc.address})" data_schema = vol.Schema( { vol.Required(CONF_ADDRESS, default=disc.address): vol.In( - {disc.address: label} + {disc.address: _discovery_label(disc)} ) } ) @@ -178,10 +188,7 @@ async def async_step_user( { vol.Required(CONF_ADDRESS): vol.In( { - service_info.address: ( - f"{service_info.name or service_info.address}" - f" ({service_info.address})" - ) + service_info.address: _discovery_label(service_info) for service_info in self._discovered_devices.values() } ), diff --git a/tests/components/avea/test_config_flow.py b/tests/components/avea/test_config_flow.py index 3cdfeb26b3255..ca1ed88852409 100644 --- a/tests/components/avea/test_config_flow.py +++ b/tests/components/avea/test_config_flow.py @@ -4,7 +4,7 @@ import pytest -from homeassistant.components.avea.const import DOMAIN +from homeassistant.components.avea.const import AVEA_SERVICE_UUID, DOMAIN from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant @@ -12,7 +12,11 @@ from . import AVEA_DISCOVERY_INFO, NOT_AVEA_DISCOVERY_INFO -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.components.bluetooth import ( + generate_advertisement_data, + generate_ble_device, + inject_bluetooth_service_info, +) pytestmark = pytest.mark.usefixtures("enable_bluetooth") @@ -35,13 +39,22 @@ async def test_user_step_success(hass: HomeAssistant) -> None: inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO) await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + with patch( + "homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan" + ) as mock_request_active_scan: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} + assert result["data_schema"].schema[CONF_ADDRESS].container == { + AVEA_DISCOVERY_INFO.address: ( + f"{AVEA_DISCOVERY_INFO.name} ({AVEA_DISCOVERY_INFO.address})" + ) + } + mock_request_active_scan.assert_awaited_once_with(hass) with ( patch( @@ -67,14 +80,62 @@ async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: inject_bluetooth_service_info(hass, NOT_AVEA_DISCOVERY_INFO) await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + with patch( + "homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" +async def test_user_step_unnamed_device_label(hass: HomeAssistant) -> None: + """Test unnamed discovered devices are shown without duplicating the address.""" + discovery_info = type(AVEA_DISCOVERY_INFO)( + name=AVEA_DISCOVERY_INFO.address, + address=AVEA_DISCOVERY_INFO.address, + rssi=-60, + manufacturer_data={}, + service_uuids=[AVEA_SERVICE_UUID], + service_data={}, + source="local", + device=generate_ble_device( + address=AVEA_DISCOVERY_INFO.address, name=AVEA_DISCOVERY_INFO.address + ), + advertisement=generate_advertisement_data( + local_name=AVEA_DISCOVERY_INFO.address, + manufacturer_data={}, + service_data={}, + service_uuids=[AVEA_SERVICE_UUID], + ), + time=0, + connectable=True, + tx_power=-127, + ) + + with ( + patch( + "homeassistant.components.avea.config_flow.async_discovered_service_info", + return_value=[discovery_info], + ), + patch( + "homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan" + ) as mock_request_active_scan, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["data_schema"].schema[CONF_ADDRESS].container == { + AVEA_DISCOVERY_INFO.address: AVEA_DISCOVERY_INFO.address + } + mock_request_active_scan.assert_awaited_once_with(hass) + + async def test_user_step_cannot_connect_recovers(hass: HomeAssistant) -> None: """Test the user step recovers after a cannot connect error.""" inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO) From b50bfda00c796f6b061915fb78e01efdb4a8b457 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 31 May 2026 12:03:12 +0200 Subject: [PATCH 064/153] Fix MQTT device_tracker not saving state on location accuracy changes (#172629) --- homeassistant/components/mqtt/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index d647e3bd84e28..7915e336b522a 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -530,7 +530,7 @@ def _attributes_prepare_subscribe_topics(self) -> None: self._attributes_message_received, { "_attr_extra_state_attributes", - "_attr_gps_accuracy", + "_attr_location_accuracy", "_attr_latitude", "_attr_location_name", "_attr_longitude", From a47105d314dc2bfb4d6840955780afeffc5a6d36 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 1 Jun 2026 04:27:28 -0400 Subject: [PATCH 065/153] Schlage: use lock connected status as availability signal (#172638) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Joostlek --- homeassistant/components/schlage/entity.py | 6 +++++- tests/components/schlage/conftest.py | 1 + tests/components/schlage/test_lock.py | 24 +++++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/schlage/entity.py b/homeassistant/components/schlage/entity.py index cc4745e51cc49..b189cfe31458a 100644 --- a/homeassistant/components/schlage/entity.py +++ b/homeassistant/components/schlage/entity.py @@ -42,4 +42,8 @@ def _lock(self) -> Lock: @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.device_id in self.coordinator.data.locks + return ( + super().available + and self.device_id in self.coordinator.data.locks + and self._lock.connected + ) diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 6695191dcf086..334d3e62edafe 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -97,4 +97,5 @@ def mock_lock_attrs() -> dict[str, Any]: "firmware_version": "1.0", "lock_and_leave_enabled": True, "beeper_enabled": True, + "connected": True, } diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 71ba04f231893..9756141005d6e 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -15,8 +15,14 @@ SERVICE_ADD_CODE, SERVICE_DELETE_CODE, SERVICE_GET_CODES, + UPDATE_INTERVAL, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_UNAVAILABLE, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -49,6 +55,22 @@ async def test_lock_attributes( assert lock.state == LockState.JAMMED +async def test_lock_disconnected( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test lock unavailable when disconnected.""" + mock_lock.connected = False + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + lock = hass.states.get("lock.vault_door") + assert lock is not None + assert lock.state == STATE_UNAVAILABLE + + async def test_lock_services( hass: HomeAssistant, mock_lock: Mock, From 22ace88b2ccbab33a65ff9aebc0ea3eba76c7716 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 31 May 2026 11:22:47 +0200 Subject: [PATCH 066/153] Bump ZHA to 1.4.1 (#172640) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index bba334981a1d9..6353c4d8abba7 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.4.0"], + "requirements": ["zha==1.4.1"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 028fa837d86f5..7dd7646ff16af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3442,7 +3442,7 @@ zeroconf==0.149.16 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.4.0 +zha==1.4.1 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 From 25875de414eddfc76f182eb220558257faf436e2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:19:09 +0200 Subject: [PATCH 067/153] Add extra device info to FRITZ!Box Tools diagnostics (#172647) --- homeassistant/components/fritz/coordinator.py | 12 ++++++++++++ homeassistant/components/fritz/diagnostics.py | 2 ++ tests/components/fritz/const.py | 8 ++++++++ .../components/fritz/snapshots/test_diagnostics.ambr | 9 +++++++++ 4 files changed, 31 insertions(+) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 5915804aa1386..005d85e9e1c99 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -938,3 +938,15 @@ async def async_wake_on_lan(self, mac_address: str) -> dict[str, Any]: "X_AVM-DE_WakeOnLANByMACAddress", NewMACAddress=mac_address, ) + + async def async_get_firmware_extra_infos(self) -> dict[str, Any]: + """Return extra infos for firmware.""" + return await self._async_service_call("UserInterface", "1", "X_AVM-DE_GetInfo") + + async def async_get_device_uptime_hours(self) -> int: + """Get device uptime in hours.""" + + def _get_uptime_hours() -> int: + return int(self.fritz_status.device_uptime // 3600) + + return await self.hass.async_add_executor_job(_get_uptime_hours) diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index 43ed80f6396d4..0165e37de0c38 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -24,9 +24,11 @@ async def async_get_config_entry_diagnostics( "unique_id": avm_wrapper.unique_id.replace( avm_wrapper.unique_id[6:11], "XX:XX" ), + "device_uptime_hours": await avm_wrapper.async_get_device_uptime_hours(), "current_firmware": avm_wrapper.current_firmware, "latest_firmware": avm_wrapper.latest_firmware, "update_available": avm_wrapper.update_available, + "firmware_extra_infos": await avm_wrapper.async_get_firmware_extra_infos(), "connection_type": avm_wrapper.device_conn_type, "is_router": avm_wrapper.device_is_router, "mesh_role": avm_wrapper.mesh_role, diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index 4415f563ffd90..3e6d9741ca9c2 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -97,6 +97,14 @@ }, "UserInterface1": { "GetInfo": {}, + "X_AVM-DE_GetInfo": { + "NewX_AVM-DE_AutoUpdateMode": "notify", + "NewX_AVM-DE_UpdateTime": "2026-05-17T18:54:37+02:00", + "NewX_AVM-DE_LastFwVersion": "256.08.20,124233", + "NewX_AVM-DE_LastInfoUrl": "http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_en.txt", + "NewX_AVM-DE_CurrentFwVersion": "256.08.25", + "NewX_AVM-DE_UpdateSuccessful": "succeeded", + }, }, "WANCommonIFC1": { "GetCommonLinkProperties": { diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index f67aaf40bcd90..af5fe8460dc12 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -25,6 +25,7 @@ 'NAS': 'none', 'Phone': 'readwrite', }), + 'device_uptime_hours': 699, 'discovered_services': list([ 'DeviceInfo1', 'Hosts1', @@ -43,6 +44,14 @@ 'X_AVM-DE_HostFilter1', 'X_AVM-DE_UPnP1', ]), + 'firmware_extra_infos': dict({ + 'NewX_AVM-DE_AutoUpdateMode': 'notify', + 'NewX_AVM-DE_CurrentFwVersion': '256.08.25', + 'NewX_AVM-DE_LastFwVersion': '256.08.20,124233', + 'NewX_AVM-DE_LastInfoUrl': 'http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_en.txt', + 'NewX_AVM-DE_UpdateSuccessful': 'succeeded', + 'NewX_AVM-DE_UpdateTime': '2026-05-17T18:54:37+02:00', + }), 'is_router': True, 'last_exception': None, 'last_update success': True, From fbb68c26b6a2eada535c963b8b245278b6c90b5b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 31 May 2026 11:57:11 +0200 Subject: [PATCH 068/153] Bump tuya-device-handlers to 0.0.22 (#172648) --- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index f0cb0c392360a..a01310dcb6f77 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.21", + "tuya-device-handlers==0.0.22", "tuya-device-sharing-sdk==0.2.8" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 7dd7646ff16af..cae87bd7a5ce2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3207,7 +3207,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.21 +tuya-device-handlers==0.0.22 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 From 10a25368a03ea7d2ab653e64c9b69514f8a55a4c Mon Sep 17 00:00:00 2001 From: jameson_uk <1040621+jamesonuk@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:11:29 +0100 Subject: [PATCH 069/153] Improve http2 task handling for Alexa Devices (#172649) --- .../components/alexa_devices/__init__.py | 15 ++---- tests/components/alexa_devices/conftest.py | 12 +++-- tests/components/alexa_devices/test_init.py | 47 ++++++++++++++++++- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index 81d9f09fd6828..b04be74029e62 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -1,8 +1,5 @@ """Alexa Devices integration.""" -import asyncio -import contextlib - from homeassistant.const import CONF_COUNTRY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client @@ -46,21 +43,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def _on_http2_reauth_required() -> None: entry.async_start_reauth(hass) - async def _cancel_http2() -> None: - http2_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await http2_task - alexa_httpx_client = httpx_client.get_async_client( hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2, ) - http2_task = await coordinator.api.start_http2_processing( - alexa_httpx_client, on_reauth_required=_on_http2_reauth_required + await coordinator.api.start_http2_processing( + alexa_httpx_client, + on_reauth_required=_on_http2_reauth_required, ) - entry.async_on_unload(_cancel_http2) + entry.async_on_unload(coordinator.api.stop_http2_processing) entry.runtime_data = coordinator diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 4f4f9adb90add..2e6ede4931dec 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -65,9 +65,15 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: client.on_history_event = MagicMock() client.on_volume_state_event = MagicMock() client.on_media_state_event = MagicMock() - http2_task = asyncio.Future() - http2_task.set_result(None) - client.start_http2_processing = AsyncMock(return_value=http2_task) + + async def _start_http2_processing(*_args, **_kwargs) -> asyncio.Task[None]: + async def _completed_task() -> None: + return + + return asyncio.create_task(_completed_task()) + + client.start_http2_processing = AsyncMock(side_effect=_start_http2_processing) + client.stop_http2_processing = AsyncMock() client.send_sound_notification = AsyncMock() yield client diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index a57181521dca8..dff813529d26e 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -1,6 +1,7 @@ """Tests for the Alexa Devices integration.""" -from unittest.mock import AsyncMock +import asyncio +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -160,3 +161,47 @@ async def test_http2_reauth_required( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["context"]["source"] == "reauth" + + +async def test_http2_reauth_callback_triggers_reauth( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test on_reauth_required callback passed to start_http2_processing triggers reauth.""" + captured_callback = None + http2_task: asyncio.Task | None = None + + async def capture_callback(_client, on_reauth_required=None) -> asyncio.Task: + nonlocal captured_callback, http2_task + captured_callback = on_reauth_required + http2_task = hass.loop.create_task(asyncio.sleep(3600)) + return http2_task + + mock_amazon_devices_client.start_http2_processing.side_effect = capture_callback + + with patch.object(mock_config_entry, "async_start_reauth") as mock_reauth: + await setup_integration(hass, mock_config_entry) + + assert captured_callback is not None + await captured_callback() + + mock_reauth.assert_called_once_with(hass) + + assert http2_task is not None + http2_task.cancel() + await asyncio.gather(http2_task, return_exceptions=True) + + +async def test_http2_stop_processing_called_on_unload( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test stop_http2_processing is called on unload.""" + await setup_integration(hass, mock_config_entry) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_amazon_devices_client.stop_http2_processing.assert_awaited_once() From 9fa0132b1c761ed5273d5a17d0eeeba749c5ef46 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 31 May 2026 17:35:03 +0200 Subject: [PATCH 070/153] Add missing exception translation keys in Ecovacs (#172658) --- homeassistant/components/ecovacs/strings.json | 3 +++ homeassistant/components/ecovacs/vacuum.py | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 40a7776e83b04..f8d6066fcbdd7 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -294,6 +294,9 @@ "vacuum_raw_get_positions_not_supported": { "message": "Retrieving the positions of the chargers and the device itself is not supported" }, + "vacuum_send_command_not_supported": { + "message": "The {command} command is not supported by {name}" + }, "vacuum_send_command_params_dict": { "message": "Params must be a dictionary and not a list" }, diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 3ca7c5a50e496..e7fa8f32db0cd 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -353,11 +353,10 @@ async def async_send_command( if self._capability.clean.action.area is None: info = self._device.device_info name = info.get("nick", info["name"]) - # pylint: disable-next=home-assistant-exception-translation-key-missing raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="vacuum_send_command_area_not_supported", - translation_placeholders={"name": name}, + translation_key="vacuum_send_command_not_supported", + translation_placeholders={"command": command, "name": name}, ) if command == "spot_area": From 4b9945e01287331b6089ea6548c23dfc5cd85b5a Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 1 Jun 2026 02:04:17 +0100 Subject: [PATCH 071/153] Bump pynintendoparental to 2.4.0 (#172666) --- .../components/nintendo_parental_controls/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nintendo_parental_controls/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json index fd1fe831b6874..eac2b3c06fd56 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.4"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cae87bd7a5ce2..0cb78d1d4207b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2367,7 +2367,7 @@ pynina==1.0.2 pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.3.4 +pynintendoparental==2.4.0 # homeassistant.components.nobo_hub pynobo==1.9.0 From 0677ed824fa0fae10df3a501b317a6497eae1b79 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 1 Jun 2026 08:05:26 +0200 Subject: [PATCH 072/153] Fix tedee entity availability (#172667) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- homeassistant/components/tedee/entity.py | 5 +++++ homeassistant/components/tedee/lock.py | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index 4c522d1feb1c1..552dbb17567c3 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -36,6 +36,11 @@ def __init__( via_device=(DOMAIN, coordinator.bridge.serial), ) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._lock.is_connected + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 6d5131d07e9c7..b895b9b0edeb3 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -89,11 +89,7 @@ def is_jammed(self) -> bool: @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and self._lock.is_connected - and self._lock.state != TedeeLockState.UNCALIBRATED - ) + return super().available and self._lock.state != TedeeLockState.UNCALIBRATED async def async_unlock(self, **kwargs: Any) -> None: """Unlock the door.""" From 3ad3e1fafbc2da6ae2b685b50f8819923294871b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 1 Jun 2026 13:06:04 +0300 Subject: [PATCH 073/153] Fix ai_task camera snapshot mime type (#172682) --- homeassistant/components/ai_task/task.py | 3 +- tests/components/ai_task/test_task.py | 81 ++++-------------------- 2 files changed, 13 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index b3952b101e914..ff30db228be84 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -72,8 +72,7 @@ async def _resolve_attachments( resolved_attachments.append( conversation.Attachment( media_content_id=media_content_id, - mime_type=attachment.get("media_content_type") - or image_data.content_type, + mime_type=image_data.content_type, path=temp_filename, ) ) diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index e2127560366f4..a15cec0b466c1 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -286,37 +286,20 @@ async def test_generate_data_content_type( mock_ai_task_entity: MockAITaskEntity, ) -> None: """Test that user-provided content type of an attachment is respected.""" - with ( - patch( # Intentionally broken content type - "homeassistant.components.camera.async_get_image", - return_value=Image(content_type="image/png", content=b"fake_camera_jpeg"), - ) as mock_get_camera_image, - patch( # Same - "homeassistant.components.image.async_get_image", - return_value=Image(content_type="image/png", content=b"fake_image_jpeg"), - ) as mock_get_image_image, - patch( - "homeassistant.components.media_source.async_resolve_media", - return_value=media_source.PlayMedia( - url="http://example.com/test.png", # jpeg image saved as png - mime_type="image/png", - path=Path("/media/test.png"), - ), - ) as mock_resolve_media, - ): + with patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/test.png", # jpeg image saved as png + mime_type="image/png", + path=Path("/media/test.png"), + ), + ) as mock_resolve_media: await async_generate_data( hass, task_name="Test Task", entity_id=TEST_ENTITY_ID, - instructions="Describe these images", + instructions="Describe this image", attachments=[ - { # supply corrected content type from the user input - "media_content_id": "media-source://camera/camera.front_door", - "media_content_type": "image/jpeg", - }, - { # User did not provide content type, fallback to the integration - "media_content_id": "media-source://image/image.floorplan", - }, { "media_content_id": "media-source://media_player/test.png", "media_content_type": "image/jpeg", @@ -324,9 +307,7 @@ async def test_generate_data_content_type( ], ) - # Verify both methods were called - mock_get_camera_image.assert_called_once_with(hass, "camera.front_door") - mock_get_image_image.assert_called_once_with(hass, "image.floorplan") + # Verify the method was called mock_resolve_media.assert_called_once_with( hass, "media-source://media_player/test.png", None ) @@ -335,47 +316,9 @@ async def test_generate_data_content_type( assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 task = mock_ai_task_entity.mock_generate_data_tasks[0] assert task.attachments is not None - assert len(task.attachments) == 3 - - # Check camera attachment - camera_attachment = task.attachments[0] - assert ( - camera_attachment.media_content_id == "media-source://camera/camera.front_door" - ) - assert camera_attachment.mime_type == "image/jpeg" - assert isinstance(camera_attachment.path, Path) - assert camera_attachment.path.suffix == ".png" # This is fine + assert len(task.attachments) == 1 - # Verify camera snapshot content - assert camera_attachment.path.exists() - content = await hass.async_add_executor_job(camera_attachment.path.read_bytes) - assert content == b"fake_camera_jpeg" - - # Check image attachment - image_attachment = task.attachments[1] - assert image_attachment.media_content_id == "media-source://image/image.floorplan" - assert image_attachment.mime_type == "image/png" - assert isinstance(image_attachment.path, Path) - assert image_attachment.path.suffix == ".png" - - # Verify image snapshot content - assert image_attachment.path.exists() - content = await hass.async_add_executor_job(image_attachment.path.read_bytes) - assert content == b"fake_image_jpeg" - - # Trigger clean up - async_fire_time_changed( - hass, - dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - - # Verify the temporary file cleaned up - assert not camera_attachment.path.exists() - assert not image_attachment.path.exists() - - # Check regular media attachment - media_attachment = task.attachments[2] + media_attachment = task.attachments[0] assert media_attachment.media_content_id == "media-source://media_player/test.png" assert media_attachment.mime_type == "image/jpeg" assert media_attachment.path == Path("/media/test.png") From 0d079c57e464f9967977ebb2b7de459719209f2c Mon Sep 17 00:00:00 2001 From: Yardian Support Date: Mon, 1 Jun 2026 16:50:52 +0800 Subject: [PATCH 074/153] Fix Yardian water hammer diagnostic sensor name (#172698) --- homeassistant/components/yardian/strings.json | 2 +- .../components/yardian/snapshots/test_sensor.ambr | 14 +++++++------- tests/components/yardian/test_sensor.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 1f20b0a37b658..5951b197c92ac 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -44,7 +44,7 @@ "name": "Rain delay" }, "water_hammer_duration": { - "name": "Water hammer reduction" + "name": "Water hammer duration" }, "zone_delay": { "name": "Zone delay" diff --git a/tests/components/yardian/snapshots/test_sensor.ambr b/tests/components/yardian/snapshots/test_sensor.ambr index 42d6a973e5b7f..b4e01f6ee80e2 100644 --- a/tests/components/yardian/snapshots/test_sensor.ambr +++ b/tests/components/yardian/snapshots/test_sensor.ambr @@ -111,7 +111,7 @@ 'state': '3600', }) # --- -# name: test_sensor_entities[sensor.yardian_smart_sprinkler_water_hammer_reduction-entry] +# name: test_sensor_entities[sensor.yardian_smart_sprinkler_water_hammer_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -125,7 +125,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.yardian_smart_sprinkler_water_hammer_reduction', + 'entity_id': 'sensor.yardian_smart_sprinkler_water_hammer_duration', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -133,7 +133,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Water hammer reduction', + 'object_id_base': 'Water hammer duration', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 0, @@ -141,7 +141,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Water hammer reduction', + 'original_name': 'Water hammer duration', 'platform': 'yardian', 'previous_unique_id': None, 'suggested_object_id': None, @@ -151,15 +151,15 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor_entities[sensor.yardian_smart_sprinkler_water_hammer_reduction-state] +# name: test_sensor_entities[sensor.yardian_smart_sprinkler_water_hammer_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'Yardian Smart Sprinkler Water hammer reduction', + 'friendly_name': 'Yardian Smart Sprinkler Water hammer duration', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.yardian_smart_sprinkler_water_hammer_reduction', + 'entity_id': 'sensor.yardian_smart_sprinkler_water_hammer_duration', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/yardian/test_sensor.py b/tests/components/yardian/test_sensor.py index b81e59514e400..81e4e9694824d 100644 --- a/tests/components/yardian/test_sensor.py +++ b/tests/components/yardian/test_sensor.py @@ -70,7 +70,7 @@ async def test_diagnostic_sensors( for entity_id in ( "sensor.yardian_smart_sprinkler_zone_delay", - "sensor.yardian_smart_sprinkler_water_hammer_reduction", + "sensor.yardian_smart_sprinkler_water_hammer_duration", ): reg_entry = entity_registry.async_get(entity_id) assert reg_entry is not None From 56057a11e624aac9ccc51c5136b4fb7ea4065e00 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 16:47:36 +0200 Subject: [PATCH 075/153] Return 404 instead of 500 when media player artwork is unavailable (#172700) --- homeassistant/components/media_player/__init__.py | 2 +- tests/components/forked_daapd/test_browse_media.py | 2 +- tests/components/media_player/test_init.py | 2 +- tests/components/shelly/test_media_player.py | 8 ++++---- tests/components/webostv/test_media_player.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 69863d7e11705..a21b6a59a3a17 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1295,7 +1295,7 @@ async def get( data, content_type = await player.async_get_media_image() if data is None: - return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + return web.Response(status=HTTPStatus.NOT_FOUND) headers: LooseHeaders = {CACHE_CONTROL: "max-age=3600"} return web.Response(body=data, content_type=content_type, headers=headers) diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index 501d822836c84..7a58aa86d901a 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -447,4 +447,4 @@ async def test_async_browse_image_missing( resp = await client.get( f"/api/media_player_proxy/{TEST_MASTER_ENTITY_NAME}/browse_media/{MediaType.TRACK}/{media_content_id}" ) - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.status == HTTPStatus.NOT_FOUND diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 8d31d7efe9070..360fe2e162bd6 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -169,7 +169,7 @@ async def test_get_image_http_log_credentials_redacted( resp = await client.get(state.attributes["entity_picture"]) - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.status == HTTPStatus.NOT_FOUND assert f"Error retrieving proxied image from {url}" not in caplog.text assert ( "Error retrieving proxied image from " diff --git a/tests/components/shelly/test_media_player.py b/tests/components/shelly/test_media_player.py index 72b8e2513c096..49c2fca3c39ad 100644 --- a/tests/components/shelly/test_media_player.py +++ b/tests/components/shelly/test_media_player.py @@ -422,7 +422,7 @@ async def test_get_image_http_stale_url_after_thumb_invalidated( client = await hass_client_no_auth() resp = await client.get(entity_picture) - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.status == HTTPStatus.NOT_FOUND async def test_entity_picture_absent_base64_data_invalid( @@ -444,7 +444,7 @@ async def test_entity_picture_absent_base64_data_invalid( client = await hass_client() resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}") - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.status == HTTPStatus.NOT_FOUND @pytest.mark.parametrize( @@ -474,7 +474,7 @@ async def test_entity_picture_absent_thumb_string_invalid( client = await hass_client() resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}") - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.status == HTTPStatus.NOT_FOUND async def test_entity_picture_absent_mime_type_not_allowed( @@ -496,7 +496,7 @@ async def test_entity_picture_absent_mime_type_not_allowed( client = await hass_client() resp = await client.get(f"/api/media_player_proxy/{ENTITY_ID}") - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.status == HTTPStatus.NOT_FOUND async def test_rpc_media_player_browse_media_root( diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 06d9a5880a600..36794a6ec4405 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -822,7 +822,7 @@ async def test_get_image_http_error( resp = await client.get(attrs["entity_picture"]) content = await resp.read() - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.status == HTTPStatus.NOT_FOUND assert f"Error retrieving proxied image from {url}" in caplog.text assert content == b"" From acfecd7f5c41f871cbd3a462d3e38a367c033255 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 12:58:26 +0200 Subject: [PATCH 076/153] Convert set_id to int in LG TV RS-232 config flow (#172701) --- .../components/lg_tv_rs232/config_flow.py | 2 +- .../lg_tv_rs232/test_config_flow.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lg_tv_rs232/config_flow.py b/homeassistant/components/lg_tv_rs232/config_flow.py index 106f5235ff04d..1f27690aa8383 100644 --- a/homeassistant/components/lg_tv_rs232/config_flow.py +++ b/homeassistant/components/lg_tv_rs232/config_flow.py @@ -70,7 +70,7 @@ async def async_step_user( if user_input is not None: port = user_input[CONF_DEVICE] - set_id = user_input[CONF_SET_ID] + set_id = int(user_input[CONF_SET_ID]) self._async_abort_entries_match({CONF_DEVICE: port, CONF_SET_ID: set_id}) error = await _async_attempt_connect(port, set_id) diff --git a/tests/components/lg_tv_rs232/test_config_flow.py b/tests/components/lg_tv_rs232/test_config_flow.py index dcf2275f3886e..8e7fa1420d40f 100644 --- a/tests/components/lg_tv_rs232/test_config_flow.py +++ b/tests/components/lg_tv_rs232/test_config_flow.py @@ -55,6 +55,30 @@ async def test_user_form_creates_entry( mock_lgtv.disconnect.assert_awaited_once() +async def test_user_form_float_set_id( + hass: HomeAssistant, + mock_lgtv: MagicMock, + mock_async_setup_entry: AsyncMock, +) -> None: + """Test that a float set_id from NumberSelector is converted to int.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.lg_tv_rs232.config_flow.LGTV", + return_value=mock_lgtv, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MOCK_DEVICE, CONF_SET_ID: 1.0}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SET_ID] == MOCK_SET_ID + assert isinstance(result["data"][CONF_SET_ID], int) + + async def test_user_form_no_tv_shows_troubleshooting( hass: HomeAssistant, mock_lgtv: MagicMock, From f7afe2231851129957c4b504cfc648ab1c4aa9cf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 16:49:56 +0200 Subject: [PATCH 077/153] Skip Overkiz events for unknown device URLs (#172712) --- .../components/overkiz/coordinator.py | 8 ++-- tests/components/overkiz/test_climate.py | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 1c7a7db184458..f01d2a2c723e5 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -144,7 +144,7 @@ async def on_device_available( coordinator: OverkizDataUpdateCoordinator, event: Event ) -> None: """Handle device available event.""" - if event.device_url: + if event.device_url and event.device_url in coordinator.devices: coordinator.devices[event.device_url].available = True @@ -154,7 +154,7 @@ async def on_device_unavailable_disabled( coordinator: OverkizDataUpdateCoordinator, event: Event ) -> None: """Handle device unavailable / disabled event.""" - if event.device_url: + if event.device_url and event.device_url in coordinator.devices: coordinator.devices[event.device_url].available = False @@ -174,7 +174,7 @@ async def on_device_state_changed( coordinator: OverkizDataUpdateCoordinator, event: Event ) -> None: """Handle device state changed event.""" - if not event.device_url: + if not event.device_url or event.device_url not in coordinator.devices: return for state in event.device_states: @@ -198,7 +198,7 @@ async def on_device_removed( ): registry.async_remove_device(registered_device.id) - if event.device_url: + if event.device_url and event.device_url in coordinator.devices: del coordinator.devices[event.device_url] diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py index 2170fa763585e..33ce08242d98b 100644 --- a/tests/components/overkiz/test_climate.py +++ b/tests/components/overkiz/test_climate.py @@ -1,6 +1,7 @@ """Tests for the Overkiz climate platform.""" from collections.abc import Generator +from typing import Any from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -65,3 +66,45 @@ async def test_valve_hvac_action_none_state( state = hass.states.get(VALVE.entity_id) assert state is not None assert state.attributes.get(ATTR_HVAC_ACTION) is None + + +@pytest.mark.parametrize( + ("event_name", "device_states"), + [ + pytest.param(EventName.DEVICE_AVAILABLE, None, id="available"), + pytest.param(EventName.DEVICE_UNAVAILABLE, None, id="unavailable"), + pytest.param( + EventName.DEVICE_STATE_CHANGED, + [{"name": "core:OnOffState", "type": 3, "value": "on"}], + id="state_changed", + ), + pytest.param(EventName.DEVICE_REMOVED, None, id="removed"), + ], +) +async def test_events_for_unknown_device_url( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, + event_name: EventName, + device_states: list[dict[str, Any]] | None, +) -> None: + """Test that events for unknown device URLs don't crash the coordinator.""" + await setup_overkiz_integration(fixture=VALVE.fixture) + + await async_deliver_events( + hass, + freezer, + mock_client, + [ + build_event( + event_name, + device_url="zigbee://1234-5678-1698/65535", + device_states=device_states, + ) + ], + ) + + # Should not crash; valve entity should still be available + state = hass.states.get(VALVE.entity_id) + assert state is not None From e53914a0ef991c4905f829c07f261962822f36f6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 1 Jun 2026 13:29:30 +0200 Subject: [PATCH 078/153] Fix MQTT device_tracker logging attributes order (#172732) --- homeassistant/components/mqtt/device_tracker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index a7dc4506c44cb..857b81470cda4 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -175,10 +175,10 @@ def _process_update_extra_state_attributes( self._attr_latitude = None self._attr_longitude = None _LOGGER.warning( - "Extra state attributes received at % and template %s " + "Extra state attributes received at %s and template %s " "contain invalid or incomplete location info. Got %s", - self._config.get(CONF_JSON_ATTRS_TEMPLATE), self._config.get(CONF_JSON_ATTRS_TOPIC), + self._config.get(CONF_JSON_ATTRS_TEMPLATE), extra_state_attributes, ) @@ -190,11 +190,11 @@ def _process_update_extra_state_attributes( self._attr_location_accuracy = gps_accuracy else: _LOGGER.warning( - "Extra state attributes received at % and template %s " + "Extra state attributes received at %s and template %s " "contain invalid GPS accuracy setting, " "gps_accuracy was set to 0 as the default. Got %s", - self._config.get(CONF_JSON_ATTRS_TEMPLATE), self._config.get(CONF_JSON_ATTRS_TOPIC), + self._config.get(CONF_JSON_ATTRS_TEMPLATE), extra_state_attributes, ) self._attr_location_accuracy = 0 From 9843fdad2ce86b08b4187edf245a0932419dd8f0 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 1 Jun 2026 16:01:32 +0200 Subject: [PATCH 079/153] Add missing `_attr_name = None` for Tractive device tracker (#172746) --- homeassistant/components/tractive/device_tracker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 47029baa103d0..75a9644012127 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -34,6 +34,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" _attr_translation_key = "tracker" + _attr_name = None def __init__(self, client: TractiveClient, item: Trackables) -> None: """Initialize tracker entity.""" From 86999063d7711d82bf8ad98b71bd2dd80bd80f09 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 1 Jun 2026 16:02:22 +0200 Subject: [PATCH 080/153] Translate the name of the Tractive tracker (#172747) --- homeassistant/components/tractive/entity.py | 3 ++- homeassistant/components/tractive/strings.json | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py index 48e80959d48e3..082a16c787bc2 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -29,7 +29,8 @@ def __init__( self._attr_device_info = DeviceInfo( configuration_url="https://my.tractive.com/", identifiers={(DOMAIN, tracker_details["_id"])}, - name=f"Tracker {tracker_details['_id']}", + translation_key="tracker", + translation_placeholders={"id": tracker_details["_id"]}, manufacturer="Tractive GmbH", sw_version=tracker_details["fw_version"], model_id=tracker_details["model_number"], diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index bf5e75a2bec73..b0ff950e7e4c4 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -20,6 +20,11 @@ } } }, + "device": { + "tracker": { + "name": "Tracker {id}" + } + }, "entity": { "binary_sensor": { "tracker_power_saving": { From 7950998083197b1ef4807d949deb32cc97f40fc7 Mon Sep 17 00:00:00 2001 From: jameson_uk <1040621+jamesonuk@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:43:46 +0100 Subject: [PATCH 081/153] Bump aioamazondevices to 13.8.2 (#172748) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 1c7842f6cac5c..2c39adf76e6bf 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.8.1"] + "requirements": ["aioamazondevices==13.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0cb78d1d4207b..199f560378f01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.8.1 +aioamazondevices==13.8.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 06b2ec22f0abac856e714c6756f009a6782dad81 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 1 Jun 2026 17:38:57 +0200 Subject: [PATCH 082/153] Bump yoto-api to 3.1.5 (#172753) --- homeassistant/components/yoto/manifest.json | 2 +- homeassistant/components/yoto/media_player.py | 7 ++++--- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/yoto/manifest.json b/homeassistant/components/yoto/manifest.json index 02f3b40e2e647..c1ab18b3c8851 100644 --- a/homeassistant/components/yoto/manifest.json +++ b/homeassistant/components/yoto/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_push", "loggers": ["yoto_api"], "quality_scale": "bronze", - "requirements": ["yoto-api==3.1.3"] + "requirements": ["yoto-api==3.1.5"] } diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index 2c5224b0f3eff..5aa472f39ff69 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -80,9 +80,10 @@ def available(self) -> bool: @property def state(self) -> MediaPlayerState: """Return the playback state.""" - return PLAYBACK_STATE_MAP.get( - self.player.last_event.playback_status, MediaPlayerState.IDLE - ) + status = self.player.last_event.playback_status + if status is None: + return MediaPlayerState.IDLE + return PLAYBACK_STATE_MAP.get(status, MediaPlayerState.IDLE) @property def volume_level(self) -> float | None: diff --git a/requirements_all.txt b/requirements_all.txt index 199f560378f01..ff13906134714 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3415,7 +3415,7 @@ yeelightsunflower==0.0.10 yolink-api==0.6.5 # homeassistant.components.yoto -yoto-api==3.1.3 +yoto-api==3.1.5 # homeassistant.components.youless youless-api==2.2.0 From 7b06228a5adb6cfa7a7269a17039a7e37db6b653 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 16:54:56 +0000 Subject: [PATCH 083/153] Bump version to 2026.6.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3b7dd647da7f0..0f024cf0a4884 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index d569543cdfd7d..8c8c052383391 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.6.0b0" +version = "2026.6.0b1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 84f4f876b19bdc9eed3b650f36fe18cbc944c87e Mon Sep 17 00:00:00 2001 From: jameson_uk <1040621+jamesonuk@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:03:02 +0100 Subject: [PATCH 084/153] media_player platform fixes for Alexa Devices (#172611) --- .../components/alexa_devices/coordinator.py | 21 ++++++++- .../components/alexa_devices/media_player.py | 24 +++++++--- .../components/alexa_devices/strings.json | 3 ++ .../alexa_devices/test_coordinator.py | 45 +++++++++++++++++++ .../alexa_devices/test_media_player.py | 25 +++++++++++ 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 80a693c50135c..4a67f5758172a 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -204,7 +204,26 @@ def vocal_records(self) -> dict[str, AmazonVocalRecord]: async def sync_media_state(self) -> None: """Sync media state.""" - await self.api.sync_media_state() + try: + await self.api.sync_media_state() + except CannotAuthenticate as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except (CannotConnect, TimeoutError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + except (CannotRetrieveData, ValueError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err async def media_state_event_handler( self, media_state: dict[str, AmazonMediaState] diff --git a/homeassistant/components/alexa_devices/media_player.py b/homeassistant/components/alexa_devices/media_player.py index e222ab02820cf..fe94b98b11840 100644 --- a/homeassistant/components/alexa_devices/media_player.py +++ b/homeassistant/components/alexa_devices/media_player.py @@ -156,9 +156,11 @@ def volume_level(self) -> float | None: @property def is_volume_muted(self) -> bool | None: """Return True if the volume is muted.""" - if not self.volume_state: + if not self.volume_state or self.volume_state.volume is None: return None - return self.volume_state.volume == 0 + # is_muted is True when Alexa has muted the device + # volume == 0 is where we have muted by setting volume to 0 + return self.volume_state.is_muted or self.volume_state.volume == 0 @property def media_title(self) -> str | None: @@ -259,12 +261,20 @@ async def async_mute_volume(self, mute: bool) -> None: return if mute: self._prev_volume = self.volume_state.volume - target_volume = 0 - else: - if self._prev_volume is None: - return - target_volume = self._prev_volume + await self.async_set_volume_level(0) + return + + if self.volume_state.is_muted and self._prev_volume is None: + # is muted by Alexa which we can see but not control + # when muted this way, volume is still set + # changing volume will unmute + # if HA set volume to 0 then Alexa muted we just default to 30% + self._prev_volume = self.volume_state.volume or 30 + if self._prev_volume is None: + return + target_volume = self._prev_volume await self.async_set_volume_level(target_volume / 100) + self._prev_volume = None @alexa_api_call async def _send_media_command(self, command: AmazonMediaControls) -> None: diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 0ec611a8d62c9..fcb8ab13b9c5e 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -125,6 +125,9 @@ }, "invalid_sound_value": { "message": "Invalid sound {sound} specified" + }, + "unknown_exception": { + "message": "Unknown error occurred: {error}" } }, "selector": { diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index 73854f0de76db..427d38e3a4b22 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -126,3 +126,48 @@ async def test_sync_history_state_error( await hass.async_block_till_done() assert mock_config_entry.state is expected_state + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + pytest.param( + CannotAuthenticate, + ConfigEntryState.SETUP_ERROR, + id="cannot_authenticate", + ), + pytest.param( + CannotConnect, + ConfigEntryState.SETUP_RETRY, + id="cannot_connect", + ), + pytest.param( + TimeoutError, + ConfigEntryState.SETUP_RETRY, + id="timeout_error", + ), + pytest.param( + CannotRetrieveData, + ConfigEntryState.SETUP_RETRY, + id="cannot_retrieve_data", + ), + pytest.param( + ValueError, + ConfigEntryState.SETUP_RETRY, + id="value_error", + ), + ], +) +async def test_sync_media_state_auth_failed( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_state: ConfigEntryState, +) -> None: + """Test setup fails with ConfigEntryAuthFailed when sync_media_state raises CannotAuthenticate.""" + mock_amazon_devices_client.sync_media_state.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state diff --git a/tests/components/alexa_devices/test_media_player.py b/tests/components/alexa_devices/test_media_player.py index 8d1399514e733..c470eeafa4730 100644 --- a/tests/components/alexa_devices/test_media_player.py +++ b/tests/components/alexa_devices/test_media_player.py @@ -700,3 +700,28 @@ async def test_unmute_volume_without_prev_volume_returns_early( ) mock_amazon_devices_client.set_device_volume.assert_not_awaited() + + +async def test_unmute_volume_when_alexa_muted_restores_current_volume( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Unmute restores current volume when device was muted directly by Alexa.""" + await _setup_media_player_platform(hass, mock_config_entry) + + await _push_volume_state( + mock_amazon_devices_client, + volume_state=AmazonVolumeState(volume=30, is_muted=True), + ) + await hass.async_block_till_done() + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: False}, + blocking=True, + ) + + mock_amazon_devices_client.set_device_volume.assert_awaited_once() + assert mock_amazon_devices_client.set_device_volume.call_args.args[1] == 30 From a0d713a4a734a9eef523ecf58af859b1d128869b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:39:28 +0200 Subject: [PATCH 085/153] Use proper user-agent to fetch feeds (#172655) --- .../components/feedreader/config_flow.py | 8 ++++++-- homeassistant/components/feedreader/const.py | 4 ++++ .../components/feedreader/coordinator.py | 9 ++++++++- tests/components/feedreader/const.py | 3 ++- tests/components/feedreader/test_config_flow.py | 5 ++++- tests/components/feedreader/test_init.py | 15 +++++++++++++++ 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 4afa4cf8dff46..4bb19944224d7 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -23,14 +23,18 @@ TextSelectorType, ) -from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN +from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN, USER_AGENT LOGGER = logging.getLogger(__name__) async def async_fetch_feed(hass: HomeAssistant, url: str) -> feedparser.FeedParserDict: """Fetch the feed.""" - return await hass.async_add_executor_job(feedparser.parse, url) + + def _parse_feed() -> feedparser.FeedParserDict: + return feedparser.parse(url, agent=USER_AGENT) + + return await hass.async_add_executor_job(_parse_feed) class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/feedreader/const.py b/homeassistant/components/feedreader/const.py index efaa0e9d97236..8a216e0d3cf15 100644 --- a/homeassistant/components/feedreader/const.py +++ b/homeassistant/components/feedreader/const.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Final +from homeassistant.const import APPLICATION_NAME, __version__ as ha_version + DOMAIN: Final[str] = "feedreader" CONF_MAX_ENTRIES: Final[str] = "max_entries" @@ -10,3 +12,5 @@ DEFAULT_SCAN_INTERVAL: Final[timedelta] = timedelta(hours=1) EVENT_FEEDREADER: Final[str] = "feedreader" + +USER_AGENT: Final[str] = f"{APPLICATION_NAME}/{ha_version}" diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index 7b2d4a890377a..31af12d144b12 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -18,7 +18,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER +from .const import ( + CONF_MAX_ENTRIES, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + EVENT_FEEDREADER, + USER_AGENT, +) DELAY_SAVE = 30 STORAGE_VERSION = 1 @@ -74,6 +80,7 @@ def _parse_feed() -> feedparser.FeedParserDict: self.url, etag=None if not self._feed else self._feed.get("etag"), modified=None if not self._feed else self._feed.get("modified"), + agent=USER_AGENT, ) feed = await self.hass.async_add_executor_job(_parse_feed) diff --git a/tests/components/feedreader/const.py b/tests/components/feedreader/const.py index bbd0f82bcfa0f..a394265a3331a 100644 --- a/tests/components/feedreader/const.py +++ b/tests/components/feedreader/const.py @@ -4,9 +4,10 @@ CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, ) -from homeassistant.const import CONF_URL +from homeassistant.const import APPLICATION_NAME, CONF_URL, __version__ as ha_version URL = "http://some.rss.local/rss_feed.xml" +USER_AGENT = f"{APPLICATION_NAME}/{ha_version}" FEED_TITLE = "RSS Sample" VALID_CONFIG_DEFAULT = {CONF_URL: URL, CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES} VALID_CONFIG_100 = {CONF_URL: URL, CONF_MAX_ENTRIES: 100} diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py index c9fc89179dbc4..6dfcf863395e8 100644 --- a/tests/components/feedreader/test_config_flow.py +++ b/tests/components/feedreader/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.data_entry_flow import FlowResultType from . import create_mock_entry -from .const import FEED_TITLE, URL, VALID_CONFIG_DEFAULT +from .const import FEED_TITLE, URL, USER_AGENT, VALID_CONFIG_DEFAULT @pytest.fixture(name="feedparser") @@ -53,6 +53,9 @@ async def test_user(hass: HomeAssistant, feedparser, setup_entry) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_URL: URL} ) + # check user-agent + assert feedparser.call_args.args[3] == USER_AGENT + # check flow result assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == FEED_TITLE assert result["data"][CONF_URL] == URL diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 0a336141e69dd..f7d64eafbe88b 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -19,6 +19,7 @@ from . import async_setup_config_entry, create_mock_entry from .const import ( URL, + USER_AGENT, VALID_CONFIG_1, VALID_CONFIG_5, VALID_CONFIG_100, @@ -398,3 +399,17 @@ async def test_feed_atom_htmlentities( identifiers={(DOMAIN, entry.entry_id)} ) assert device_entry.manufacturer == "Juan Pérez" + + +async def test_feedparser_user_agent(hass: HomeAssistant, feed_one_event) -> None: + """Test that the correct user-agent is used for feedparser requests.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get" + ) as feedparser: + feedparser.return_value = feed_one_event + await hass.config_entries.async_setup(entry.entry_id) + + # check user-agent + assert feedparser.call_args.args[3] == USER_AGENT From e5f9c7892a7b3ac6f39cc61a1460b58dccdb393b Mon Sep 17 00:00:00 2001 From: "Thijs W." Date: Mon, 1 Jun 2026 21:21:26 +0200 Subject: [PATCH 086/153] Fix get_play_status function call in frontier silicon (#172705) --- homeassistant/components/frontier_silicon/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index bb33aa3938837..55a4880995b8b 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -320,7 +320,7 @@ async def async_turn_off(self) -> None: @fs_command_exception_wrap async def async_media_play(self) -> None: """Send play command.""" - if (await self.fs_device.get_play_state()) == PlayState.STOPPED: + if (await self.fs_device.get_play_status()) == PlayState.STOPPED: # The 'play' command only seems to work when the current stream is paused. # We need to send a 'stop' command instead to resume a stopped stream. await self.fs_device.stop() From 5e56d74257739725ce36edf994985dd19c627ade Mon Sep 17 00:00:00 2001 From: "A. Gideonse" Date: Mon, 1 Jun 2026 11:01:07 +0200 Subject: [PATCH 087/153] Bump indevolt-api to 1.8.3 (#172683) --- homeassistant/components/indevolt/__init__.py | 27 +++++++++++++- .../components/indevolt/config_flow.py | 1 + .../components/indevolt/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/indevolt/conftest.py | 21 +++++++++++ tests/components/indevolt/fixtures/gen_2.json | 2 +- .../snapshots/test_binary_sensor.ambr | 2 +- .../indevolt/snapshots/test_diagnostics.ambr | 2 +- tests/components/indevolt/test_init.py | 37 ++++++++++++++++++- 9 files changed, 88 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/indevolt/__init__.py b/homeassistant/components/indevolt/__init__.py index 32283056fdc40..907819e123fe8 100644 --- a/homeassistant/components/indevolt/__init__.py +++ b/homeassistant/components/indevolt/__init__.py @@ -1,7 +1,10 @@ """Home Assistant integration for indevolt device.""" +from typing import Any + from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -20,6 +23,28 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +async def async_migrate_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1 and entry.minor_version < 2: + # 1.1 -> 1.2: indevolt-api 1.8.3 changed IndevoltBattery.MAIN_HEATING_STATE + # from 9079 to 9080, so migrate affected unique IDs. + @callback + def migrate_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, Any] | None: + if entity_entry.unique_id.endswith("_9079"): + return { + "new_unique_id": entity_entry.unique_id.removesuffix("_9079") + + "_9080" + } + return None + + await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) + hass.config_entries.async_update_entry(entry, version=1, minor_version=2) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: """Set up indevolt integration entry using given configuration.""" coordinator = IndevoltCoordinator(hass, entry) diff --git a/homeassistant/components/indevolt/config_flow.py b/homeassistant/components/indevolt/config_flow.py index 3e79161b3d36f..7039d426704ac 100644 --- a/homeassistant/components/indevolt/config_flow.py +++ b/homeassistant/components/indevolt/config_flow.py @@ -22,6 +22,7 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN): """Configuration flow for Indevolt integration.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" diff --git a/homeassistant/components/indevolt/manifest.json b/homeassistant/components/indevolt/manifest.json index 6deaffa7403b3..c0e12f4d4c65d 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -8,6 +8,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["indevolt-api==1.8.2"], + "requirements": ["indevolt-api==1.8.3"], "zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }] } diff --git a/requirements_all.txt b/requirements_all.txt index ff13906134714..16d58b29249a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1350,7 +1350,7 @@ imgw_pib==2.2.0 incomfort-client==0.7.0 # homeassistant.components.indevolt -indevolt-api==1.8.2 +indevolt-api==1.8.3 # homeassistant.components.influxdb influxdb-client==1.50.0 diff --git a/tests/components/indevolt/conftest.py b/tests/components/indevolt/conftest.py index 9b13aabf7715b..65ec6b2c05f61 100644 --- a/tests/components/indevolt/conftest.py +++ b/tests/components/indevolt/conftest.py @@ -64,6 +64,26 @@ def mock_config_entry(generation: int) -> MockConfigEntry: domain=DOMAIN, title=device_info["device"], version=1, + minor_version=2, + data={ + CONF_HOST: device_info["host"], + CONF_SERIAL_NUMBER: device_info["sn"], + CONF_MODEL: device_info["device"], + CONF_GENERATION: device_info["generation"], + }, + unique_id=device_info["sn"], + ) + + +@pytest.fixture +def mock_config_entry_v1_1(generation: int) -> MockConfigEntry: + """Return a mocked config entry with version 1.1 for migration testing.""" + device_info = DEVICE_MAPPING[generation] + return MockConfigEntry( + domain=DOMAIN, + title=device_info["device"], + version=1, + minor_version=1, data={ CONF_HOST: device_info["host"], CONF_SERIAL_NUMBER: device_info["sn"], @@ -82,6 +102,7 @@ def alt_mock_config_entry(alt_generation: int) -> MockConfigEntry: domain=DOMAIN, title=device_info["device"], version=1, + minor_version=2, data={ CONF_HOST: device_info["host"], CONF_SERIAL_NUMBER: device_info["sn"], diff --git a/tests/components/indevolt/fixtures/gen_2.json b/tests/components/indevolt/fixtures/gen_2.json index 56d0b18d1c374..e33ca3ca40911 100644 --- a/tests/components/indevolt/fixtures/gen_2.json +++ b/tests/components/indevolt/fixtures/gen_2.json @@ -24,7 +24,7 @@ "6006": 380.58, "6007": 338.07, "7120": 1001, - "9079": 1, + "9080": 1, "9096": 1, "9112": 0, "9128": 1, diff --git a/tests/components/indevolt/snapshots/test_binary_sensor.ambr b/tests/components/indevolt/snapshots/test_binary_sensor.ambr index e18c4c3425e19..78b8ae266666c 100644 --- a/tests/components/indevolt/snapshots/test_binary_sensor.ambr +++ b/tests/components/indevolt/snapshots/test_binary_sensor.ambr @@ -383,7 +383,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'main_electric_heating_state', - 'unique_id': 'SolidFlex2000-87654321_9079', + 'unique_id': 'SolidFlex2000-87654321_9080', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/indevolt/snapshots/test_diagnostics.ambr b/tests/components/indevolt/snapshots/test_diagnostics.ambr index 6f143b00a5799..8797eeeedeaa8 100644 --- a/tests/components/indevolt/snapshots/test_diagnostics.ambr +++ b/tests/components/indevolt/snapshots/test_diagnostics.ambr @@ -136,7 +136,7 @@ '9058': 51.1, '9068': 25.0, '9070': '**REDACTED**', - '9079': 1, + '9080': 1, '9085': 31.5, '9096': 1, '9101': 32.8, diff --git a/tests/components/indevolt/test_init.py b/tests/components/indevolt/test_init.py index 3f495e6d99ae8..3a0daa247edfa 100644 --- a/tests/components/indevolt/test_init.py +++ b/tests/components/indevolt/test_init.py @@ -4,13 +4,14 @@ import pytest +from homeassistant.components.indevolt.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from . import setup_integration -from .conftest import DEVICE_MAPPING +from .conftest import DEVICE_MAPPING, TEST_DEVICE_SN_GEN2 from tests.common import MockConfigEntry @@ -72,3 +73,35 @@ async def test_load_failure( # Verify the config entry enters retry state due to failure assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("generation", [2], indirect=True) +async def test_migrate_main_heating_state_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_indevolt: AsyncMock, + mock_config_entry_v1_1: MockConfigEntry, +) -> None: + """Test migration of MAIN_HEATING_STATE unique ID from 9079 to 9080.""" + mock_config_entry_v1_1.add_to_hass(hass) + + old_unique_id = f"{TEST_DEVICE_SN_GEN2}_9079" + new_unique_id = f"{TEST_DEVICE_SN_GEN2}_9080" + + entity_registry.async_get_or_create( + "binary_sensor", + DOMAIN, + old_unique_id, + config_entry=mock_config_entry_v1_1, + ) + + assert mock_config_entry_v1_1.minor_version == 1 + + await hass.config_entries.async_setup(mock_config_entry_v1_1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1_1.minor_version == 2 + assert entity_registry.async_get_entity_id("binary_sensor", DOMAIN, new_unique_id) + assert not entity_registry.async_get_entity_id( + "binary_sensor", DOMAIN, old_unique_id + ) From 3dda7d984878c85262d979cdbed2b3921ebea4e3 Mon Sep 17 00:00:00 2001 From: "A. Gideonse" Date: Mon, 1 Jun 2026 20:39:30 +0200 Subject: [PATCH 088/153] Fix binary sensor defaults for Indevolt (#172714) --- homeassistant/components/indevolt/binary_sensor.py | 8 ++------ tests/components/indevolt/fixtures/gen_2.json | 12 ++++++------ .../indevolt/snapshots/test_diagnostics.ambr | 12 ++++++------ 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/indevolt/binary_sensor.py b/homeassistant/components/indevolt/binary_sensor.py index 1be4bf6364a50..2e372d35a0322 100644 --- a/homeassistant/components/indevolt/binary_sensor.py +++ b/homeassistant/components/indevolt/binary_sensor.py @@ -25,8 +25,8 @@ class IndevoltBinarySensorEntityDescription(BinarySensorEntityDescription): """Custom entity description class for Indevolt binary sensors.""" - on_value: int = 1 - off_value: int = 0 + on_value: int = 1000 + off_value: int = 1001 generation: tuple[int, ...] = (1, 2) @@ -35,8 +35,6 @@ class IndevoltBinarySensorEntityDescription(BinarySensorEntityDescription): IndevoltBinarySensorEntityDescription( key=IndevoltGrid.METER_CONNECTED, translation_key="meter_connected", - on_value=1000, - off_value=1001, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -46,8 +44,6 @@ class IndevoltBinarySensorEntityDescription(BinarySensorEntityDescription): key=IndevoltSystem.HEATING_STATE, generation=(1,), translation_key="electric_heating_state", - on_value=1000, - off_value=1001, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), diff --git a/tests/components/indevolt/fixtures/gen_2.json b/tests/components/indevolt/fixtures/gen_2.json index e33ca3ca40911..323307c038d32 100644 --- a/tests/components/indevolt/fixtures/gen_2.json +++ b/tests/components/indevolt/fixtures/gen_2.json @@ -24,12 +24,12 @@ "6006": 380.58, "6007": 338.07, "7120": 1001, - "9080": 1, - "9096": 1, - "9112": 0, - "9128": 1, - "9144": 0, - "9279": 1, + "9080": 1000, + "9096": 1000, + "9112": 1001, + "9128": 1000, + "9144": 1001, + "9279": 1000, "11016": 0, "2600": 1200, "2612": 50.0, diff --git a/tests/components/indevolt/snapshots/test_diagnostics.ambr b/tests/components/indevolt/snapshots/test_diagnostics.ambr index 8797eeeedeaa8..e9204025047fe 100644 --- a/tests/components/indevolt/snapshots/test_diagnostics.ambr +++ b/tests/components/indevolt/snapshots/test_diagnostics.ambr @@ -136,15 +136,15 @@ '9058': 51.1, '9068': 25.0, '9070': '**REDACTED**', - '9080': 1, + '9080': 1000, '9085': 31.5, - '9096': 1, + '9096': 1000, '9101': 32.8, - '9112': 0, + '9112': 1001, '9117': 31.9, - '9128': 1, + '9128': 1000, '9133': 33.0, - '9144': 0, + '9144': 1001, '9149': 94, '9152': 125, '9153': 51.4, @@ -156,7 +156,7 @@ '9216': 24.9, '9218': '**REDACTED**', '9270': 31.2, - '9279': 1, + '9279': 1000, }), 'device': dict({ 'firmware_version': '1.2.3', From 323304664ec16a096b9931bb00ebc3d587625501 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 14:13:12 -0500 Subject: [PATCH 089/153] Explain why an Airthings BLE device could not be found (#172758) --- .../components/airthings_ble/coordinator.py | 12 ++++++- .../components/airthings_ble/strings.json | 5 +++ tests/components/airthings_ble/test_init.py | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index dcecf26c7da3c..409db9418098b 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -8,6 +8,7 @@ from bleak_retry_connector import close_stale_connections_by_address from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -63,7 +64,16 @@ async def _async_setup(self) -> None: if not ble_device: raise ConfigEntryNotReady( - f"Could not find Airthings device with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + self.hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) self.ble_device = ble_device diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index 6e1143fb97905..2e876f0c1e691 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -54,5 +54,10 @@ "name": "Radon longterm level" } } + }, + "exceptions": { + "device_not_found": { + "message": "Could not find Airthings device with address {address}: {reason}" + } } } diff --git a/tests/components/airthings_ble/test_init.py b/tests/components/airthings_ble/test_init.py index 50351f2290dc3..7cf03940190d9 100644 --- a/tests/components/airthings_ble/test_init.py +++ b/tests/components/airthings_ble/test_init.py @@ -1,6 +1,7 @@ """Test the Airthings BLE integration init.""" from copy import deepcopy +from unittest.mock import patch from airthings_ble import AirthingsDeviceType from freezegun.api import FrozenDateTimeFactory @@ -12,6 +13,7 @@ DEVICE_SPECIFIC_SCAN_INTERVAL, DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import ( @@ -66,6 +68,36 @@ async def test_migration_existing_entries( assert entry.data[DEVICE_MODEL] == device_info.model.value +async def test_setup_retries_when_device_not_found( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup is retried with a diagnostic reason when the device is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + data={DEVICE_MODEL: WAVE_DEVICE_INFO.model.value}, + ) + entry.add_to_hass(hass) + + with ( + patch_async_ble_device_from_address(None), + patch( + "homeassistant.components.airthings_ble.coordinator.bluetooth." + "async_address_reachability_diagnostics", + return_value="mock reachability reason", + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert ( + "Could not find Airthings device with address " + f"{WAVE_SERVICE_INFO.address}: mock reachability reason" in caplog.text + ) + + async def test_no_migration_when_device_model_exists( hass: HomeAssistant, ) -> None: From 51c9d0c6e55082093833ec2feb4c25a6cefaf10f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 1 Jun 2026 22:52:30 +0200 Subject: [PATCH 090/153] Bump frontend to 20260527.2 (#172759) Co-authored-by: Franck Nijhof --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 222a33816f833..dd098b7d63c5f 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260527.1"] + "requirements": ["home-assistant-frontend==20260527.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a208df50cda37..4ba8714da4e0e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.8.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260527.1 +home-assistant-frontend==20260527.2 home-assistant-intents==2026.5.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 090bb31ed4154..222de95d497ce 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260527.1" +FRONTEND_VERSION: Final[str] = "20260527.2" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index 16d58b29249a7..a09726e63090d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1266,7 +1266,7 @@ hole==0.9.0 holidays==0.97 # homeassistant.components.frontend -home-assistant-frontend==20260527.1 +home-assistant-frontend==20260527.2 # homeassistant.components.conversation home-assistant-intents==2026.5.5 From 6057d326361e4c89bd690652ae022b5123f62ce3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 14:14:52 -0500 Subject: [PATCH 091/153] Explain why a Yale Access Bluetooth device could not be found (#172761) --- .../components/yalexs_ble/__init__.py | 16 ++++- .../components/yalexs_ble/strings.json | 5 ++ tests/components/yalexs_ble/test_init.py | 61 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/components/yalexs_ble/test_init.py diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 26231a8d58eca..539db81dc054f 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -12,6 +12,7 @@ ) from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback @@ -24,6 +25,7 @@ CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, + DOMAIN, ) from .models import YaleXSBLEData from .util import async_find_existing_service_info, bluetooth_callback_matcher @@ -83,7 +85,19 @@ def _async_shutdown(event: Event | None = None) -> None: # If we are starting and the advertisement is not found, do not delay # the setup. We will wait for the advertisement to be found and then # discovery will trigger setup retry. - raise ConfigEntryNotReady("{local_name} ({address}) not advertising yet") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_not_advertising", + translation_placeholders={ + "local_name": local_name, + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, + ) entry.async_on_unload( bluetooth.async_register_callback( diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index c4e4210a0b64e..5951301075996 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -53,6 +53,11 @@ } } }, + "exceptions": { + "device_not_advertising": { + "message": "{local_name} ({address}) is not advertising yet: {reason}" + } + }, "options": { "step": { "device_options": { diff --git a/tests/components/yalexs_ble/test_init.py b/tests/components/yalexs_ble/test_init.py new file mode 100644 index 0000000000000..1ad6f260c2bfc --- /dev/null +++ b/tests/components/yalexs_ble/test_init.py @@ -0,0 +1,61 @@ +"""Test the Yale Access Bluetooth init.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.yalexs_ble.const import ( + CONF_KEY, + CONF_LOCAL_NAME, + CONF_SLOT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import CoreState, HomeAssistant + +from . import YALE_ACCESS_LOCK_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_setup_retries_when_not_advertising_at_startup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup is retried with a diagnostic reason when not advertising at startup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + }, + unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + + hass.set_state(CoreState.starting) + + push_lock = MagicMock() + push_lock.start = AsyncMock(return_value=MagicMock()) + + with ( + patch("homeassistant.components.yalexs_ble.close_stale_connections_by_address"), + patch("homeassistant.components.yalexs_ble.PushLock", return_value=push_lock), + patch( + "homeassistant.components.yalexs_ble.bluetooth." + "async_address_reachability_diagnostics", + return_value="mock reachability reason", + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert ( + f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} " + f"({YALE_ACCESS_LOCK_DISCOVERY_INFO.address}) is not advertising yet: " + "mock reachability reason" in caplog.text + ) From c09216650f8f93c61aba231a69e7dfbe2d999866 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 14:12:49 -0500 Subject: [PATCH 092/153] Explain why an INKBIRD device could not be found (#172762) --- homeassistant/components/inkbird/coordinator.py | 11 ++++++++++- homeassistant/components/inkbird/strings.json | 2 +- tests/components/inkbird/test_sensor.py | 17 ++++++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index f9d6face867e4..5640af75a7e5f 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -7,9 +7,11 @@ from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, + async_address_reachability_diagnostics, async_ble_device_from_address, async_last_service_info, ) @@ -84,7 +86,14 @@ async def async_init(self) -> None: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="no_advertisement", - translation_placeholders={"address": self.address}, + translation_placeholders={ + "address": self.address, + "reason": async_address_reachability_diagnostics( + self.hass, + self.address.upper(), + BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT, + ), + }, ) await self._data.async_start(service_info, service_info.device) self._entry.async_on_unload(self._data.async_stop) diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 46cc7ae374c7e..e2f1afb0be875 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -20,7 +20,7 @@ }, "exceptions": { "no_advertisement": { - "message": "The device with address {address} is not advertising; Make sure it is in range and powered on." + "message": "The device with address {address} is not advertising: {reason}" } } } diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 9509dccabffef..13b125abf9eb2 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -14,6 +14,7 @@ Units, ) from inkbird_ble.parser import Model +import pytest from sensor_state_data import SensorDeviceClass from homeassistant.components.bluetooth import async_last_service_info @@ -210,7 +211,9 @@ async def test_fallback_poll_queries_latest_service_info(hass: HomeAssistant) -> await hass.async_block_till_done() -async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None: +async def test_notify_sensor_no_advertisement( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test setting up a notify sensor that has no advertisement.""" entry = MockConfigEntry( domain=DOMAIN, @@ -219,10 +222,18 @@ async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.inkbird.coordinator." + "async_address_reachability_diagnostics", + return_value="mock reachability reason", + ): + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + assert ( + "62:00:A1:3C:AE:7B is not advertising: mock reachability reason" in caplog.text + ) async def test_notify_sensor(hass: HomeAssistant) -> None: From 0bfd4c44bb8e4902ed92f7afd5ac736836dc5761 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 14:11:09 -0500 Subject: [PATCH 093/153] Explain why a LED BLE device could not be found (#172764) --- homeassistant/components/led_ble/__init__.py | 14 ++++++- homeassistant/components/led_ble/strings.json | 5 +++ tests/components/led_ble/test_init.py | 41 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/components/led_ble/test_init.py diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 14a41952b44da..a6f8bf56efd46 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -5,12 +5,13 @@ from led_ble import LEDBLE from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import DEVICE_TIMEOUT +from .const import DEVICE_TIMEOUT, DOMAIN from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator, LEDBLEData PLATFORMS: list[Platform] = [Platform.LIGHT] @@ -22,7 +23,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bo ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) if not ble_device: raise ConfigEntryNotReady( - f"Could not find LED BLE device with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) led_ble = LEDBLE(ble_device) diff --git a/homeassistant/components/led_ble/strings.json b/homeassistant/components/led_ble/strings.json index 7d777781ab11d..0cc44653ce2f9 100644 --- a/homeassistant/components/led_ble/strings.json +++ b/homeassistant/components/led_ble/strings.json @@ -18,5 +18,10 @@ } } } + }, + "exceptions": { + "device_not_found": { + "message": "Could not find LED BLE device with address {address}: {reason}" + } } } diff --git a/tests/components/led_ble/test_init.py b/tests/components/led_ble/test_init.py new file mode 100644 index 0000000000000..f5b1bdf3d5d76 --- /dev/null +++ b/tests/components/led_ble/test_init.py @@ -0,0 +1,41 @@ +"""Test the LED BLE integration init.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.led_ble.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import LED_BLE_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_setup_retries_when_device_not_found( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup is retried with a diagnostic reason when the device is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LED_BLE_DISCOVERY_INFO.address, + data={CONF_ADDRESS: LED_BLE_DISCOVERY_INFO.address}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.led_ble.bluetooth." + "async_address_reachability_diagnostics", + return_value="mock reachability reason", + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert ( + "Could not find LED BLE device with address " + f"{LED_BLE_DISCOVERY_INFO.address}: mock reachability reason" in caplog.text + ) From b49a6b89b6e60e2f5f366c28d44a2964af71556d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 14:41:55 -0500 Subject: [PATCH 094/153] Bump habluetooth to 6.8.1 (#172768) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 9cada8b157048..aa7dc564f1450 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.6.4", "bluetooth-data-tools==1.29.18", "dbus-fast==5.0.16", - "habluetooth==6.8.0" + "habluetooth==6.8.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4ba8714da4e0e..91720420417e9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ file-read-backwards==2.0.0 fnv-hash-fast==2.0.3 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 -habluetooth==6.8.0 +habluetooth==6.8.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index a09726e63090d..123ce1c607af9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5 habiticalib==0.4.7 # homeassistant.components.bluetooth -habluetooth==6.8.0 +habluetooth==6.8.1 # homeassistant.components.hanna hanna-cloud==0.0.7 From 8622f0f4dec1e4094df5153ead6355454d5eb85d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:53:15 -0500 Subject: [PATCH 095/153] Explain why an eQ-3 Bluetooth device could not be found (#172770) --- .../components/eq3btsmart/__init__.py | 14 ++++++- .../components/eq3btsmart/strings.json | 5 +++ tests/components/eq3btsmart/test_init.py | 42 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/components/eq3btsmart/test_init.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 957d17a55d4a9..9472ea7008a25 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -8,13 +8,14 @@ from eq3btsmart.exceptions import Eq3Exception from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED +from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ @@ -49,7 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: if device is None: raise ConfigEntryNotReady( - f"[{eq3_config.mac_address}] Device could not be found" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "mac_address": eq3_config.mac_address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + mac_address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) thermostat = Thermostat(device) diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index c98a47b2d5c14..d7ed397dcd593 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -61,5 +61,10 @@ "name": "Lock" } } + }, + "exceptions": { + "device_not_found": { + "message": "[{mac_address}] Device could not be found: {reason}" + } } } diff --git a/tests/components/eq3btsmart/test_init.py b/tests/components/eq3btsmart/test_init.py new file mode 100644 index 0000000000000..e8cb060959d29 --- /dev/null +++ b/tests/components/eq3btsmart/test_init.py @@ -0,0 +1,42 @@ +"""Test the eq3btsmart integration init.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.eq3btsmart.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from .const import MAC + +from tests.common import MockConfigEntry + + +async def test_setup_retries_when_device_not_found( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup is retried with a diagnostic reason when the device is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_MAC: MAC}, + unique_id=format_mac(MAC), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.eq3btsmart.bluetooth." + "async_address_reachability_diagnostics", + return_value="mock reachability reason", + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert ( + f"[{format_mac(MAC)}] Device could not be found: mock reachability reason" + in caplog.text + ) From 4fbc36396544720cba4ab50746726017bb327238 Mon Sep 17 00:00:00 2001 From: Ingo Fischer Date: Mon, 1 Jun 2026 21:23:41 +0200 Subject: [PATCH 096/153] Filter stale replayed BLE advertisements in Matter BLE proxy (#172773) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/matter/ble_proxy.py | 8 ++++ tests/components/matter/test_ble_proxy.py | 41 +++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/ble_proxy.py b/homeassistant/components/matter/ble_proxy.py index 9e6a93d7080ca..ed68476e69312 100644 --- a/homeassistant/components/matter/ble_proxy.py +++ b/homeassistant/components/matter/ble_proxy.py @@ -23,6 +23,7 @@ ) from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, BluetoothScanningMode, async_ble_device_from_address, async_register_callback, @@ -51,11 +52,18 @@ async def start( # pylint: disable=arguments-renamed if self._cancel is not None: return + # Drop HA's synchronous replay of stale history on register; otherwise a + # rotating peripheral's old addresses each become a parallel connect candidate. + # `MONOTONIC_TIME` is the clock that stamps `service_info.time`. + scan_start = MONOTONIC_TIME() + @callback def _on_advertisement( service_info: BluetoothServiceInfoBleak, _change: object, ) -> None: + if service_info.time < scan_start: + return try: callback_fn(_to_advertisement_data(service_info)) except Exception: diff --git a/tests/components/matter/test_ble_proxy.py b/tests/components/matter/test_ble_proxy.py index e6c7c2b3059b3..47072dbdb5d8e 100644 --- a/tests/components/matter/test_ble_proxy.py +++ b/tests/components/matter/test_ble_proxy.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant -def _make_service_info() -> BluetoothServiceInfoBleak: +def _make_service_info(time: float | None = None) -> BluetoothServiceInfoBleak: """Return a real BluetoothServiceInfoBleak with realistic field values.""" address = "AA:BB:CC:DD:EE:FF" name = "TestDevice" @@ -37,7 +37,7 @@ def _make_service_info() -> BluetoothServiceInfoBleak: device=BLEDevice(name=name, address=address, details={}), advertisement=None, connectable=True, - time=monotonic_time_coarse(), + time=monotonic_time_coarse() if time is None else time, tx_power=0, raw=None, ) @@ -152,6 +152,43 @@ def fake_register(hass_, cb, _matcher, _mode): assert forwarded[0].address == "AA:BB:CC:DD:EE:FF" +@pytest.mark.parametrize( + ("advert_time", "expected_count"), + [ + pytest.param(999.0, 0, id="stale-before-scan-start-dropped"), + pytest.param(1000.0, 1, id="equal-scan-start-forwarded"), + pytest.param(1001.0, 1, id="fresh-after-scan-start-forwarded"), + ], +) +async def test_scan_source_drops_replayed_history( + hass: HomeAssistant, advert_time: float, expected_count: int +) -> None: + """Adverts older than the registration instant (HA history replay) are dropped.""" + forwarded: list[AdvertisementData] = [] + captured: dict[str, object] = {} + + def fake_register(hass_, cb, _matcher, _mode): + captured["cb"] = cb + return MagicMock() + + source = HaBluetoothScanSource(hass) + with ( + patch( + "homeassistant.components.matter.ble_proxy.async_register_callback", + side_effect=fake_register, + ), + patch( + "homeassistant.components.matter.ble_proxy.MONOTONIC_TIME", + return_value=1000.0, + ), + ): + await source.start(forwarded.append) + + captured["cb"](_make_service_info(time=advert_time), object()) + + assert len(forwarded) == expected_count + + async def test_scan_source_callback_swallows_exceptions( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From c82d32bbae1f50c439213a6b5b0875941bc6f5a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:57:19 -0500 Subject: [PATCH 097/153] Explain why a Husqvarna Automower BLE device could not be connected to (#172774) --- .../husqvarna_automower_ble/__init__.py | 13 +++++++++++- .../husqvarna_automower_ble/strings.json | 3 +++ .../husqvarna_automower_ble/test_init.py | 20 +++++++++++++++---- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index 4eb853ff931cd..ff048b5194622 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -6,6 +6,7 @@ from bleak_retry_connector import close_stale_connections_by_address, get_device from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform from homeassistant.core import HomeAssistant @@ -56,7 +57,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> ) except (TimeoutError, BleakError) as exception: raise ConfigEntryNotReady( - f"Unable to connect to device {address} due to {exception}" + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={ + "address": address, + "error": str(exception) or type(exception).__name__, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) from exception LOGGER.debug("connected and paired") diff --git a/homeassistant/components/husqvarna_automower_ble/strings.json b/homeassistant/components/husqvarna_automower_ble/strings.json index ef366540db92c..edf93d1becea7 100644 --- a/homeassistant/components/husqvarna_automower_ble/strings.json +++ b/homeassistant/components/husqvarna_automower_ble/strings.json @@ -45,6 +45,9 @@ } }, "exceptions": { + "connection_failed": { + "message": "Unable to connect to device {address} due to {error}: {reason}" + }, "pin_required": { "message": "PIN is required for {domain_name}" } diff --git a/tests/components/husqvarna_automower_ble/test_init.py b/tests/components/husqvarna_automower_ble/test_init.py index f10ae1fa7430a..83c7e6d3162f8 100644 --- a/tests/components/husqvarna_automower_ble/test_init.py +++ b/tests/components/husqvarna_automower_ble/test_init.py @@ -1,6 +1,6 @@ """Test the Husqvarna Automower Bluetooth setup.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch from automower_ble.protocol import ResponseResult import pytest @@ -75,16 +75,28 @@ async def test_setup_failed_connect( hass: HomeAssistant, mock_automower_client: Mock, mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test setup creates expected devices.""" + """Test setup retries with a diagnostic reason when the device cannot connect.""" mock_automower_client.connect.side_effect = TimeoutError mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.husqvarna_automower_ble.bluetooth." + "async_address_reachability_diagnostics", + return_value="mock reachability reason", + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + # A bare TimeoutError has an empty str(), so the error falls back to the + # exception class name; both the error and the reachability reason appear. + assert ( + f"Unable to connect to device {mock_config_entry.data[CONF_ADDRESS]} " + "due to TimeoutError: mock reachability reason" in caplog.text + ) async def test_setup_unknown_error( From 6f59bb0661f921111004a6b0556d333da37e2010 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:54:46 -0500 Subject: [PATCH 098/153] Explain why an LD2410 BLE device could not be found (#172779) --- .../components/ld2410_ble/__init__.py | 13 ++++- .../components/ld2410_ble/strings.json | 5 ++ tests/components/ld2410_ble/test_init.py | 47 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/components/ld2410_ble/test_init.py diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index 1a9f3cc57e6b3..0bc5374c7ece2 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -10,11 +10,13 @@ from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothReachabilityIntent from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from .const import DOMAIN from .coordinator import LD2410BLECoordinator from .models import LD2410BLEConfigEntry, LD2410BLEData @@ -34,7 +36,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) -> ) or await get_device(address) if not ble_device: raise ConfigEntryNotReady( - f"Could not find LD2410B device with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": bluetooth.async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) ld2410_ble = LD2410BLE(ble_device) diff --git a/homeassistant/components/ld2410_ble/strings.json b/homeassistant/components/ld2410_ble/strings.json index 769cbb6a652cb..ad6b07b7581c7 100644 --- a/homeassistant/components/ld2410_ble/strings.json +++ b/homeassistant/components/ld2410_ble/strings.json @@ -97,5 +97,10 @@ "name": "Static target energy" } } + }, + "exceptions": { + "device_not_found": { + "message": "Could not find LD2410B device with address {address}: {reason}" + } } } diff --git a/tests/components/ld2410_ble/test_init.py b/tests/components/ld2410_ble/test_init.py new file mode 100644 index 0000000000000..606409d530f1f --- /dev/null +++ b/tests/components/ld2410_ble/test_init.py @@ -0,0 +1,47 @@ +"""Test the LD2410 BLE integration init.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.ld2410_ble.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import LD2410_BLE_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_setup_retries_when_device_not_found( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup is retried with a diagnostic reason when the device is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LD2410_BLE_DISCOVERY_INFO.address, + data={CONF_ADDRESS: LD2410_BLE_DISCOVERY_INFO.address}, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.ld2410_ble.get_device", + return_value=None, + ), + patch( + "homeassistant.components.ld2410_ble.bluetooth." + "async_address_reachability_diagnostics", + return_value="mock reachability reason", + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert ( + "Could not find LD2410B device with address " + f"{LD2410_BLE_DISCOVERY_INFO.address}: mock reachability reason" in caplog.text + ) From 5ff07fcc4944db6f889f5712bb47cbf287278b36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jun 2026 15:54:11 -0500 Subject: [PATCH 099/153] Explain why a Snooz device could not be found (#172780) --- homeassistant/components/snooz/__init__.py | 19 +++++++-- homeassistant/components/snooz/strings.json | 5 +++ tests/components/snooz/test_init.py | 43 ++++++++++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py index 1291825efaa30..d485723844f79 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -4,12 +4,16 @@ from pysnooz.device import SnoozDevice -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS from .models import SnoozConfigEntry, SnoozConfigurationData @@ -23,7 +27,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> boo if not (ble_device := async_ble_device_from_address(hass, address)): raise ConfigEntryNotReady( - f"Could not find Snooz with address {address}. Try power cycling the device" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) device = SnoozDevice(ble_device, token) diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index cd9568564411b..70621c6eba048 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -24,6 +24,11 @@ } } }, + "exceptions": { + "device_not_found": { + "message": "Could not find Snooz with address {address}: {reason}" + } + }, "services": { "transition_off": { "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.", diff --git a/tests/components/snooz/test_init.py b/tests/components/snooz/test_init.py index 247a1f55d0550..197c39929dc9a 100644 --- a/tests/components/snooz/test_init.py +++ b/tests/components/snooz/test_init.py @@ -1,8 +1,49 @@ """Test Snooz configuration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.snooz.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant -from . import SnoozFixture +from . import TEST_ADDRESS, TEST_PAIRING_TOKEN, SnoozFixture + +from tests.common import MockConfigEntry + + +async def test_setup_retries_when_device_not_found( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup is retried with a diagnostic reason when the device is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.snooz.async_ble_device_from_address", + return_value=None, + ), + patch( + "homeassistant.components.snooz.async_address_reachability_diagnostics", + return_value="mock reachability reason", + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert ( + f"Could not find Snooz with address {TEST_ADDRESS}: mock reachability reason" + in caplog.text + ) async def test_removing_entry_cleans_up_connections( From d8a9ea1d9d30ff1db99c23ee4c0a87ee0e65799f Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 1 Jun 2026 22:10:43 +0200 Subject: [PATCH 100/153] Fix ProxmoxVE missing unused token data (#172782) --- homeassistant/components/proxmoxve/common.py | 4 ++-- tests/components/proxmoxve/test_config_flow.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py index 32131808af7c9..73dee442372af 100644 --- a/homeassistant/components/proxmoxve/common.py +++ b/homeassistant/components/proxmoxve/common.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from typing import Any -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_TOKEN, CONF_USERNAME from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM, CONF_TOKEN_ID @@ -21,7 +21,7 @@ def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]: data[CONF_REALM] = realm data[CONF_USERNAME] = f"{username}@{realm}" - if CONF_TOKEN_ID in data and "!" in data[CONF_TOKEN_ID]: + if data.get(CONF_TOKEN) and data.get(CONF_TOKEN_ID) and "!" in data[CONF_TOKEN_ID]: data[CONF_TOKEN_ID] = data[CONF_TOKEN_ID].split("!")[1] return data diff --git a/tests/components/proxmoxve/test_config_flow.py b/tests/components/proxmoxve/test_config_flow.py index a11c6637e5717..3a6abc4c64ede 100644 --- a/tests/components/proxmoxve/test_config_flow.py +++ b/tests/components/proxmoxve/test_config_flow.py @@ -496,6 +496,11 @@ def sanitize_config_entry(data: dict[str, Any]) -> dict[str, Any]: MOCK_USER_AUTH_STEP_OTHER_TOKEN, MOCK_TEST_TOKEN_OTHER_CONFIG, ), + ( + MOCK_USER_STEP_TOKEN, + MOCK_USER_AUTH_STEP_TOKEN_FULL_ID, + MOCK_TEST_TOKEN_CONFIG, + ), ], ) @pytest.mark.usefixtures("mock_setup_entry") From b03d87dc21407a2abab156123df32ab16e017a8a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 22:45:58 +0200 Subject: [PATCH 101/153] Cancel iCloud polling timer on config entry unload (#172793) --- homeassistant/components/icloud/__init__.py | 1 + homeassistant/components/icloud/account.py | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 45a51c246a3a5..696f0aa20a3e5 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo await hass.async_add_executor_job(account.setup) entry.runtime_data = account + entry.async_on_unload(account.cancel_fetch) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 630bc3d94ac34..25d9a7e52ab67 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -92,6 +92,7 @@ def __init__( self._retried_fetch = False self._config_entry = config_entry + self._unsub_fetch: CALLBACK_TYPE | None = None self.listeners: list[CALLBACK_TYPE] = [] def setup(self) -> None: @@ -293,9 +294,16 @@ def _determine_interval(self) -> int: self._max_interval, ) + def cancel_fetch(self) -> None: + """Cancel the scheduled fetch timer.""" + if self._unsub_fetch is not None: + self._unsub_fetch() + self._unsub_fetch = None + def _schedule_next_fetch(self) -> None: + self.cancel_fetch() if not self._config_entry.pref_disable_polling: - track_point_in_utc_time( + self._unsub_fetch = track_point_in_utc_time( self.hass, self.keep_alive, utcnow() + timedelta(minutes=self._fetch_interval), From af08e5e7d0448de0ce68813333e6628394890d99 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 Jun 2026 21:05:58 +0000 Subject: [PATCH 102/153] Bump version to 2026.6.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0f024cf0a4884..1a48c532af0ea 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 8c8c052383391..d680d28b9d44e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.6.0b1" +version = "2026.6.0b2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From d2abd7f6ca74a9a51659a9c4703fb4cc2e853eb1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 May 2026 13:22:38 +0200 Subject: [PATCH 103/153] Add zone entered left triggers (#172412) --- .../device_tracker/device_trigger.py | 30 ++- homeassistant/components/zone/icons.json | 8 + homeassistant/components/zone/strings.json | 40 +++ homeassistant/components/zone/trigger.py | 249 ++++++++++++------ homeassistant/components/zone/triggers.yaml | 26 ++ homeassistant/helpers/trigger.py | 9 +- tests/components/zone/test_trigger.py | 210 ++++++++++++++- tests/helpers/test_trigger.py | 26 +- 8 files changed, 494 insertions(+), 104 deletions(-) create mode 100644 homeassistant/components/zone/triggers.yaml diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index a85006d17814c..8e9c1ddbad8be 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -12,13 +12,19 @@ CONF_DOMAIN, CONF_ENTITY_ID, CONF_EVENT, + CONF_OPTIONS, CONF_PLATFORM, CONF_TYPE, CONF_ZONE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + # protected, but only used for legacy triggers + _async_attach_trigger_cls, +) from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -79,16 +85,18 @@ async def async_attach_trigger( event = zone.EVENT_ENTER else: event = zone.EVENT_LEAVE - - zone_config = { - CONF_PLATFORM: ZONE_DOMAIN, - CONF_ENTITY_ID: config[CONF_ENTITY_ID], - CONF_ZONE: config[CONF_ZONE], - CONF_EVENT: event, - } - zone_config = await zone.async_validate_trigger_config(hass, zone_config) - return await zone.async_attach_trigger( - hass, zone_config, action, trigger_info, platform_type="device" + zone_config = await zone.LegacyZoneTrigger.async_validate_config( + hass, + { + CONF_OPTIONS: { + CONF_ENTITY_ID: [config[CONF_ENTITY_ID]], + CONF_ZONE: config[CONF_ZONE], + CONF_EVENT: event, + } + }, + ) + return await _async_attach_trigger_cls( + hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info ) diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index a9829425570a0..90d90c50565e1 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -3,5 +3,13 @@ "reload": { "service": "mdi:reload" } + }, + "triggers": { + "entered": { + "trigger": "mdi:map-marker-plus" + }, + "left": { + "trigger": "mdi:map-marker-minus" + } } } diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index 346a43e5b2602..b511a57c79c6c 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -1,8 +1,48 @@ { + "common": { + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", + "trigger_zone_description": "The zone to trigger on.", + "trigger_zone_name": "Zone" + }, "services": { "reload": { "description": "Reloads zones from the YAML-configuration.", "name": "Reload zones" } + }, + "triggers": { + "entered": { + "description": "Triggers when one or more persons or device trackers enter a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::trigger_zone_description%]", + "name": "[%key:component::zone::common::trigger_zone_name%]" + } + }, + "name": "Entered zone" + }, + "left": { + "description": "Triggers when one or more persons or device trackers leave a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::trigger_zone_description%]", + "name": "[%key:component::zone::common::trigger_zone_name%]" + } + }, + "name": "Left zone" + } } } diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 550cec19e413b..608e154beaf38 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -1,22 +1,24 @@ """Offer zone automation rules.""" import logging +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol +from homeassistant.components.device_tracker import ATTR_IN_ZONES from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_EVENT, - CONF_PLATFORM, + CONF_OPTIONS, CONF_ZONE, ) from homeassistant.core import ( CALLBACK_TYPE, Event, EventStateChangedData, - HassJob, HomeAssistant, + State, callback, ) from homeassistant.helpers import ( @@ -24,8 +26,18 @@ entity_registry as er, location, ) +from homeassistant.helpers.automation import ( + DomainSpec, + move_top_level_schema_fields_to_options, +) from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR, + EntityTriggerBase, + Trigger, + TriggerActionRunner, + TriggerConfig, +) from homeassistant.helpers.typing import ConfigType from . import condition @@ -38,93 +50,166 @@ _EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"} -_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( +_LEGACY_OPTIONS_SCHEMA: dict[vol.Marker, Any] = { + vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, + vol.Required(CONF_ZONE): cv.entity_id, + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(EVENT_ENTER, EVENT_LEAVE), +} + +_LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema( { - vol.Required(CONF_PLATFORM): "zone", - vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids, - vol.Required(CONF_ZONE): cv.entity_id, - vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any( - EVENT_ENTER, EVENT_LEAVE - ), - } + vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA, + }, ) +# New-style zone trigger schema +_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_ZONE): cv.entity_domain("zone"), + }, + } +) -async def async_validate_trigger_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate trigger config.""" - config = _TRIGGER_SCHEMA(config) - registry = er.async_get(hass) - config[CONF_ENTITY_ID] = er.async_validate_entity_ids( - registry, config[CONF_ENTITY_ID] - ) - return config - - -async def async_attach_trigger( - hass: HomeAssistant, - config: ConfigType, - action: TriggerActionType, - trigger_info: TriggerInfo, - *, - platform_type: str = "zone", -) -> CALLBACK_TYPE: - """Listen for state changes based on configuration.""" - trigger_data = trigger_info["trigger_data"] - entity_id: list[str] = config[CONF_ENTITY_ID] - zone_entity_id: str = config[CONF_ZONE] - event: str = config[CONF_EVENT] - job = HassJob(action) - - @callback - def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None: - """Listen for state changes and calls action.""" - entity = zone_event.data["entity_id"] - from_s = zone_event.data["old_state"] - to_s = zone_event.data["new_state"] - - if (from_s and not location.has_location(from_s)) or ( - to_s and not location.has_location(to_s) - ): - return - - if not (zone_state := hass.states.get(zone_entity_id)): - _LOGGER.warning( - ( - "Automation '%s' is referencing non-existing zone '%s' in a zone" - " trigger" - ), - trigger_info["name"], - zone_entity_id, - ) - return - - from_match = condition.zone(hass, zone_state, from_s) if from_s else False - to_match = condition.zone(hass, zone_state, to_s) if to_s else False - - if (event == EVENT_ENTER and not from_match and to_match) or ( - event == EVENT_LEAVE and from_match and not to_match - ): - description = ( - f"{entity} {_EVENT_DESCRIPTION[event]}" - f" {zone_state.attributes[ATTR_FRIENDLY_NAME]}" +_DOMAIN_SPECS: dict[str, DomainSpec] = { + "person": DomainSpec(), + "device_tracker": DomainSpec(), +} + + +class LegacyZoneTrigger(Trigger): + """Legacy zone trigger (platform: zone).""" + + @classmethod + async def async_validate_complete_config( + cls, hass: HomeAssistant, complete_config: ConfigType + ) -> ConfigType: + """Validate complete config, migrating legacy format to options.""" + complete_config = move_top_level_schema_fields_to_options( + complete_config, _LEGACY_OPTIONS_SCHEMA + ) + return await super().async_validate_complete_config(hass, complete_config) + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + config = cast(ConfigType, _LEGACY_TRIGGER_OPTIONS_SCHEMA(config)) + registry = er.async_get(hass) + config[CONF_OPTIONS][CONF_ENTITY_ID] = er.async_validate_entity_ids( + registry, config[CONF_OPTIONS][CONF_ENTITY_ID] + ) + return config + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize trigger.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._options = config.options + + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + entity_id: list[str] = self._options[CONF_ENTITY_ID] + zone_entity_id: str = self._options[CONF_ZONE] + event: str = self._options[CONF_EVENT] + + @callback + def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None: + """Listen for state changes and calls action.""" + entity = zone_event.data["entity_id"] + from_s = zone_event.data["old_state"] + to_s = zone_event.data["new_state"] + + if (from_s and not location.has_location(from_s)) or ( + to_s and not location.has_location(to_s) + ): + return + + if not (zone_state := self._hass.states.get(zone_entity_id)): + _LOGGER.warning( + "Non-existing zone '%s' in a zone trigger", + zone_entity_id, + ) + return + + from_match = ( + condition.zone(self._hass, zone_state, from_s) if from_s else False ) - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": platform_type, + to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False + + if (event == EVENT_ENTER and not from_match and to_match) or ( + event == EVENT_LEAVE and from_match and not to_match + ): + description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" + run_action( + { "entity_id": entity, "from_state": from_s, "to_state": to_s, "zone": zone_state, "event": event, - "description": description, - } - }, - to_s.context if to_s else None, - ) + }, + description, + to_s.context if to_s else None, + ) + + return async_track_state_change_event( + self._hass, entity_id, zone_automation_listener + ) + + +class ZoneTriggerBase(EntityTriggerBase): + """Base for zone-based triggers targeting person and device_tracker entities.""" + + _domain_specs = _DOMAIN_SPECS + _schema = _ZONE_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the trigger.""" + super().__init__(hass, config) + self._zone: str = self._options[CONF_ZONE] + + def _in_target_zone(self, state: State) -> bool: + """Check if the entity is in the selected zone.""" + in_zones = state.attributes.get(ATTR_IN_ZONES) or () + return self._zone in in_zones + + +class EnteredZoneTrigger(ZoneTriggerBase): + """Trigger when an entity enters the selected zone.""" + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the entity was not already in the selected zone.""" + return not self._in_target_zone(from_state) + + def is_valid_state(self, state: State) -> bool: + """Check that the entity is now in the selected zone.""" + return self._in_target_zone(state) + + +class LeftZoneTrigger(ZoneTriggerBase): + """Trigger when an entity leaves the selected zone.""" + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the entity was previously in the selected zone.""" + return self._in_target_zone(from_state) + + def is_valid_state(self, state: State) -> bool: + """Check that the entity is no longer in the selected zone.""" + return not self._in_target_zone(state) + + +TRIGGERS: dict[str, type[Trigger]] = { + "_": LegacyZoneTrigger, + "entered": EnteredZoneTrigger, + "left": LeftZoneTrigger, +} + - return async_track_state_change_event(hass, entity_id, zone_automation_listener) +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for zones.""" + return TRIGGERS diff --git a/homeassistant/components/zone/triggers.yaml b/homeassistant/components/zone/triggers.yaml new file mode 100644 index 0000000000000..8828c8b128aca --- /dev/null +++ b/homeassistant/components/zone/triggers.yaml @@ -0,0 +1,26 @@ +.trigger_zone: &trigger_zone + target: + entity: + domain: + - person + - device_tracker + fields: + behavior: + required: true + default: each + selector: + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +entered: *trigger_zone +left: *trigger_zone diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 3ea44c2fd0357..1c86dde4ad571 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -1714,7 +1714,14 @@ def async_extract_entities(trigger_conf: dict) -> list[str]: return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]] if trigger_conf[CONF_PLATFORM] == "zone": - return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return] + options = trigger_conf[CONF_OPTIONS] + return [*options[CONF_ENTITY_ID], options[CONF_ZONE]] + + if trigger_conf[CONF_PLATFORM] in ("zone.entered", "zone.left"): + return [ + *async_extract_targets(trigger_conf, CONF_ENTITY_ID), + trigger_conf[CONF_OPTIONS][CONF_ZONE], + ] if trigger_conf[CONF_PLATFORM] == "geo_location": return [trigger_conf[CONF_ZONE]] diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 16cbb7b541844..8d268fe5be6ba 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -1,14 +1,28 @@ """The tests for the location automation.""" +from typing import Any + import pytest +import voluptuous as vol from homeassistant.components import automation, zone from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.setup import async_setup_component from tests.common import mock_component +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_all, + assert_trigger_behavior_each, + assert_trigger_behavior_first, + assert_trigger_options_supported, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) @pytest.fixture(autouse=True) @@ -343,10 +357,7 @@ async def test_unknown_zone( }, ) - assert ( - "Automation 'My Automation' is referencing non-existing zone" - " 'zone.no_such_zone' in a zone trigger" not in caplog.text - ) + assert "Non-existing zone 'zone.no_such_zone' in a zone trigger" not in caplog.text hass.states.async_set( "test.entity", @@ -356,7 +367,192 @@ async def test_unknown_zone( ) await hass.async_block_till_done() - assert ( - "Automation 'My Automation' is referencing non-existing zone" - " 'zone.no_such_zone' in a zone trigger" in caplog.text + assert "Non-existing zone 'zone.no_such_zone' in a zone trigger" in caplog.text + + +# --- New-style zone trigger tests --- + +ZONE_HOME = "zone.home" +ZONE_WORK = "zone.work" +IN_ZONES_HOME = {"in_zones": [ZONE_HOME]} +IN_ZONES_WORK = {"in_zones": [ZONE_WORK]} +IN_ZONES_NONE: dict[str, list[str]] = {"in_zones": []} +TRIGGER_ZONE = ZONE_HOME + + +@pytest.mark.parametrize( + ("trigger_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("zone.entered", {"zone": TRIGGER_ZONE}, True, True), + ("zone.left", {"zone": TRIGGER_ZONE}, True, True), + ], +) +async def test_zone_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that zone triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize("trigger_key", ["zone.entered", "zone.left"]) +async def test_zone_trigger_rejects_non_zone_entity_id( + hass: HomeAssistant, trigger_key: str +) -> None: + """Test that the zone option must reference entities in the zone domain.""" + with pytest.raises(vol.Invalid): + await async_validate_trigger_config( + hass, + [ + { + "platform": trigger_key, + "target": {"entity_id": "person.alice"}, + "options": {"zone": "person.alice"}, + } + ], + ) + + +@pytest.fixture +async def target_zone_entities( + hass: HomeAssistant, domain: str +) -> dict[str, list[str]]: + """Create multiple zone-trackable entities associated with different targets.""" + return await target_entities(hass, domain, domain_excluded="sensor") + + +_ZONE_TRIGGER_STATES = [ + *parametrize_trigger_states( + trigger="zone.entered", + trigger_options={"zone": TRIGGER_ZONE}, + target_states=[ + ("home", IN_ZONES_HOME), + ], + other_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + ), + *parametrize_trigger_states( + trigger="zone.left", + trigger_options={"zone": TRIGGER_ZONE}, + target_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + other_states=[ + ("home", IN_ZONES_HOME), + ], + ), +] + + +def _parametrize_zone_target_entities() -> list[tuple[dict[str, Any], str, int, str]]: + """Parametrize target entities for all supported zone trigger domains.""" + return [ + (*params, domain) + for domain in ("person", "device_tracker") + for params in parametrize_target_entities(domain) + ] + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + _ZONE_TRIGGER_STATES, +) +async def test_zone_trigger_behavior_each( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + trigger_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test zone triggers fire when any targeted entity changes.""" + await assert_trigger_behavior_each( + hass, + target_entities=target_zone_entities, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + _ZONE_TRIGGER_STATES, +) +async def test_zone_trigger_behavior_first( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + trigger_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test zone triggers fire when first targeted entity changes.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_zone_entities, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + _ZONE_TRIGGER_STATES, +) +async def test_zone_trigger_behavior_all( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + trigger_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test zone triggers fire when last targeted entity changes.""" + await assert_trigger_behavior_all( + hass, + target_entities=target_zone_entities, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, ) diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index f1fb9cc2e808f..930e1556f6118 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -4639,13 +4639,33 @@ async def test_entity_trigger_duration_cancelled_on_invalid_state( pytest.param( { "platform": "zone", - "entity_id": ["person.a"], - "zone": "zone.home", - "event": "enter", + "options": { + "entity_id": ["person.a"], + "zone": "zone.home", + "event": "enter", + }, }, ["person.a", "zone.home"], id="zone-legacy", ), + pytest.param( + { + "platform": "zone.entered", + "target": {"entity_id": ["person.a", "device_tracker.b"]}, + "options": {"zone": "zone.home"}, + }, + ["person.a", "device_tracker.b", "zone.home"], + id="zone-entered-modern", + ), + pytest.param( + { + "platform": "zone.left", + "target": {"entity_id": "person.a"}, + "options": {"zone": "zone.home"}, + }, + ["person.a", "zone.home"], + id="zone-left-modern", + ), pytest.param( {"platform": "geo_location", "zone": "zone.home"}, ["zone.home"], From 05eada2569641c26a38774532da90428c06d42fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jun 2026 09:34:12 +0200 Subject: [PATCH 104/153] Add zone triggers occupancy detected/cleared (#172438) --- homeassistant/components/zone/icons.json | 6 + homeassistant/components/zone/strings.json | 26 +++ homeassistant/components/zone/trigger.py | 72 +++++++++ homeassistant/components/zone/triggers.yaml | 16 ++ tests/components/common.py | 39 +++-- tests/components/zone/test_trigger.py | 171 +++++++++++++++++++- 6 files changed, 318 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index 90d90c50565e1..f582e6b65a495 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -10,6 +10,12 @@ }, "left": { "trigger": "mdi:map-marker-minus" + }, + "occupancy_cleared": { + "trigger": "mdi:account-off" + }, + "occupancy_detected": { + "trigger": "mdi:account-group" } } } diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index b511a57c79c6c..43d0c2986c2e9 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -43,6 +43,32 @@ } }, "name": "Left zone" + }, + "occupancy_cleared": { + "description": "Triggers when a zone transitions from occupied to unoccupied.", + "fields": { + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::triggers::occupancy_detected::fields::zone::description%]", + "name": "[%key:component::zone::triggers::occupancy_detected::fields::zone::name%]" + } + }, + "name": "Zone occupancy cleared" + }, + "occupancy_detected": { + "description": "Triggers when a zone transitions to an occupied state.", + "fields": { + "for": { + "name": "[%key:component::zone::common::trigger_for_name%]" + }, + "zone": { + "description": "The zone to monitor.", + "name": "Zone" + } + }, + "name": "Zone occupancy detected" } } } diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 608e154beaf38..13d067b0ff276 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -10,7 +10,9 @@ ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_EVENT, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, ) from homeassistant.core import ( @@ -203,10 +205,80 @@ def is_valid_state(self, state: State) -> bool: return not self._in_target_zone(state) +_OCCUPANCY_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default={}): { + vol.Required(CONF_ZONE): cv.entity_domain("zone"), + vol.Optional(CONF_FOR): cv.positive_time_period, + }, + } +) + + +class _ZoneOccupancyTriggerBase(EntityTriggerBase): + """Base for zone occupancy triggers (single zone, no behavior).""" + + _domain_specs = {"zone": DomainSpec()} + _schema = _OCCUPANCY_TRIGGER_SCHEMA + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config and synthesize a target from the zone option. + + We synthesize a target because we allow users to pick a single zone + to monitor, not a target. + """ + config = cast(ConfigType, cls._schema(config)) + config[CONF_TARGET] = {CONF_ENTITY_ID: [config[CONF_OPTIONS][CONF_ZONE]]} + return config + + @staticmethod + def _occupancy_count(state: State) -> int | None: + """Return the zone's persons-in-zone count; None if unparsable.""" + try: + return int(state.state) + except TypeError, ValueError: + return None + + @classmethod + def _is_occupied(cls, state: State) -> bool: + """Return True if the zone has at least one occupant.""" + count = cls._occupancy_count(state) + return count is not None and count >= 1 + + +class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase): + """Trigger when a zone transitions to an occupied state.""" + + def is_valid_state(self, state: State) -> bool: + """Check that the zone is occupied.""" + return self._is_occupied(state) + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the zone was previously not occupied.""" + return not self._is_occupied(from_state) + + +class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase): + """Trigger when a zone transitions from occupied to unoccupied.""" + + def is_valid_state(self, state: State) -> bool: + """Check that the zone is empty (count == 0).""" + return self._occupancy_count(state) == 0 + + def is_valid_transition(self, from_state: State, to_state: State) -> bool: + """Check that the zone was previously occupied.""" + return self._is_occupied(from_state) + + TRIGGERS: dict[str, type[Trigger]] = { "_": LegacyZoneTrigger, "entered": EnteredZoneTrigger, "left": LeftZoneTrigger, + "occupancy_detected": OccupancyDetectedTrigger, + "occupancy_cleared": OccupancyClearedTrigger, } diff --git a/homeassistant/components/zone/triggers.yaml b/homeassistant/components/zone/triggers.yaml index 8828c8b128aca..81526345b799f 100644 --- a/homeassistant/components/zone/triggers.yaml +++ b/homeassistant/components/zone/triggers.yaml @@ -24,3 +24,19 @@ entered: *trigger_zone left: *trigger_zone + +.trigger_occupancy: &trigger_occupancy + fields: + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +occupancy_detected: *trigger_occupancy +occupancy_cleared: *trigger_occupancy diff --git a/tests/components/common.py b/tests/components/common.py index 0fab4ae0716a3..c542c03999082 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -1586,12 +1586,12 @@ async def _validate_trigger_options( options: dict[str, Any] | None, *, valid: bool, + supports_target: bool = True, ) -> None: """Assert that a trigger accepts or rejects the given options during validation.""" - trigger_config: dict[str, Any] = { - CONF_PLATFORM: trigger, - CONF_TARGET: {ATTR_LABEL_ID: "test_label"}, - } + trigger_config: dict[str, Any] = {CONF_PLATFORM: trigger} + if supports_target: + trigger_config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"} if options is not None: trigger_config[CONF_OPTIONS] = options if valid: @@ -1608,6 +1608,7 @@ async def assert_trigger_options_supported( *, supports_behavior: bool, supports_duration: bool, + supports_target: bool = True, ) -> None: """Assert which options a trigger supports. @@ -1624,9 +1625,15 @@ async def assert_trigger_options_supported( # Minimal config should always be valid supports_empty = not bool(base_options) - await _validate_trigger_options(hass, trigger, None, valid=supports_empty) - await _validate_trigger_options(hass, trigger, {}, valid=supports_empty) - await _validate_trigger_options(hass, trigger, base_options, valid=True) + await _validate_trigger_options( + hass, trigger, None, valid=supports_empty, supports_target=supports_target + ) + await _validate_trigger_options( + hass, trigger, {}, valid=supports_empty, supports_target=supports_target + ) + await _validate_trigger_options( + hass, trigger, base_options, valid=True, supports_target=supports_target + ) def _merge(extra: dict[str, Any]) -> dict[str, Any]: return {**(base_options or {}), **extra} @@ -1634,18 +1641,30 @@ def _merge(extra: dict[str, Any]) -> dict[str, Any]: # Behavior for behavior in ("each", "first", "all"): await _validate_trigger_options( - hass, trigger, _merge({"behavior": behavior}), valid=supports_behavior + hass, + trigger, + _merge({"behavior": behavior}), + valid=supports_behavior, + supports_target=supports_target, ) # Duration for for_value in ({"seconds": 5}, "00:00:05", 5): await _validate_trigger_options( - hass, trigger, _merge({"for": for_value}), valid=supports_duration + hass, + trigger, + _merge({"for": for_value}), + valid=supports_duration, + supports_target=supports_target, ) # Unknown option should always be rejected await _validate_trigger_options( - hass, trigger, _merge({"unknown_option": True}), valid=False + hass, + trigger, + _merge({"unknown_option": True}), + valid=False, + supports_target=supports_target, ) diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 8d268fe5be6ba..648ef7dad698a 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -1,18 +1,26 @@ """The tests for the location automation.""" +from datetime import timedelta from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol from homeassistant.components import automation, zone -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_OFF, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.setup import async_setup_component -from tests.common import mock_component +from tests.common import async_fire_time_changed, mock_component from tests.components.common import ( TriggerStateDescription, assert_trigger_behavior_all, @@ -556,3 +564,162 @@ async def test_zone_trigger_behavior_all( trigger_options=trigger_options, states=states, ) + + +# --- Zone occupancy trigger tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_key"), + ["zone.occupancy_detected", "zone.occupancy_cleared"], +) +async def test_zone_occupancy_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, +) -> None: + """Test that occupancy triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + {"zone": ZONE_HOME}, + supports_behavior=False, + supports_duration=True, + supports_target=False, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_key", "from_state", "to_state", "should_fire"), + [ + # occupancy_detected + pytest.param("zone.occupancy_detected", "0", "1", True, id="detected_0_to_1"), + pytest.param("zone.occupancy_detected", "0", "3", True, id="detected_0_to_3"), + pytest.param("zone.occupancy_detected", "1", "2", False, id="detected_1_to_2"), + pytest.param("zone.occupancy_detected", "2", "0", False, id="detected_2_to_0"), + pytest.param( + "zone.occupancy_detected", + STATE_UNKNOWN, + "1", + False, + id="detected_unknown_to_1", + ), + pytest.param( + "zone.occupancy_detected", + STATE_UNAVAILABLE, + "1", + False, + id="detected_unavailable_to_1", + ), + pytest.param( + "zone.occupancy_detected", + "0", + STATE_UNAVAILABLE, + False, + id="detected_0_to_unavailable", + ), + # occupancy_cleared + pytest.param("zone.occupancy_cleared", "1", "0", True, id="cleared_1_to_0"), + pytest.param("zone.occupancy_cleared", "3", "0", True, id="cleared_3_to_0"), + pytest.param("zone.occupancy_cleared", "2", "1", False, id="cleared_2_to_1"), + pytest.param("zone.occupancy_cleared", "0", "1", False, id="cleared_0_to_1"), + pytest.param( + "zone.occupancy_cleared", + "1", + STATE_UNAVAILABLE, + False, + id="cleared_1_to_unavailable", + ), + pytest.param( + "zone.occupancy_cleared", + "1", + STATE_UNKNOWN, + False, + id="cleared_1_to_unknown", + ), + ], +) +async def test_zone_occupancy_trigger_transitions( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_key: str, + from_state: str, + to_state: str, + should_fire: bool, +) -> None: + """Test occupancy triggers fire on the expected numeric-state transitions.""" + hass.states.async_set(ZONE_HOME, from_state) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "trigger": trigger_key, + "options": {"zone": ZONE_HOME}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.states.async_set(ZONE_HOME, to_state) + await hass.async_block_till_done() + assert (len(service_calls) == 1) is should_fire + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_key", "from_value", "to_value", "revert_value"), + [ + ("zone.occupancy_detected", "0", "1", "0"), + ("zone.occupancy_cleared", "1", "0", "1"), + ], +) +async def test_zone_occupancy_trigger_for_duration( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], + trigger_key: str, + from_value: str, + to_value: str, + revert_value: str, +) -> None: + """Test that `for` delays the firing and an early revert cancels it.""" + hass.states.async_set(ZONE_HOME, from_value) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "trigger": trigger_key, + "options": {"zone": ZONE_HOME, "for": {"seconds": 5}}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # Transition, then revert before the duration elapses -> no fire. + hass.states.async_set(ZONE_HOME, to_value) + await hass.async_block_till_done() + hass.states.async_set(ZONE_HOME, revert_value) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(service_calls) == 0 + + # Transition and hold past the duration -> fire once. + hass.states.async_set(ZONE_HOME, to_value) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(service_calls) == 1 From 60f458a372b22dfe5212d9cb36399845bb3d414a Mon Sep 17 00:00:00 2001 From: jameson_uk <1040621+jamesonuk@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:04:20 +0100 Subject: [PATCH 105/153] alexa devices - media player code quality (#172650) --- .../components/alexa_devices/media_player.py | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/alexa_devices/media_player.py b/homeassistant/components/alexa_devices/media_player.py index fe94b98b11840..34fdeeed01da2 100644 --- a/homeassistant/components/alexa_devices/media_player.py +++ b/homeassistant/components/alexa_devices/media_player.py @@ -1,8 +1,7 @@ """Media player platform for Alexa Devices.""" -from dataclasses import dataclass from datetime import datetime -from typing import Any, Final +from typing import Any from aioamazondevices.structures import ( AmazonMediaControls, @@ -38,18 +37,6 @@ ) -@dataclass(frozen=True, kw_only=True) -class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription): - """Describes an Alexa Devices media player entity.""" - - -MEDIA_PLAYERS: Final = ( - AmazonDevicesMediaPlayerEntityDescription( - key="media", - ), -) - - async def async_setup_entry( hass: HomeAssistant, entry: AmazonConfigEntry, @@ -69,9 +56,10 @@ def _check_device() -> None: continue known_devices.add(serial_num) - new_entities.extend( - AlexaDevicesMediaPlayer(coordinator, serial_num, description) - for description in MEDIA_PLAYERS + new_entities.append( + AlexaDevicesMediaPlayer( + coordinator, serial_num, MediaPlayerEntityDescription(key="media") + ) ) if new_entities: @@ -85,8 +73,6 @@ def _check_device() -> None: class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): """Representation of an Alexa device media player.""" - entity_description: AmazonDevicesMediaPlayerEntityDescription - _attr_name = None # Uses the device name _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_volume_step = 0.05 @@ -95,7 +81,7 @@ def __init__( self, coordinator: AmazonDevicesCoordinator, serial_num: str, - description: AmazonDevicesMediaPlayerEntityDescription, + description: MediaPlayerEntityDescription, ) -> None: """Initialize.""" self._prev_volume: int | None = None @@ -214,7 +200,7 @@ def media_position_updated_at(self) -> datetime | None: @property def media_content_type(self) -> MediaType | None: """Content type — tells HA what kind of media is playing.""" - if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]: + if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED): return MediaType.MUSIC return None @@ -227,7 +213,8 @@ async def async_play_media( **kwargs: Any, ) -> None: """Play a piece of media.""" - await self.async_call_alexa_music(media_id, media_type) + provider = media_type.value if isinstance(media_type, MediaType) else media_type + await self.async_call_alexa_music(media_id, provider) @alexa_api_call async def async_call_alexa_music( From 7bbd0ea472c45e576b733b7f313ae5beab243b9b Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:02:56 -0400 Subject: [PATCH 106/153] Replace usages of datetime.now(UTC) with dt_util for Sonos (#172737) --- tests/components/sonos/test_media_player.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 1a3ebee74a013..ff56bba933c22 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,7 +1,6 @@ """Tests for the Sonos Media Player platform.""" from collections.abc import Generator -from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock, patch @@ -87,6 +86,7 @@ DeviceRegistry, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .conftest import MockMusicServiceItem, MockSoCo, SoCoMockFactory, SonosMockEvent @@ -1477,7 +1477,7 @@ async def test_position_updates( assert state.attributes[ATTR_MEDIA_POSITION] == 42 # updated_at should be recent updated_at = state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] - assert updated_at == datetime.now(UTC) + assert updated_at == dt_util.utcnow() # Position only updated by 1 second; should not update attributes new_track_info = current_track_info.copy() @@ -1507,7 +1507,7 @@ async def test_position_updates( await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.attributes[ATTR_MEDIA_POSITION] == 70 - assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == datetime.now(UTC) + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == dt_util.utcnow() @pytest.mark.parametrize( From 763d9879bff61a88b463a3888b0858f3039e1d73 Mon Sep 17 00:00:00 2001 From: Heikki Henriksen Date: Wed, 3 Jun 2026 11:47:19 +0200 Subject: [PATCH 107/153] prusalink: guard non-string original in config_flow workaround (#172375) --- homeassistant/components/prusalink/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index c9b763e9ab165..e193033647fdb 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Any, cast +from typing import Any from awesomeversion import AwesomeVersion, AwesomeVersionException from httpx import HTTPError, InvalidURL @@ -41,7 +41,8 @@ def ensure_printer_is_supported(version: VersionInfo) -> None: # Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports # the 2.0.0 API, but doesn't advertise it yet - original = cast(str, version.get("original", "")) + original_value = version.get("original") + original = original_value if isinstance(original_value, str) else "" if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and ( AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"]) ): From ada8a98f870f32abe0a17289ede676ba6b800fdf Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:27:54 -0400 Subject: [PATCH 108/153] Log warning on unsupported announce media formats for Sonos (#172614) Co-authored-by: Joost Lekkerkerker --- .../components/sonos/media_player.py | 12 ++++ tests/components/sonos/test_media_player.py | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 8e4bdebfb7621..5319397350302 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -3,7 +3,9 @@ import datetime from functools import partial import logging +import os from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse from soco import SoCo, alarms from soco.core import ( @@ -90,6 +92,7 @@ UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"] ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"] +ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: frozenset[str] = frozenset({".mp3", ".wav"}) async def async_setup_entry( @@ -460,6 +463,15 @@ async def async_play_media( if kwargs.get(ATTR_MEDIA_ANNOUNCE): volume = kwargs.get("extra", {}).get("volume") + ext = os.path.splitext(urlparse(media_id).path)[1].lower() + if ext and ext not in ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: + _LOGGER.warning( + "Sonos AudioClip announce only supports MP3 and WAV; " + "%s has extension %s and will be attempted as a clip anyway on %s", + media_id, + ext, + self.speaker.zone_name, + ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: assert self.speaker.websocket diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index ff56bba933c22..326519f64b867 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,6 +1,7 @@ """Tests for the Sonos Media Player platform.""" from collections.abc import Generator +import logging from typing import Any from unittest.mock import MagicMock, patch @@ -1347,6 +1348,61 @@ async def test_play_media_announce( soco.play_uri.assert_called_with(content_id, force_radio=False) +@pytest.mark.parametrize( + ("content_id", "expect_warning"), + [ + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123.mp3", + False, + id="mp3_no_warning", + ), + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123.wav", + False, + id="wav_no_warning", + ), + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123.flac", + True, + id="flac_warns_and_plays", + ), + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123", + False, + id="no_extension_no_warning", + ), + ], +) +async def test_play_media_announce_format_warning( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, + sonos_websocket, + content_id: str, + expect_warning: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that announce logs a warning for unsupported file formats.""" + caplog.clear() + caplog.set_level( + logging.WARNING, logger="homeassistant.components.sonos.media_player" + ) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + assert sonos_websocket.play_clip.call_count == 1 + warning_logged = "only supports MP3 and WAV" in caplog.text + assert warning_logged == expect_warning + + async def test_media_get_queue( hass: HomeAssistant, soco: MockSoCo, From b176fb211300ae1a08d4fda977de1be3656c4530 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 2 Jun 2026 08:04:20 +0200 Subject: [PATCH 109/153] Update knx-frontend to 2026.6.1.213802 (#172806) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 2fb9d53ee0d41..2f41732d03dc2 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.15.0", "xknxproject==3.9.0", - "knx-frontend==2026.4.30.60856" + "knx-frontend==2026.6.1.213802" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 123ce1c607af9..a576bd1f3cd62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1423,7 +1423,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.4.30.60856 +knx-frontend==2026.6.1.213802 # homeassistant.components.kraken krakenex==2.2.2 From 8ce5ba2ba41e7378491ffae422138ac982dcd562 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Jun 2026 10:40:43 +0200 Subject: [PATCH 110/153] Add zone conditions in / not in zone (#172810) --- homeassistant/components/zone/condition.py | 61 ++++- homeassistant/components/zone/conditions.yaml | 26 ++ homeassistant/components/zone/icons.json | 8 + homeassistant/components/zone/strings.json | 38 +++ tests/components/zone/test_condition.py | 255 ++++++++++++++++++ 5 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/zone/conditions.yaml diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index 130648f5a2796..c2f712ea6e141 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -4,6 +4,7 @@ import voluptuous as vol +from homeassistant.components.device_tracker import ATTR_IN_ZONES from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, @@ -17,15 +18,21 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import move_top_level_schema_fields_to_options +from homeassistant.helpers.automation import ( + DomainSpec, + move_top_level_schema_fields_to_options, +) from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionCheckParams, ConditionConfig, + EntityConditionBase, ) from homeassistant.helpers.typing import ConfigType from . import in_zone +from .const import DOMAIN _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_ENTITY_ID): cv.entity_ids, @@ -149,11 +156,61 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: return all_ok +_DOMAIN_SPECS: dict[str, DomainSpec] = { + "person": DomainSpec(value_source=ATTR_IN_ZONES), + "device_tracker": DomainSpec(value_source=ATTR_IN_ZONES), +} + +_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), + }, + } +) + + +class _ZoneTargetConditionBase(EntityConditionBase): + """Base for zone-target conditions on person and device_tracker entities.""" + + _domain_specs = _DOMAIN_SPECS + _schema = _ZONE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the condition.""" + super().__init__(hass, config) + assert config.options is not None + self._zone: str = config.options[CONF_ZONE] + + def _in_target_zone(self, entity_state: State) -> bool: + """Check if the entity is currently in the selected zone.""" + in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or () + return self._zone in in_zones + + +class InZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is in the selected zone.""" + return self._in_target_zone(entity_state) + + +class NotInZoneCondition(_ZoneTargetConditionBase): + """Condition: targeted entity is not in the selected zone.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the entity is not in the selected zone.""" + return not self._in_target_zone(entity_state) + + CONDITIONS: dict[str, type[Condition]] = { "_": ZoneCondition, + "in_zone": InZoneCondition, + "not_in_zone": NotInZoneCondition, } async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: - """Return the sun conditions.""" + """Return the zone conditions.""" return CONDITIONS diff --git a/homeassistant/components/zone/conditions.yaml b/homeassistant/components/zone/conditions.yaml new file mode 100644 index 0000000000000..3853bc9beb756 --- /dev/null +++ b/homeassistant/components/zone/conditions.yaml @@ -0,0 +1,26 @@ +.condition_zone: &condition_zone + target: + entity: + domain: + - person + - device_tracker + fields: + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +in_zone: *condition_zone +not_in_zone: *condition_zone diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index f582e6b65a495..7d082d5f0d1dd 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "in_zone": { + "condition": "mdi:map-marker-check" + }, + "not_in_zone": { + "condition": "mdi:map-marker-remove" + } + }, "services": { "reload": { "service": "mdi:reload" diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index 43d0c2986c2e9..133bf0e15ab60 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -1,10 +1,48 @@ { "common": { + "condition_behavior_name": "Check when", + "condition_for_name": "For at least", + "condition_zone_description": "The zone to test against.", + "condition_zone_name": "Zone", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least", "trigger_zone_description": "The zone to trigger on.", "trigger_zone_name": "Zone" }, + "conditions": { + "in_zone": { + "description": "Tests if one or more persons or device trackers are in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is in zone" + }, + "not_in_zone": { + "description": "Tests if one or more persons or device trackers are not in a zone.", + "fields": { + "behavior": { + "name": "[%key:component::zone::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::common::condition_zone_description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Is not in zone" + } + }, "services": { "reload": { "description": "Reloads zones from the YAML-configuration.", diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index 391593019b33d..4a8d7408f2167 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -1,12 +1,28 @@ """The tests for the location condition.""" +from datetime import timedelta +from typing import Any + +from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol from homeassistant.components.zone import condition as zone_condition from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_options_supported, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + async def test_zone_raises(hass: HomeAssistant) -> None: """Test that zone raises ConditionError on errors.""" @@ -206,3 +222,242 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, ) assert not test.async_check() + + +# --- New-style zone condition tests --- + +ZONE_HOME = "zone.home" +ZONE_WORK = "zone.work" +IN_ZONES_HOME = {"in_zones": [ZONE_HOME]} +IN_ZONES_WORK = {"in_zones": [ZONE_WORK]} +IN_ZONES_NONE: dict[str, list[str]] = {"in_zones": []} +TARGET_ZONE = ZONE_HOME + + +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("zone.in_zone", {"zone": TARGET_ZONE}, True, True), + ("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True), + ], +) +async def test_zone_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that zone conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize("condition_key", ["zone.in_zone", "zone.not_in_zone"]) +async def test_zone_condition_rejects_non_zone_entity_id( + hass: HomeAssistant, condition_key: str +) -> None: + """Test that the zone option must reference entities in the zone domain.""" + with pytest.raises(vol.Invalid): + await condition.async_validate_condition_config( + hass, + { + "condition": condition_key, + "target": {"entity_id": "person.alice"}, + "options": {"zone": "person.alice"}, + }, + ) + + +@pytest.fixture +async def target_zone_entities( + hass: HomeAssistant, domain: str +) -> dict[str, list[str]]: + """Create multiple zone-trackable entities associated with different targets.""" + return await target_entities(hass, domain, domain_excluded="sensor") + + +# `in_zone` is True for states where the entity carries the target zone in +# `in_zones`; `not_in_zone` flips the relation. +_ZONE_CONDITION_STATES_ANY = [ + *parametrize_condition_states_any( + condition="zone.in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("home", IN_ZONES_HOME), + ], + other_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + excluded_entities_from_other_domain=True, + ), + *parametrize_condition_states_any( + condition="zone.not_in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + other_states=[ + ("home", IN_ZONES_HOME), + ], + excluded_entities_from_other_domain=True, + ), +] + + +_ZONE_CONDITION_STATES_ALL = [ + *parametrize_condition_states_all( + condition="zone.in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("home", IN_ZONES_HOME), + ], + other_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + excluded_entities_from_other_domain=True, + ), + *parametrize_condition_states_all( + condition="zone.not_in_zone", + condition_options={"zone": TARGET_ZONE}, + target_states=[ + ("not_home", IN_ZONES_NONE), + ("Work", IN_ZONES_WORK), + ], + other_states=[ + ("home", IN_ZONES_HOME), + ], + excluded_entities_from_other_domain=True, + ), +] + + +def _parametrize_zone_target_entities() -> list[tuple[dict[str, Any], str, int, str]]: + """Parametrize target entities for all supported zone condition domains.""" + return [ + (*params, domain) + for domain in ("person", "device_tracker") + for params in parametrize_target_entities(domain) + ] + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + _ZONE_CONDITION_STATES_ANY, +) +async def test_zone_condition_behavior_any( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test zone conditions under behavior=any.""" + await assert_condition_behavior_any( + hass, + target_entities=target_zone_entities, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target", "domain"), + _parametrize_zone_target_entities(), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + _ZONE_CONDITION_STATES_ALL, +) +async def test_zone_condition_behavior_all( + hass: HomeAssistant, + target_zone_entities: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test zone conditions under behavior=all.""" + await assert_condition_behavior_all( + hass, + target_entities=target_zone_entities, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +async def test_in_zone_condition_for_attribute_only_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test `for:` anchors to in_zones updates, not state.state changes. + + A person already "home" who enters an overlapping zone (e.g. zone.coffee) + keeps state.state == "home" while in_zones grows. `for: 5m` on + in_zone(zone.coffee) must start counting from when in_zones changed, not + from the (older) last state.state transition. + """ + coffee_zone = "zone.coffee" + + # Person at home but not yet in the coffee zone. + hass.states.async_set( + "person.alice", + "home", + {"in_zones": [ZONE_HOME]}, + ) + await hass.async_block_till_done() + + # Time passes — state.state's last_changed sits 10 minutes in the past. + freezer.tick(timedelta(minutes=10)) + + config = await condition.async_validate_condition_config( + hass, + { + "condition": "zone.in_zone", + "target": {"entity_id": "person.alice"}, + "options": {"zone": coffee_zone, "for": {"minutes": 5}}, + }, + ) + test = await condition.async_from_config(hass, config) + + # in_zones gains the coffee zone; state.state stays "home", so last_changed + # is untouched and only last_updated advances. + hass.states.async_set( + "person.alice", + "home", + {"in_zones": [ZONE_HOME, coffee_zone]}, + ) + await hass.async_block_till_done() + + # Just entered; `for: 5m` must not be satisfied yet. (Without value_source + # set on the DomainSpec, the anchor would be last_changed from 10 minutes + # ago and this would incorrectly evaluate to True.) + assert test.async_check() is False + + # After the duration elapses, the condition is satisfied. + freezer.tick(timedelta(minutes=6)) + assert test.async_check() is True From db6f1426ece8b512eebb55f4a2b56de901a5ba95 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jun 2026 10:37:16 +0200 Subject: [PATCH 111/153] Fix SwitchBot Blind Tilt KeyError on idle BLE advertisements (#172816) --- homeassistant/components/switchbot/cover.py | 4 +-- tests/components/switchbot/test_cover.py | 31 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 61750efbb4ace..4c1b7c2e71e1d 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -218,8 +218,8 @@ def _handle_coordinator_update(self) -> None: self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or ( _tilt > self.CLOSED_UP_THRESHOLD ) - self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] - self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index fd696f67c0ef4..716759acdf578 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -392,6 +392,37 @@ async def test_blindtilt_controlling( assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 +async def test_blindtilt_idle_advertisement( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test blindtilt handles BLE advertisement without motionDirection.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + new=AsyncMock(return_value={}), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"x\x00*" + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%F\x12\x85" + + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + # Should not crash; entity should still exist + state = hass.states.get(entity_id) + assert state is not None + + async def test_roller_shade_setup( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] ) -> None: From 994b2105885d6b85726366482a7b5aa586fa6a17 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jun 2026 12:31:57 +0200 Subject: [PATCH 112/153] Make the renamed trigger behavior options backwards compatible (#172822) --- homeassistant/helpers/trigger.py | 15 +++++++++-- tests/helpers/test_trigger.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 1c86dde4ad571..730352886cd77 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -330,6 +330,16 @@ async def async_attach_runner( BEHAVIOR_ALL: Final = "all" BEHAVIOR_EACH: Final = "each" + +def _backwards_compatible_behavior(value: Any) -> Any: + """Convert legacy behavior values to new ones.""" + if value == "any": + return BEHAVIOR_EACH + if value == "last": + return BEHAVIOR_ALL + return value + + ENTITY_STATE_TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_TARGET): cv.TARGET_FIELDS, @@ -340,8 +350,9 @@ async def async_attach_runner( ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend( { vol.Required(CONF_OPTIONS, default={}): { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_EACH): vol.In( - [BEHAVIOR_FIRST, BEHAVIOR_ALL, BEHAVIOR_EACH] + vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_EACH): vol.All( + _backwards_compatible_behavior, + vol.In([BEHAVIOR_FIRST, BEHAVIOR_ALL, BEHAVIOR_EACH]), ), vol.Optional(CONF_FOR): cv.positive_time_period, }, diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 930e1556f6118..e3d8157f0cfe6 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -53,6 +53,7 @@ BEHAVIOR_EACH, BEHAVIOR_FIRST, DATA_PLUGGABLE_ACTIONS, + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR, TRIGGERS, EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, @@ -4787,3 +4788,48 @@ def test_async_extract_devices( ) -> None: """Test extracting devices from various trigger config shapes.""" assert trigger.async_extract_devices(trigger_conf) == expected + + +@pytest.mark.parametrize( + ("behavior", "expected"), + [ + # Legacy values are converted to their new equivalents + ("any", BEHAVIOR_EACH), + ("last", BEHAVIOR_ALL), + # New values pass through unchanged + (BEHAVIOR_FIRST, BEHAVIOR_FIRST), + (BEHAVIOR_ALL, BEHAVIOR_ALL), + (BEHAVIOR_EACH, BEHAVIOR_EACH), + ], +) +def test_entity_state_trigger_schema_behavior_backwards_compatible( + behavior: str, expected: str +) -> None: + """Test legacy behavior values are converted to their new equivalents.""" + config = { + CONF_TARGET: {CONF_ENTITY_ID: "test.entity"}, + CONF_OPTIONS: {ATTR_BEHAVIOR: behavior}, + } + validated = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR(config) + assert validated[CONF_OPTIONS][ATTR_BEHAVIOR] == expected + + +def test_entity_state_trigger_schema_behavior_default() -> None: + """Test the behavior defaults to 'each' when omitted.""" + config = { + CONF_TARGET: {CONF_ENTITY_ID: "test.entity"}, + CONF_OPTIONS: {}, + } + validated = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR(config) + assert validated[CONF_OPTIONS][ATTR_BEHAVIOR] == BEHAVIOR_EACH + + +@pytest.mark.parametrize("behavior", ["invalid", "anything", ""]) +def test_entity_state_trigger_schema_behavior_invalid(behavior: str) -> None: + """Test invalid behavior values are rejected.""" + config = { + CONF_TARGET: {CONF_ENTITY_ID: "test.entity"}, + CONF_OPTIONS: {ATTR_BEHAVIOR: behavior}, + } + with pytest.raises(vol.Invalid): + ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR(config) From 24037fcfa32aba2eedd2256f74a460c53b0e15dc Mon Sep 17 00:00:00 2001 From: zhangluofeng <30921940+zhangluofeng@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:05:00 +0800 Subject: [PATCH 113/153] Don't create switch entity for switch device type in XThings Cloud (#172828) --- .../components/xthings_cloud/switch.py | 2 +- tests/components/xthings_cloud/test_switch.py | 21 +------------------ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/xthings_cloud/switch.py b/homeassistant/components/xthings_cloud/switch.py index 26422a19d28e4..c62240b07bf41 100644 --- a/homeassistant/components/xthings_cloud/switch.py +++ b/homeassistant/components/xthings_cloud/switch.py @@ -20,7 +20,7 @@ async def async_setup_entry( entities = [ XthingsCloudSwitch(coordinator, device_id, device_data) for device_id, device_data in coordinator.data.items() - if device_data["type"] in ("switch", "plug") + if device_data["type"] == "plug" ] async_add_entities(entities) diff --git a/tests/components/xthings_cloud/test_switch.py b/tests/components/xthings_cloud/test_switch.py index d1a8fd6c90e62..d1b519616c23e 100644 --- a/tests/components/xthings_cloud/test_switch.py +++ b/tests/components/xthings_cloud/test_switch.py @@ -40,36 +40,20 @@ async def test_switches( @pytest.mark.parametrize( - ("entity_id", "device_id", "device_type", "service", "method"), + ("entity_id", "device_id", "service", "method"), [ ( "switch.smart_plug_50", "dev_plug_001", - "plug", SERVICE_TURN_ON, "async_plug_on", ), ( "switch.smart_plug_50", "dev_plug_001", - "plug", SERVICE_TURN_OFF, "async_plug_off", ), - ( - "switch.smart_plug_100", - "dev_plug_002", - "switch", - SERVICE_TURN_ON, - "async_switch_on", - ), - ( - "switch.smart_plug_100", - "dev_plug_002", - "switch", - SERVICE_TURN_OFF, - "async_switch_off", - ), ], ) async def test_turn_on_off( @@ -78,13 +62,10 @@ async def test_turn_on_off( mock_api_client: AsyncMock, entity_id: str, device_id: str, - device_type: str, service: str, method: str, ) -> None: """Test turning on and off a device.""" - get_device_by_id(mock_api_client, device_id)["type"] = device_type - with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.SWITCH]): await setup_integration(hass, mock_config_entry) From 387b84ec7ba4f5c73c7f72e7536bb6267f3a6ac6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Jun 2026 10:10:30 +0200 Subject: [PATCH 114/153] Prevent log spam when WS subscribe_condition is active (#172832) --- .../components/websocket_api/commands.py | 17 +++- homeassistant/helpers/template/__init__.py | 14 ++++ homeassistant/helpers/trace.py | 60 ++++++++++++- .../components/websocket_api/test_commands.py | 77 +++++++++++++++++ tests/helpers/test_condition.py | 84 +++++++++++++++++++ 5 files changed, 249 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a1c466b9400fd..0bcbad5b6275a 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -39,6 +39,7 @@ entity, target as target_helpers, template, + trace, ) from homeassistant.helpers.condition import ( async_from_config as async_condition_from_config, @@ -1061,10 +1062,24 @@ def evaluate_condition(now: datetime | None) -> None: nonlocal event_data new_event_data: dict[str, Any] + condition_trace = trace.trace_get() try: - new_event_data = {"result": condition.async_check()} + with trace.record_template_errors(): + new_event_data = {"result": condition.async_check()} except HomeAssistantError as err: new_event_data = {"error": str(err)} + + # Template errors (e.g. undefined variables) are recorded in the trace + # instead of being logged. Forward them to the client so they are not + # lost, even when the condition still evaluated to a result. + if template_errors := [ + template_error + for elements in condition_trace.values() + for element in elements + for template_error in element.template_errors + ]: + new_event_data["template_errors"] = template_errors + if new_event_data == event_data: return event_data = new_event_data diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index ba1fe3b1fc126..7bd5c16000f42 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -24,6 +24,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.trace import ( + record_template_errors_cv, + trace_stack_cv, + trace_stack_top, +) from homeassistant.helpers.typing import TemplateVarsType from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey @@ -627,6 +632,15 @@ def make_logging_undefined( return jinja2.StrictUndefined def _log_with_logger(level: int, msg: str) -> None: + # When a consumer such as the subscribe_condition websocket command has + # opted in, record the error on the active trace element instead of + # logging it, so repeated evaluations don't spam the log. + if record_template_errors_cv.get() and ( + node := trace_stack_top(trace_stack_cv) + ): + node.add_template_error(msg) + return + template, action = template_cv.get() or ("", "rendering or compiling") _LOGGER.log( level, diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index e16bf0882233b..8b21a87fb66c0 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -5,7 +5,7 @@ from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any +from typing import Any, Literal, overload from homeassistant.core import ServiceResponse from homeassistant.util import dt as dt_util @@ -22,6 +22,7 @@ class TraceElement: "_error", "_last_variables", "_result", + "_template_errors", "_timestamp", "_variables", "path", @@ -35,6 +36,7 @@ def __init__(self, variables: TemplateVarsType, path: str) -> None: self._error: BaseException | None = None self.path: str = path self._result: dict[str, Any] | None = None + self._template_errors: list[str] | None = None self.reuse_by_child = False self._timestamp = dt_util.utcnow() @@ -54,6 +56,23 @@ def set_error(self, ex: BaseException | None) -> None: """Set error.""" self._error = ex + def add_template_error(self, msg: str) -> None: + """Record a template error message. + + Used to record template variable errors which would otherwise be logged + directly, so they are surfaced in the trace instead of spamming the log. + A single template render can emit more than one message, so they are + accumulated in a list. + """ + if self._template_errors is None: + self._template_errors = [] + self._template_errors.append(msg) + + @property + def template_errors(self) -> list[str]: + """Return the recorded template error messages.""" + return self._template_errors or [] + def set_result(self, **kwargs: Any) -> None: """Set result.""" self._result = {**kwargs} @@ -90,6 +109,8 @@ def as_dict(self) -> dict[str, Any]: result["changed_variables"] = self._variables if self._error is not None: result["error"] = str(self._error) or self._error.__class__.__name__ + if self._template_errors: + result["template_errors"] = self._template_errors if self._result is not None: result["result"] = self._result return result @@ -118,6 +139,26 @@ def as_dict(self) -> dict[str, Any]: script_execution_cv: ContextVar[StopReason | None] = ContextVar( "script_execution_cv", default=None ) +# When set, template errors are recorded on the active TraceElement instead of +# being logged directly +record_template_errors_cv: ContextVar[bool] = ContextVar( + "record_template_errors_cv", default=False +) + + +@contextmanager +def record_template_errors() -> Generator[None]: + """Record template errors in the active trace instead of logging them. + + Used by consumers such as the subscribe_condition websocket command, which + re-evaluate a condition repeatedly and forward template errors to the client + via the trace, so the errors don't spam the log. + """ + token = record_template_errors_cv.set(True) + try: + yield + finally: + record_template_errors_cv.reset(token) def trace_id_set(trace_id: tuple[str, str]) -> None: @@ -189,8 +230,23 @@ def trace_append_element( trace[path].append(trace_element) +@overload +def trace_get(clear: Literal[True] = True) -> dict[str, deque[TraceElement]]: ... + + +@overload +def trace_get(clear: Literal[False]) -> dict[str, deque[TraceElement]] | None: ... + + def trace_get(clear: bool = True) -> dict[str, deque[TraceElement]] | None: - """Return the current trace.""" + """Return the current trace. + + When clear is True the trace is reset and a fresh (empty) trace is + unconditionally returned. + + When clear is False, the current trace is returned without modification + if it exists, otherwise None is returned. + """ if clear: trace_clear() return trace_cv.get() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index e2f76d330f12b..dc96bec457105 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2868,6 +2868,83 @@ async def test_subscribe_condition( } +@pytest.mark.parametrize( + ("value_template", "expected_event"), + [ + # Undefined variable used in a way that raises: forwarded as an error, + # with the underlying template error included. + ( + "{{ trigger.to_state.attributes.event_type == 'double_press' }}", + { + "error": "In 'template' condition: UndefinedError: 'trigger' is undefined", + "template_errors": ["'trigger' is undefined"], + }, + ), + # Undefined variable used in a way that only warns: the condition still + # evaluates to a result, but the template error is forwarded alongside it. + ( + "{{ no_such_variable }}", + {"result": False, "template_errors": ["'no_such_variable' is undefined"]}, + ), + # A single render emitting multiple errors forwards all of them. + ( + "{{ foo }}{{ bar }}", + { + "result": False, + "template_errors": ["'foo' is undefined", "'bar' is undefined"], + }, + ), + ], +) +async def test_subscribe_condition_template_error( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + value_template: str, + expected_event: dict[str, Any], +) -> None: + """Test template errors are forwarded as events and don't spam the log.""" + caplog.set_level(logging.WARNING) + + await websocket_client.send_json_auto_id( + { + "type": "subscribe_condition", + "condition": { + "condition": "template", + "value_template": value_template, + }, + } + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + subscription_id = msg["id"] + + msg = await websocket_client.receive_json() + assert msg == { + "id": subscription_id, + "type": "event", + "event": expected_event, + } + + # Let the condition be evaluated a few more times + for _ in range(5): + freezer.tick(1.1) + await hass.async_block_till_done() + + # The unchanged result/error is not re-sent; a ping is the next message + await websocket_client.send_json_auto_id({"type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["type"] == "pong" + + # The template error is forwarded, not logged + assert "Template variable warning" not in caplog.text + assert "Template variable error" not in caplog.text + + @pytest.mark.parametrize( ("condition", "expected_error"), [ diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 2e4146213ac49..6fd2c9b117307 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2205,6 +2205,90 @@ async def test_condition_template_error(hass: HomeAssistant) -> None: test.async_check() +@pytest.mark.parametrize( + ("value_template", "expectation", "expected_template_errors", "expected_result"), + [ + # Undefined variable used in a way that raises (e.g. attribute access) + ( + "{{ trigger.to_state.attributes.event_type == 'double_press' }}", + pytest.raises(ConditionError), + ["'trigger' is undefined"], + {}, + ), + # Undefined variable used in a way that only warns + ( + "{{ no_such_variable }}", + does_not_raise(), + ["'no_such_variable' is undefined"], + {"result": False, "entities": []}, + ), + # A single render can emit more than one message + ( + "{{ foo }}{{ bar }}", + does_not_raise(), + ["'foo' is undefined", "'bar' is undefined"], + {"result": False, "entities": []}, + ), + ], +) +async def test_condition_template_error_traced_not_logged( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + value_template: str, + expectation: AbstractContextManager, + expected_template_errors: list[str], + expected_result: dict[str, Any], +) -> None: + """Test template errors are added to the trace and not logged when opted in. + + The subscribe_condition websocket command re-evaluates a condition every + second and opts in via trace.record_template_errors(). Template variable + errors must then be recorded in the trace instead of being logged repeatedly. + """ + caplog.set_level(logging.WARNING) + config = {"condition": "template", "value_template": value_template} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + with expectation, trace.record_template_errors(): + test.async_check() + + # The template errors are recorded in the trace... + condition_trace = trace.trace_get(clear=False) + trace.trace_clear() + trace_element = condition_trace[""][0] + assert trace_element.template_errors == expected_template_errors + assert (trace_element._result or {}) == expected_result + + # ...and not logged + assert "Template variable" not in caplog.text + + +async def test_condition_template_error_logged_without_opt_in( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test template errors are logged when recording is not opted in. + + An active trace is not enough to suppress logging; the consumer must opt in + via trace.record_template_errors(). Without it, the error is logged as usual + and not recorded in the trace. + """ + caplog.set_level(logging.WARNING) + config = {"condition": "template", "value_template": "{{ no_such_variable }}"} + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + assert test.async_check() is False + + assert "Template variable warning: 'no_such_variable' is undefined" in caplog.text + condition_trace = trace.trace_get(clear=False) + trace.trace_clear() + assert condition_trace[""][0].template_errors == [] + + async def test_condition_template_invalid_results(hass: HomeAssistant) -> None: """Test template condition render false with invalid results.""" config = {"condition": "template", "value_template": "{{ 'string' }}"} From bb8036f2c87301b7ecada99ee0da7bccdb424813 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:12:52 +0200 Subject: [PATCH 115/153] Automation choose: Add optional note to options (#172837) --- homeassistant/helpers/config_validation.py | 1 + tests/helpers/test_config_validation.py | 36 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 46096f6f2b87a..6034ede444981 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1953,6 +1953,7 @@ def _base_trigger_validator(value: Any) -> Any: [ { vol.Optional(CONF_ALIAS): string, + vol.Remove(CONF_NOTE): str, # Is only used in frontend vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, } diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index e6a9c1bf5ddbd..f30b9d2b838c5 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -2084,3 +2084,39 @@ def test_base_schemas_reject_invalid_note( """Test that script, condition, trigger base schemas reject non-string notes.""" with pytest.raises(vol.Invalid): validator({**base_config, "note": invalid_note}) + + +_CHOOSE_OPTION_BASE_CONFIG = { + "conditions": [ + {"condition": "state", "entity_id": "sun.sun", "state": "above_horizon"} + ], + "sequence": [{"action": "test.foo"}], +} + + +@pytest.mark.usefixtures("hass") +def test_choose_option_accepts_note() -> None: + """Test that the note field is accepted and stripped from a choose option.""" + validated = cv.script_action( + {"choose": [{**_CHOOSE_OPTION_BASE_CONFIG, "note": "Single line"}]} + ) + assert "note" not in validated["choose"][0] + + +@pytest.mark.parametrize( + "invalid_note", + [ + pytest.param(None, id="none"), + pytest.param(42, id="int"), + pytest.param(True, id="bool"), + pytest.param([], id="list"), + pytest.param({}, id="dict"), + ], +) +@pytest.mark.usefixtures("hass") +def test_choose_option_rejects_invalid_note(invalid_note: Any) -> None: + """Test that choose option schemas reject non-string notes.""" + with pytest.raises(vol.Invalid): + cv.script_action( + {"choose": [{**_CHOOSE_OPTION_BASE_CONFIG, "note": invalid_note}]} + ) From 265fe6d33869866fec5407f5da86686e901bb246 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:38:18 -0400 Subject: [PATCH 116/153] Add translations for template device trackers `in_zones` option (#172850) --- homeassistant/components/template/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 5bae9bfb462cf..09c1770fcbfd6 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -139,12 +139,14 @@ "device_tracker": { "data": { "device_id": "[%key:common::config_flow::data::device%]", + "in_zones": "Zones", "latitude": "Latitude", "longitude": "Longitude", "name": "[%key:common::config_flow::data::name%]" }, "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", + "in_zones": "Defines a template that returns a list of zones the device tracker is currently in. The template should return a list of zone entity IDs. If the device tracker is not in any zone, the template should return an empty list.", "latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.", "longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.", "name": "[%key:common::config_flow::data::name%]" @@ -715,11 +717,13 @@ "device_tracker": { "data": { "device_id": "[%key:common::config_flow::data::device%]", + "in_zones": "[%key:component::template::config::step::device_tracker::data::in_zones%]", "latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]", "longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]" }, "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", + "in_zones": "[%key:component::template::config::step::device_tracker::data_description::in_zones%]", "latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]", "longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]" }, From 82d83feda45bfefc9719959fb8926e14f2e903b6 Mon Sep 17 00:00:00 2001 From: jameson_uk <1040621+jamesonuk@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:45:55 +0100 Subject: [PATCH 117/153] Bump aioamazondevices to 14.0.0 (#172857) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/alexa_devices/const.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 2c39adf76e6bf..69b63993fc1e8 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.8.2"] + "requirements": ["aioamazondevices==14.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a576bd1f3cd62..456774dff9789 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.8.2 +aioamazondevices==14.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 8a5772f6af6bc..959f7762a366c 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -113,7 +113,7 @@ TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord( timestamp=1000, - utterance_type="WAKE_WORD_UTTERANCE", + history_type="WAKE_WORD_UTTERANCE", intent="PlayMusicIntent", title="Play some music", sub_title="Echo Test", @@ -121,7 +121,7 @@ TEST_VOCAL_RECORD_EVENT = AmazonVocalRecord( timestamp=1234567890, - utterance_type="WAKE_WORD_UTTERANCE", + history_type="WAKE_WORD_UTTERANCE", intent="PlayMusicIntent", title="Play some music", sub_title="Echo Test", From 4e2bc610e35a1b4be0426bed248ffb235054998a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 2 Jun 2026 22:36:15 +0200 Subject: [PATCH 118/153] Bump pySmartThings to 4.0.0 (#172858) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 5cc4530e97a97..799e4ae9329d1 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -38,5 +38,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.7.3"] + "requirements": ["pysmartthings==4.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 456774dff9789..4493d5e1f38e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2540,7 +2540,7 @@ pysmappee==0.2.29 pysmarlaapi==1.0.2 # homeassistant.components.smartthings -pysmartthings==3.7.3 +pysmartthings==4.0.0 # homeassistant.components.smarty pysmarty2==0.10.3 From 8e493d84f1616ec7af23928d6ba3ea70518f81ab Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jun 2026 05:20:42 +0200 Subject: [PATCH 119/153] Bump frontend to 20260527.3 (#172873) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index dd098b7d63c5f..5f7b5ac88d0ae 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260527.2"] + "requirements": ["home-assistant-frontend==20260527.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 91720420417e9..2756a9f39526e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.8.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260527.2 +home-assistant-frontend==20260527.3 home-assistant-intents==2026.5.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4493d5e1f38e3..b7bd52a143e54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1266,7 +1266,7 @@ hole==0.9.0 holidays==0.97 # homeassistant.components.frontend -home-assistant-frontend==20260527.2 +home-assistant-frontend==20260527.3 # homeassistant.components.conversation home-assistant-intents==2026.5.5 From 0e0b29d16edd86b1b5547b46083d1ace93421007 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 3 Jun 2026 05:56:21 +0200 Subject: [PATCH 120/153] Regenerate mdi_icons.py for frontend 20260527.3 (#172887) --- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 222de95d497ce..9312e58e8912c 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260527.2" +FRONTEND_VERSION: Final[str] = "20260527.3" MDI_ICONS: Final[set[str]] = { "ab-testing", From 6c87284deea9a238ca6f11164108d1cd942b43df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Jun 2026 11:06:18 +0200 Subject: [PATCH 121/153] Catch errors when setting up condition in WS subscribe_condition (#172895) --- .../components/websocket_api/commands.py | 18 ++++++- .../components/websocket_api/test_commands.py | 53 +++++++++++++++++-- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 0bcbad5b6275a..5343d696a9756 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1051,9 +1051,23 @@ async def handle_subscribe_condition( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle subscribe condition command.""" - condition_config = await async_validate_condition_config(hass, msg["condition"]) + try: + condition_config = await async_validate_condition_config(hass, msg["condition"]) + condition = await async_condition_from_config(hass, condition_config) + except vol.Invalid as err: + connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + return + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return - condition = await async_condition_from_config(hass, condition_config) event_data: dict[str, Any] = {} @callback diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index dc96bec457105..0b66d62139d2d 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2969,27 +2969,67 @@ async def test_subscribe_condition_template_error( ), }, ), - # Validated by async_validate_condition_config + ], +) +async def test_subscribe_condition_error( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + condition: dict, + expected_error: dict, +) -> None: + """Test subscribing to a condition.""" + hass.states.async_set("hello.world", "paulus") + + await websocket_client.send_json_auto_id( + {"type": "subscribe_condition", "condition": condition} + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"] == expected_error + + +@pytest.mark.parametrize( + ("condition", "expected_error"), + [ + # Missing mandatory config, raised by async_validate_condition_config ( {"condition": "sun"}, { "code": "invalid_format", "message": ( "must contain at least one of before, after. for dictionary value " - "@ data['options']. Got None" + "@ data['options']" + ), + }, + ), + # Failing enabled template, raised by async_condition_from_config + ( + { + "condition": "template", + "value_template": "{{ true }}", + "enabled": "{{ 1 / 0 }}", + }, + { + "code": "home_assistant_error", + "message": ( + "Error rendering condition enabled template: " + "ZeroDivisionError: division by zero" ), }, ), ], ) -async def test_subscribe_condition_error( +async def test_subscribe_condition_config_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, condition: dict, expected_error: dict, ) -> None: - """Test subscribing to a condition.""" - hass.states.async_set("hello.world", "paulus") + """Test condition config errors are reported to the client without logging.""" + caplog.set_level(logging.ERROR) await websocket_client.send_json_auto_id( {"type": "subscribe_condition", "condition": condition} @@ -3000,6 +3040,9 @@ async def test_subscribe_condition_error( assert not msg["success"] assert msg["error"] == expected_error + # The expected error is not logged by the default websocket error handler + assert "Error handling message" not in caplog.text + async def test_execute_script( hass: HomeAssistant, websocket_client: MockHAClientWebSocket From 083af9ccc7dd9ba1d39cc942371031e864b3dfd9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Jun 2026 11:20:13 +0200 Subject: [PATCH 122/153] Add zone occupancy conditions (#172896) --- homeassistant/components/zone/condition.py | 69 ++++++++++++ homeassistant/components/zone/conditions.yaml | 16 +++ homeassistant/components/zone/icons.json | 6 ++ homeassistant/components/zone/strings.json | 26 +++++ tests/components/common.py | 39 +++++-- tests/components/zone/test_condition.py | 102 ++++++++++++++++-- 6 files changed, 238 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index c2f712ea6e141..14f95166f05bc 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -10,7 +10,9 @@ ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -23,6 +25,8 @@ move_top_level_schema_fields_to_options, ) from homeassistant.helpers.condition import ( + ATTR_BEHAVIOR, + BEHAVIOR_ANY, ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionCheckParams, @@ -204,10 +208,75 @@ def is_valid_state(self, entity_state: State) -> bool: return not self._in_target_zone(entity_state) +_OCCUPANCY_CONDITION_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default={}): { + vol.Required(CONF_ZONE): cv.entity_domain("zone"), + vol.Optional(CONF_FOR): cv.positive_time_period, + }, + } +) + + +class _ZoneOccupancyConditionBase(EntityConditionBase): + """Base for zone occupancy conditions (single zone, no behavior).""" + + _domain_specs = {"zone": DomainSpec()} + _schema = _OCCUPANCY_CONDITION_SCHEMA + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config and synthesize a target from the zone option. + + We synthesize a target because we allow users to pick a single zone + to monitor, not a target. + """ + config = cast(ConfigType, cls._schema(config)) + zone_entity_id: str = config[CONF_OPTIONS][CONF_ZONE] + config[CONF_TARGET] = {CONF_ENTITY_ID: [zone_entity_id]} + # `behavior` is needed by `EntityConditionBase.__init__` + config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY + return config + + @staticmethod + def _occupancy_count(entity_state: State) -> int | None: + """Return the zone's persons-in-zone count; None if unparsable.""" + try: + return int(entity_state.state) + except TypeError, ValueError: + return None + + @classmethod + def _is_occupied(cls, entity_state: State) -> bool: + """Return True if the zone has at least one occupant.""" + count = cls._occupancy_count(entity_state) + return count is not None and count >= 1 + + +class OccupancyIsDetectedCondition(_ZoneOccupancyConditionBase): + """Condition: the selected zone is occupied.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the zone is occupied.""" + return self._is_occupied(entity_state) + + +class OccupancyIsNotDetectedCondition(_ZoneOccupancyConditionBase): + """Condition: the selected zone is empty.""" + + def is_valid_state(self, entity_state: State) -> bool: + """Check that the zone is empty (count == 0).""" + return self._occupancy_count(entity_state) == 0 + + CONDITIONS: dict[str, type[Condition]] = { "_": ZoneCondition, "in_zone": InZoneCondition, "not_in_zone": NotInZoneCondition, + "occupancy_is_detected": OccupancyIsDetectedCondition, + "occupancy_is_not_detected": OccupancyIsNotDetectedCondition, } diff --git a/homeassistant/components/zone/conditions.yaml b/homeassistant/components/zone/conditions.yaml index 3853bc9beb756..2294ecd2c2b4b 100644 --- a/homeassistant/components/zone/conditions.yaml +++ b/homeassistant/components/zone/conditions.yaml @@ -24,3 +24,19 @@ in_zone: *condition_zone not_in_zone: *condition_zone + +.condition_occupancy: &condition_occupancy + fields: + for: + required: true + default: 00:00:00 + selector: + duration: + zone: + required: true + selector: + entity: + domain: zone + +occupancy_is_detected: *condition_occupancy +occupancy_is_not_detected: *condition_occupancy diff --git a/homeassistant/components/zone/icons.json b/homeassistant/components/zone/icons.json index 7d082d5f0d1dd..5ff8e4944319c 100644 --- a/homeassistant/components/zone/icons.json +++ b/homeassistant/components/zone/icons.json @@ -5,6 +5,12 @@ }, "not_in_zone": { "condition": "mdi:map-marker-remove" + }, + "occupancy_is_detected": { + "condition": "mdi:account-group" + }, + "occupancy_is_not_detected": { + "condition": "mdi:account-off" } }, "services": { diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json index 133bf0e15ab60..912cbff16e768 100644 --- a/homeassistant/components/zone/strings.json +++ b/homeassistant/components/zone/strings.json @@ -41,6 +41,32 @@ } }, "name": "Is not in zone" + }, + "occupancy_is_detected": { + "description": "Tests if a zone is occupied.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "The zone to monitor.", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is detected" + }, + "occupancy_is_not_detected": { + "description": "Tests if a zone is empty.", + "fields": { + "for": { + "name": "[%key:component::zone::common::condition_for_name%]" + }, + "zone": { + "description": "[%key:component::zone::conditions::occupancy_is_detected::fields::zone::description%]", + "name": "[%key:component::zone::common::condition_zone_name%]" + } + }, + "name": "Zone occupancy is not detected" } }, "services": { diff --git a/tests/components/common.py b/tests/components/common.py index c542c03999082..d3f53c5b1a60a 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -1485,12 +1485,12 @@ async def _validate_condition_options( options: dict[str, Any] | None, *, valid: bool, + supports_target: bool = True, ) -> None: """Assert that a condition accepts or rejects the given options.""" - config: dict[str, Any] = { - CONF_CONDITION: condition, - CONF_TARGET: {ATTR_LABEL_ID: "test_label"}, - } + config: dict[str, Any] = {CONF_CONDITION: condition} + if supports_target: + config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"} if options is not None: config[CONF_OPTIONS] = options if valid: @@ -1536,6 +1536,7 @@ async def assert_condition_options_supported( *, supports_behavior: bool, supports_duration: bool, + supports_target: bool = True, ) -> None: """Assert which options a condition supports. @@ -1555,9 +1556,15 @@ async def assert_condition_options_supported( # Minimal config should always be valid # If there are no base options, also test that options can be omitted or be empty supports_empty = not bool(base_options) - await _validate_condition_options(hass, condition, None, valid=supports_empty) - await _validate_condition_options(hass, condition, {}, valid=supports_empty) - await _validate_condition_options(hass, condition, base_options, valid=True) + await _validate_condition_options( + hass, condition, None, valid=supports_empty, supports_target=supports_target + ) + await _validate_condition_options( + hass, condition, {}, valid=supports_empty, supports_target=supports_target + ) + await _validate_condition_options( + hass, condition, base_options, valid=True, supports_target=supports_target + ) def _merge(extra: dict[str, Any]) -> dict[str, Any]: return {**(base_options or {}), **extra} @@ -1565,18 +1572,30 @@ def _merge(extra: dict[str, Any]) -> dict[str, Any]: # Behavior for behavior in ("any", "all"): await _validate_condition_options( - hass, condition, _merge({"behavior": behavior}), valid=supports_behavior + hass, + condition, + _merge({"behavior": behavior}), + valid=supports_behavior, + supports_target=supports_target, ) # Duration for for_value in ({"seconds": 5}, "00:00:05", 5): await _validate_condition_options( - hass, condition, _merge({"for": for_value}), valid=supports_duration + hass, + condition, + _merge({"for": for_value}), + valid=supports_duration, + supports_target=supports_target, ) # Unknown option should always be rejected await _validate_condition_options( - hass, condition, _merge({"unknown_option": True}), valid=False + hass, + condition, + _merge({"unknown_option": True}), + valid=False, + supports_target=supports_target, ) diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index 4a8d7408f2167..d2b944b8862be 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components.zone import condition as zone_condition +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv @@ -235,10 +236,18 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("condition_key", "base_options", "supports_behavior", "supports_duration"), + ( + "condition_key", + "base_options", + "supports_behavior", + "supports_duration", + "supports_target", + ), [ - ("zone.in_zone", {"zone": TARGET_ZONE}, True, True), - ("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True), + ("zone.in_zone", {"zone": TARGET_ZONE}, True, True, True), + ("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True, True), + ("zone.occupancy_is_detected", {"zone": ZONE_HOME}, False, True, False), + ("zone.occupancy_is_not_detected", {"zone": ZONE_HOME}, False, True, False), ], ) async def test_zone_condition_options_validation( @@ -247,6 +256,7 @@ async def test_zone_condition_options_validation( base_options: dict[str, Any] | None, supports_behavior: bool, supports_duration: bool, + supports_target: bool, ) -> None: """Test that zone conditions support the expected options.""" await assert_condition_options_supported( @@ -255,22 +265,39 @@ async def test_zone_condition_options_validation( base_options, supports_behavior=supports_behavior, supports_duration=supports_duration, + supports_target=supports_target, ) -@pytest.mark.parametrize("condition_key", ["zone.in_zone", "zone.not_in_zone"]) +@pytest.mark.parametrize( + ("condition_key", "config"), + [ + ( + "zone.in_zone", + {"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}}, + ), + ( + "zone.not_in_zone", + {"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}}, + ), + ( + "zone.occupancy_is_detected", + {"options": {"zone": "light.x"}}, + ), + ( + "zone.occupancy_is_not_detected", + {"options": {"zone": "light.x"}}, + ), + ], +) async def test_zone_condition_rejects_non_zone_entity_id( - hass: HomeAssistant, condition_key: str + hass: HomeAssistant, condition_key: str, config: dict[str, Any] ) -> None: """Test that the zone option must reference entities in the zone domain.""" with pytest.raises(vol.Invalid): await condition.async_validate_condition_config( hass, - { - "condition": condition_key, - "target": {"entity_id": "person.alice"}, - "options": {"zone": "person.alice"}, - }, + {"condition": condition_key, **config}, ) @@ -461,3 +488,58 @@ async def test_in_zone_condition_for_attribute_only_change( # After the duration elapses, the condition is satisfied. freezer.tick(timedelta(minutes=6)) assert test.async_check() is True + + +# --- Zone occupancy condition tests --- + + +@pytest.mark.parametrize( + ("condition_key", "zone_state", "expected"), + [ + # occupancy_is_detected — true when count >= 1 + pytest.param("zone.occupancy_is_detected", "1", True, id="detected_1"), + pytest.param("zone.occupancy_is_detected", "3", True, id="detected_3"), + pytest.param("zone.occupancy_is_detected", "0", False, id="detected_0"), + pytest.param( + "zone.occupancy_is_detected", + STATE_UNAVAILABLE, + False, + id="detected_unavailable", + ), + pytest.param( + "zone.occupancy_is_detected", STATE_UNKNOWN, False, id="detected_unknown" + ), + # occupancy_is_not_detected — true only when count == 0 + pytest.param("zone.occupancy_is_not_detected", "0", True, id="empty_0"), + pytest.param("zone.occupancy_is_not_detected", "1", False, id="empty_1"), + pytest.param("zone.occupancy_is_not_detected", "3", False, id="empty_3"), + # Unavailable / unknown are not "empty" — they're indeterminate. + pytest.param( + "zone.occupancy_is_not_detected", + STATE_UNAVAILABLE, + False, + id="empty_unavailable", + ), + pytest.param( + "zone.occupancy_is_not_detected", + STATE_UNKNOWN, + False, + id="empty_unknown", + ), + ], +) +async def test_zone_occupancy_condition_evaluates( + hass: HomeAssistant, + condition_key: str, + zone_state: str, + expected: bool, +) -> None: + """Test occupancy conditions evaluate against the zone's integer state.""" + hass.states.async_set(ZONE_HOME, zone_state) + await hass.async_block_till_done() + + config = await condition.async_validate_condition_config( + hass, {"condition": condition_key, "options": {"zone": ZONE_HOME}} + ) + test = await condition.async_from_config(hass, config) + assert test.async_check() is expected From 5d0565f0079586f710cab8699fbc666a6cfb6dce Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jun 2026 10:03:15 +0000 Subject: [PATCH 123/153] Bump version to 2026.6.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a48c532af0ea..c191d4b1b88b3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index d680d28b9d44e..ffa70f3e015d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.6.0b2" +version = "2026.6.0b3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 78a97f99dc0c5c1472670fb6ab8e1b25cd8072cc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 2 Jun 2026 11:31:08 -0500 Subject: [PATCH 124/153] Bump intents to 2026.6.1 (#172842) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 40629a05a16ee..bc353271a27d1 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"] + "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2756a9f39526e..62353de1e32f1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260527.3 -home-assistant-intents==2026.5.5 +home-assistant-intents==2026.6.1 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements.txt b/requirements.txt index edd4e1e10a537..0afa12c73f3a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-intents==2026.5.5 +home-assistant-intents==2026.6.1 httpx==0.28.1 ifaddr==0.2.0 infrared-protocols==5.6.1 diff --git a/requirements_all.txt b/requirements_all.txt index b7bd52a143e54..f0109a69a5b40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1269,7 +1269,7 @@ holidays==0.97 home-assistant-frontend==20260527.3 # homeassistant.components.conversation -home-assistant-intents==2026.5.5 +home-assistant-intents==2026.6.1 # homeassistant.components.homekit homekit-audio-proxy==1.2.1 From b4f8fce9120499aa8910214ddfcfa50b60dc2181 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Jun 2026 16:11:54 +0200 Subject: [PATCH 125/153] Don't log condition errors when executing WS test_condition (#172897) --- .../components/websocket_api/commands.py | 51 ++++++- .../components/websocket_api/test_commands.py | 125 ++++++++++++++++++ 2 files changed, 170 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 5343d696a9756..68a52fca9ab60 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1027,14 +1027,53 @@ async def handle_test_condition( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle test condition command.""" - # Do static + dynamic validation of the condition - config = await async_validate_condition_config(hass, msg["condition"]) - # Test the condition - condition = await async_condition_from_config(hass, config) + # Validating and instantiating the condition can fail on bad user input. + # Handle those errors here so they are reported to the client without being + # logged as unexpected errors by the default websocket error handler. try: - connection.send_result( - msg["id"], {"result": condition.async_check(variables=msg.get("variables"))} + # Do static + dynamic validation of the condition + config = await async_validate_condition_config(hass, msg["condition"]) + condition = await async_condition_from_config(hass, config) + except vol.Invalid as err: + connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + return + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, ) + return + + # Template errors (e.g. undefined variables) are recorded in the trace + # instead of being logged. Capture the trace and forward them to the client + # alongside the result. + condition_trace = trace.trace_get() + try: + with trace.record_template_errors(): + check_result = condition.async_check(variables=msg.get("variables")) + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + else: + result: dict[str, Any] = {"result": check_result} + if template_errors := [ + template_error + for elements in condition_trace.values() + for element in elements + for template_error in element.template_errors + ]: + result["template_errors"] = template_errors + connection.send_result(msg["id"], result) finally: condition.async_unload() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 0b66d62139d2d..71771e355bdfd 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2821,6 +2821,131 @@ async def test_test_condition( assert msg["result"]["result"] is False +@pytest.mark.parametrize( + ("value_template", "expected_template_errors"), + [ + ("{{ no_such_variable }}", ["'no_such_variable' is undefined"]), + # A single render emitting multiple errors forwards all of them + ("{{ foo }}{{ bar }}", ["'foo' is undefined", "'bar' is undefined"]), + ], +) +async def test_test_condition_template_error( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + value_template: str, + expected_template_errors: list[str], +) -> None: + """Test template errors are forwarded in the result without being logged.""" + caplog.set_level(logging.WARNING) + + await websocket_client.send_json_auto_id( + { + "type": "test_condition", + "condition": {"condition": "template", "value_template": value_template}, + } + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert msg["result"] == { + "result": False, + "template_errors": expected_template_errors, + } + + assert "Template variable" not in caplog.text + + +@pytest.mark.parametrize( + ("condition", "expected_error"), + [ + # Missing mandatory config, raised by async_validate_condition_config + ( + {"condition": "sun"}, + { + "code": "invalid_format", + "message": ( + "must contain at least one of before, after. for dictionary value " + "@ data['options']" + ), + }, + ), + # Failing enabled template, raised by async_condition_from_config + ( + { + "condition": "template", + "value_template": "{{ true }}", + "enabled": "{{ 1 / 0 }}", + }, + { + "code": "home_assistant_error", + "message": ( + "Error rendering condition enabled template: " + "ZeroDivisionError: division by zero" + ), + }, + ), + ], +) +async def test_test_condition_config_error( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + condition: dict, + expected_error: dict, +) -> None: + """Test condition config errors are reported to the client without logging.""" + caplog.set_level(logging.ERROR) + + await websocket_client.send_json_auto_id( + {"type": "test_condition", "condition": condition} + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"] == expected_error + + # The expected error is not logged by the default websocket error handler + assert "Error handling message" not in caplog.text + + +async def test_test_condition_check_error_not_logged( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test errors raised while checking the condition are not logged. + + The condition is valid and instantiates fine, but checking it raises (here + the entity does not exist). The error is reported to the client without + being logged by the default websocket error handler. + """ + caplog.set_level(logging.ERROR) + + await websocket_client.send_json_auto_id( + { + "type": "test_condition", + "condition": { + "condition": "state", + "entity_id": "hello.world", + "state": "paulus", + }, + } + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"] == { + "code": "home_assistant_error", + "message": "In 'state':\n In 'state' condition: unknown entity hello.world", + } + + assert "Error handling message" not in caplog.text + + async def test_subscribe_condition( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, From 74fd636aa674ad55551f5f6e81551eb9ce3af147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren?= Date: Wed, 3 Jun 2026 13:43:33 +0200 Subject: [PATCH 126/153] Add Avea Bluetooth reachability diagnostics (#172898) --- homeassistant/components/avea/__init__.py | 24 +++++++++++++++++----- homeassistant/components/avea/strings.json | 5 +++++ tests/components/avea/test_init.py | 21 ++++++++++++++++--- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/avea/__init__.py b/homeassistant/components/avea/__init__.py index 13df78301ae0b..5a876eda8e8c1 100644 --- a/homeassistant/components/avea/__init__.py +++ b/homeassistant/components/avea/__init__.py @@ -2,12 +2,18 @@ import avea -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from .const import DOMAIN + type AveaConfigEntry = ConfigEntry[avea.Bulb] PLATFORMS: list[Platform] = [Platform.LIGHT] @@ -15,12 +21,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool: """Set up Avea from a config entry.""" - ble_device = async_ble_device_from_address( - hass, entry.data[CONF_ADDRESS], connectable=True - ) + address = entry.data[CONF_ADDRESS] + ble_device = async_ble_device_from_address(hass, address, connectable=True) if not ble_device: raise ConfigEntryNotReady( - f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) entry.runtime_data = avea.Bulb(ble_device) diff --git a/homeassistant/components/avea/strings.json b/homeassistant/components/avea/strings.json index d52f3c6f6afb4..99d731b07d5dc 100644 --- a/homeassistant/components/avea/strings.json +++ b/homeassistant/components/avea/strings.json @@ -22,6 +22,11 @@ } } }, + "exceptions": { + "device_not_found": { + "message": "Could not find Avea device with address {address}: {reason}" + } + }, "issues": { "deprecated_yaml": { "description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]", diff --git a/tests/components/avea/test_init.py b/tests/components/avea/test_init.py index 7263b5e101770..b2d0399cea9a8 100644 --- a/tests/components/avea/test_init.py +++ b/tests/components/avea/test_init.py @@ -2,8 +2,11 @@ from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from homeassistant.components.avea.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -50,19 +53,31 @@ async def _setup_yaml_import(hass: HomeAssistant, bulbs: list[MagicMock]) -> Non async def test_setup_entry_retries_when_ble_device_is_missing( hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, mock_config_entry: MockConfigEntry, ) -> None: """Test setup retries when the Bluetooth device is unavailable.""" mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.avea.async_ble_device_from_address", - return_value=None, + with ( + patch( + "homeassistant.components.avea.async_ble_device_from_address", + return_value=None, + ), + patch( + "homeassistant.components.avea.async_address_reachability_diagnostics", + return_value="mock reachability reason", + ), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + "Could not find Avea device with address " + f"{mock_config_entry.data[CONF_ADDRESS]}: mock reachability reason" + in caplog.text + ) async def test_yaml_import_creates_entries_for_discovered_bulbs( From d2672050cf4a78e1fad762513ed22dcb65dd41dc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jun 2026 14:17:30 +0200 Subject: [PATCH 127/153] Update frontend to 20260527.4 (#172907) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 5f7b5ac88d0ae..83d3f469ca532 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260527.3"] + "requirements": ["home-assistant-frontend==20260527.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 62353de1e32f1..d6d831709ef98 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.8.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260527.3 +home-assistant-frontend==20260527.4 home-assistant-intents==2026.6.1 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 9312e58e8912c..6887f2debfac6 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260527.3" +FRONTEND_VERSION: Final[str] = "20260527.4" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index f0109a69a5b40..be0c6bea47f5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1266,7 +1266,7 @@ hole==0.9.0 holidays==0.97 # homeassistant.components.frontend -home-assistant-frontend==20260527.3 +home-assistant-frontend==20260527.4 # homeassistant.components.conversation home-assistant-intents==2026.6.1 From ad999291780d44c02936f5238b927cdb6cb97b76 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jun 2026 15:09:31 +0000 Subject: [PATCH 128/153] Bump version to 2026.6.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c191d4b1b88b3..c6c4f03dc0348 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index ffa70f3e015d7..4c28abaf9e5c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.6.0b3" +version = "2026.6.0b4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From e812cd3c3ffb688104ee80e487eee9677b545c04 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 3 Jun 2026 18:04:56 +0200 Subject: [PATCH 129/153] Bump reolink_aio to 0.20.1 (#172927) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index abb29c202e529..10c48451acfa2 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.20.0"] + "requirements": ["reolink-aio==0.20.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index be0c6bea47f5a..2ec462d56b47b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2878,7 +2878,7 @@ renault-api==0.5.11 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.20.0 +reolink-aio==0.20.1 # homeassistant.components.radio_frequency rf-protocols==4.0.1 From 89a033bc2ccdeacfd0840139f821d27bbd43de6e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 3 Jun 2026 18:29:14 +0200 Subject: [PATCH 130/153] Remove state attributes from OPNsense (#172930) --- .../components/opnsense/device_tracker.py | 19 ------------------- .../opnsense/test_device_tracker.py | 3 --- 2 files changed, 22 deletions(-) diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index 5fc232f21ea26..b836602211447 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker support for OPNsense routers.""" -from typing import Any - from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -98,20 +96,3 @@ def hostname(self) -> str | None: hostname = device_data.get("hostname") return hostname or None return None - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - device_data = self.device_data - if not device_data: - return {} - - attrs = {} - if manufacturer := device_data.get("manufacturer"): - attrs["manufacturer"] = manufacturer - if interface := device_data.get("intf_description"): - attrs["interface"] = interface - if expires := device_data.get("expires"): - attrs["expires"] = expires - - return attrs diff --git a/tests/components/opnsense/test_device_tracker.py b/tests/components/opnsense/test_device_tracker.py index a1baa8b89dc2b..0f30e2a24c422 100644 --- a/tests/components/opnsense/test_device_tracker.py +++ b/tests/components/opnsense/test_device_tracker.py @@ -90,7 +90,6 @@ async def test_device_tracker_states( assert state_1.state == "home" # Should be connected since it's in ARP table assert state_1.attributes.get("ip") == "192.168.0.123" assert state_1.attributes.get("mac") == "ff:ff:ff:ff:ff:ff" - assert state_1.attributes.get("interface") == "LAN" # Test second device (with hostname and manufacturer) entity_id_2 = entity_ids_by_unique_id["ff:ff:ff:ff:ff:fe"] @@ -99,8 +98,6 @@ async def test_device_tracker_states( assert state_2.state == "home" # Should be connected since it's in ARP table assert state_2.attributes.get("ip") == "192.168.0.167" assert state_2.attributes.get("mac") == "ff:ff:ff:ff:ff:fe" - assert state_2.attributes.get("interface") == "LAN" - assert state_2.attributes.get("manufacturer") == "OEM" async def test_device_tracker_with_interfaces_filter( From bd985a2db25273ed7626f3a321a2ee66f1b8276a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 Jun 2026 16:31:34 +0000 Subject: [PATCH 131/153] Bump version to 2026.6.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c6c4f03dc0348..290ab9ad55491 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 4c28abaf9e5c7..578942d0c0536 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.6.0b4" +version = "2026.6.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From fcaa11d09ab5e27b313e1029c79f31a764c2c84d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 29 May 2026 14:18:08 +0200 Subject: [PATCH 132/153] Fix CI failure due to missing ssdp patching in braviatv (#172561) --- tests/components/braviatv/conftest.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/components/braviatv/conftest.py b/tests/components/braviatv/conftest.py index b25e8ddf0675c..488dd58e7fd61 100644 --- a/tests/components/braviatv/conftest.py +++ b/tests/components/braviatv/conftest.py @@ -6,6 +6,23 @@ import pytest +@pytest.fixture(autouse=True) +def silent_ssdp_scanner() -> Generator[None]: + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with ( + patch("homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"), + patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), + patch("homeassistant.components.ssdp.Scanner.async_scan"), + patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers", + ), + patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", + ), + ): + yield + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" From a6b7641d477699029f82bc075c9e2d5132c85831 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 4 Jun 2026 17:35:27 +0200 Subject: [PATCH 133/153] Add diagnostics for Blebox integration (#172556) Co-authored-by: Joost Lekkerkerker --- .../components/blebox/diagnostics.py | 33 ++++++++ tests/components/blebox/conftest.py | 6 ++ .../blebox/snapshots/test_diagnostics.ambr | 79 +++++++++++++++++++ tests/components/blebox/test_diagnostics.py | 58 ++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 homeassistant/components/blebox/diagnostics.py create mode 100644 tests/components/blebox/snapshots/test_diagnostics.ambr create mode 100644 tests/components/blebox/test_diagnostics.py diff --git a/homeassistant/components/blebox/diagnostics.py b/homeassistant/components/blebox/diagnostics.py new file mode 100644 index 0000000000000..bb9a0728dab5a --- /dev/null +++ b/homeassistant/components/blebox/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for BleBox devices.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import BleBoxConfigEntry + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BleBoxConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + product = entry.runtime_data.box + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "device": { + "name": product.name, + "type": product.type, + "model": product.model, + "unique_id": product.unique_id, + "firmware_version": product.firmware_version, + "hardware_version": product.hardware_version, + "available_firmware_version": product.available_firmware_version, + "api_version": product.api_version, + "last_data": product.last_data, + }, + } diff --git a/tests/components/blebox/conftest.py b/tests/components/blebox/conftest.py index 890b4a2046abc..39e599b95a8c0 100644 --- a/tests/components/blebox/conftest.py +++ b/tests/components/blebox/conftest.py @@ -55,6 +55,12 @@ def mock_feature(category, spec, set_spec: bool = True, **kwargs): type(feature_mock.product).model = PropertyMock(return_value="some model") type(feature_mock.product).brand = PropertyMock(return_value="BleBox") type(feature_mock.product).firmware_version = PropertyMock(return_value="1.23") + type(feature_mock.product).hardware_version = PropertyMock(return_value="0.1") + type(feature_mock.product).available_firmware_version = PropertyMock( + return_value="1.0.1" + ) + type(feature_mock.product).api_version = PropertyMock(return_value=20200229) + type(feature_mock.product).last_data = PropertyMock(return_value={"state": 1}) type(feature_mock.product).unique_id = PropertyMock(return_value="abcd0123ef5678") type(feature_mock).product = PropertyMock(return_value=product) return feature_mock diff --git a/tests/components/blebox/snapshots/test_diagnostics.ambr b/tests/components/blebox/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..326885368ae9e --- /dev/null +++ b/tests/components/blebox/snapshots/test_diagnostics.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_diagnostics[no_credentials] + dict({ + 'device': dict({ + 'api_version': 20200229, + 'available_firmware_version': '1.0.1', + 'firmware_version': '1.23', + 'hardware_version': '0.1', + 'last_data': dict({ + 'state': 1, + }), + 'model': 'some model', + 'name': 'Some name', + 'type': 'some type', + 'unique_id': 'abcd0123ef5678', + }), + 'entry': dict({ + 'data': dict({ + 'host': '172.100.123.4', + 'port': 80, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'blebox', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + }) +# --- +# name: test_diagnostics[with_credentials] + dict({ + 'device': dict({ + 'api_version': 20200229, + 'available_firmware_version': '1.0.1', + 'firmware_version': '1.23', + 'hardware_version': '0.1', + 'last_data': dict({ + 'state': 1, + }), + 'model': 'some model', + 'name': 'Some name', + 'type': 'some type', + 'unique_id': 'abcd0123ef5678', + }), + 'entry': dict({ + 'data': dict({ + 'host': '172.100.123.4', + 'password': '**REDACTED**', + 'port': 80, + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'blebox', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/blebox/test_diagnostics.py b/tests/components/blebox/test_diagnostics.py new file mode 100644 index 0000000000000..bc098c7e698ee --- /dev/null +++ b/tests/components/blebox/test_diagnostics.py @@ -0,0 +1,58 @@ +"""Tests for BleBox diagnostics.""" + +import blebox_uniapi.switch +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.blebox.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .conftest import mock_feature + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(name="switchbox", autouse=True) +def switchbox_fixture() -> None: + """Set up a switch product mock.""" + mock_feature("switches", blebox_uniapi.switch.Switch) + + +@pytest.mark.parametrize( + "entry_data", + [ + pytest.param( + {CONF_HOST: "172.100.123.4", CONF_PORT: 80}, + id="no_credentials", + ), + pytest.param( + { + CONF_HOST: "172.100.123.4", + CONF_PORT: 80, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + }, + id="with_credentials", + ), + ], +) +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + entry_data: dict, +) -> None: + """Test diagnostics output, including credential redaction.""" + entry = MockConfigEntry(domain=DOMAIN, data=entry_data) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + exclude=props("entry_id", "created_at", "modified_at") + ) From a0d67b80ab86fd5c5fad1f68e4e3aba0b1dbf8c8 Mon Sep 17 00:00:00 2001 From: Marcello <58506324+Marcello17@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:39:34 +0200 Subject: [PATCH 134/153] Fix offline devices in Fluss (#172833) --- homeassistant/components/fluss/coordinator.py | 7 ++- homeassistant/components/fluss/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/fluss/test_cover.py | 4 +- tests/components/fluss/test_init.py | 45 ++++++++++++++++--- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py index 165fe244e04b3..36df9298eb7f0 100644 --- a/homeassistant/components/fluss/coordinator.py +++ b/homeassistant/components/fluss/coordinator.py @@ -7,6 +7,7 @@ FlussApiClient, FlussApiClientAuthenticationError, FlussApiClientError, + FlussDeviceOfflineError, ) from homeassistant.config_entries import ConfigEntry @@ -45,11 +46,13 @@ def __init__( ) async def _async_get_status(self, device_id: str) -> dict[str, Any]: - """Return per-device status.""" + """Return per-device status, treating an offline device as disconnected.""" try: response = await self.api.async_get_device_status(device_id) + except FlussDeviceOfflineError: + return {"internetConnected": False} except FlussApiClientError as err: - raise UpdateFailed(f"Error fetching status for {device_id}: {err}") from err + raise UpdateFailed(f"Error fetching Fluss device status: {err}") from err return response["status"] async def _async_update_data(self) -> dict[str, dict[str, Any]]: diff --git a/homeassistant/components/fluss/manifest.json b/homeassistant/components/fluss/manifest.json index 83494d8d77fea..d420a0b82a4a7 100644 --- a/homeassistant/components/fluss/manifest.json +++ b/homeassistant/components/fluss/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["fluss-api"], "quality_scale": "bronze", - "requirements": ["fluss-api==0.2.4"] + "requirements": ["fluss-api==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index d3eed24ff8c54..0b97b3667cdc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.fluss -fluss-api==0.2.4 +fluss-api==0.2.5 # homeassistant.components.flux_led flux-led==1.2.0 diff --git a/tests/components/fluss/test_cover.py b/tests/components/fluss/test_cover.py index d0604136af8c3..7996a777df9d0 100644 --- a/tests/components/fluss/test_cover.py +++ b/tests/components/fluss/test_cover.py @@ -96,14 +96,14 @@ async def test_cover_unavailable_when_offline( assert hass.states.get(ENTITY_ID_1).state == STATE_UNAVAILABLE -async def test_cover_unavailable_on_transient_status_error( +async def test_cover_unavailable_on_status_error( hass: HomeAssistant, mock_api_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: - """A failed per-device status fetch raises UpdateFailed and marks the cover unavailable.""" + """A failed per-device status fetch fails the refresh and marks the cover unavailable.""" mock_api_client.async_get_device_status.return_value = { "status": {"internetConnected": True, "openCloseStatus": "Closed"} } diff --git a/tests/components/fluss/test_init.py b/tests/components/fluss/test_init.py index c0c66646bc896..358a4ecb3e635 100644 --- a/tests/components/fluss/test_init.py +++ b/tests/components/fluss/test_init.py @@ -1,15 +1,18 @@ """Test script for Fluss+ integration initialization.""" +from typing import Any from unittest.mock import AsyncMock from fluss_api import ( FlussApiClientAuthenticationError, FlussApiClientCommunicationError, FlussApiClientError, + FlussDeviceOfflineError, ) import pytest from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from . import setup_integration @@ -56,15 +59,47 @@ async def test_async_setup_entry_authentication_error( assert mock_config_entry.state is state -async def test_status_error_during_setup_retries( +async def test_offline_device_stays_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: AsyncMock, ) -> None: - """Test auth error from per-device status marks device offline.""" - mock_api_client.async_get_device_status.side_effect = ( - FlussApiClientAuthenticationError("permission revoked") - ) + """A 503 keeps the device but marks it offline; the rest still load.""" + + async def _status(device_id: str) -> dict[str, Any]: + if device_id == "2a303030sdj1": + raise FlussDeviceOfflineError("offline") + return {"status": {"internetConnected": True}} + + mock_api_client.async_get_device_status.side_effect = _status + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert hass.states.get("button.device_1").state == STATE_UNAVAILABLE + assert hass.states.get("button.device_2").state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "exception", + [ + FlussApiClientError("boom"), + FlussApiClientAuthenticationError("permission revoked"), + ], +) +async def test_failed_status_fails_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: AsyncMock, + exception: Exception, +) -> None: + """A non-offline status error fails the whole refresh.""" + + async def _status(device_id: str) -> dict[str, Any]: + if device_id == "2a303030sdj1": + raise exception + return {"status": {"internetConnected": True}} + + mock_api_client.async_get_device_status.side_effect = _status await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 21260bf1abb05c539c46fb1d45e30fb32d76530a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 4 Jun 2026 17:53:06 +0200 Subject: [PATCH 135/153] Fix value template in MQTT Fan and Siren subentry setup (#172980) --- homeassistant/components/mqtt/config_flow.py | 4 ++-- tests/components/mqtt/common.py | 10 ++++++---- tests/components/mqtt/test_config_flow.py | 4 ++-- tests/components/mqtt/test_mixins.py | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 6bfadb63155a1..77633b564a719 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2451,7 +2451,7 @@ class PlatformField: validator=valid_subscribe_topic, error="invalid_subscribe_topic", ), - CONF_VALUE_TEMPLATE: PlatformField( + CONF_STATE_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, validator=validate(cv.template), @@ -3395,7 +3395,7 @@ class PlatformField: validator=valid_subscribe_topic, error="invalid_subscribe_topic", ), - CONF_VALUE_TEMPLATE: PlatformField( + CONF_STATE_VALUE_TEMPLATE: PlatformField( selector=TEMPLATE_SELECTOR, required=False, validator=validate(cv.template), diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 72860a39b5451..0af4ebee425c1 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -354,7 +354,7 @@ "entity_category": None, "state_topic": "test-topic", "command_template": "{{ value }}", - "value_template": "{{ value_json.value }}", + "state_value_template": "{{ value_json.value }}", "percentage_command_topic": "test-topic/pct", "percentage_state_topic": "test-topic/pct", "percentage_command_template": "{{ value }}", @@ -637,7 +637,7 @@ "state_topic": "test-topic", "command_template": "{{ value }}", "command_off_template": "{{ value }}", - "value_template": "{{ value_json.value }}", + "state_value_template": "{{ value_json.value }}", "payload_off": "OFF", "payload_on": "ON", "available_tones": ["Happy hour", "Cooling alarm"], @@ -957,11 +957,13 @@ }, } }, - "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 + "components": MOCK_SUBENTRY_FAN_COMPONENT + | MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 | MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT | MOCK_SUBENTRY_SWITCH_COMPONENT - | MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL, + | MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL + | MOCK_SUBENTRY_SIREN_COMPONENT, } | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f8b3bbfa48321..0b13f5f8b3ea3 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3243,7 +3243,7 @@ async def test_migrate_of_incompatible_config_entry( "command_topic": "test-topic", "command_template": "{{ value }}", "state_topic": "test-topic", - "value_template": "{{ value_json.value }}", + "state_value_template": "{{ value_json.value }}", "fan_speed_settings": { "percentage_command_template": "{{ value }}", "percentage_command_topic": "test-topic/pct", @@ -3802,7 +3802,7 @@ async def test_migrate_of_incompatible_config_entry( "command_topic": "test-topic", "command_template": "{{ value }}", "state_topic": "test-topic", - "value_template": "{{ value_json.value }}", + "state_value_template": "{{ value_json.value }}", "optimistic": True, "available_tones": ["Happy hour", "Cooling alarm"], "support_duration": True, diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index dfecd2891a1df..3b9560582fdda 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -676,7 +676,7 @@ async def test_loading_subentries( entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}" state = hass.states.get(entity_id) assert state is not None - assert state.state == "unknown" + assert state.state in ("off", "unknown") @pytest.mark.parametrize( From bbeb2ac6676e8d2f8ba4905d4858371732679066 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:55:08 +0200 Subject: [PATCH 136/153] Vistapool: Add reconfiguration flow (#172836) Co-authored-by: Claude --- .../components/vistapool/config_flow.py | 37 +++++++++ .../components/vistapool/quality_scale.yaml | 2 +- .../components/vistapool/strings.json | 14 +++- .../components/vistapool/test_config_flow.py | 77 +++++++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vistapool/config_flow.py b/homeassistant/components/vistapool/config_flow.py index 15f6346c53c22..760a115bc5c1f 100644 --- a/homeassistant/components/vistapool/config_flow.py +++ b/homeassistant/components/vistapool/config_flow.py @@ -22,6 +22,8 @@ } ) +RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): cv.string}) + class VistapoolConfigFlow(ConfigFlow, domain=DOMAIN): """Vistapool config flow (one entry per Hayward account).""" @@ -74,3 +76,38 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=AUTH_SCHEMA, errors=errors ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user proactively update the stored Vistapool password.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + username = entry.data[CONF_USERNAME] + + if user_input is not None: + password = user_input[CONF_PASSWORD] + session = async_get_clientsession(self.hass) + auth = AquariteAuth(session, username, password) + try: + await auth.authenticate() + except AuthenticationError: + errors["base"] = "invalid_auth" + except AquariteError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reconfiguration") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(auth.user_id) + self._abort_if_unique_id_mismatch(reason="account_mismatch") + return self.async_update_reload_and_abort( + entry, data_updates={CONF_PASSWORD: password} + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=RECONFIGURE_SCHEMA, + description_placeholders={"username": username}, + errors=errors, + ) diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 876cb7bd86cac..d69380658ecb2 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -55,7 +55,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No known repair scenarios diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index e12ecfe545ec3..557c9c420626c 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -1,8 +1,10 @@ { "config": { "abort": { + "account_mismatch": "The credentials entered are for a different Vistapool account than the one being reconfigured.", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -12,6 +14,16 @@ }, "flow_title": "Vistapool pool controller", "step": { + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The new password for your Vistapool account." + }, + "description": "Update the stored password for {username}.", + "title": "Reconfigure Vistapool" + }, "user": { "data": { "password": "Password", diff --git a/tests/components/vistapool/test_config_flow.py b/tests/components/vistapool/test_config_flow.py index 921207e707467..2057fd4490fe6 100644 --- a/tests/components/vistapool/test_config_flow.py +++ b/tests/components/vistapool/test_config_flow.py @@ -303,3 +303,80 @@ async def test_dhcp_discovery_aborts_when_in_progress( assert second["type"] is FlowResultType.ABORT assert second["reason"] == "already_in_progress" + + +_NEW_PASSWORD = "new-password" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the reconfigure flow updates the stored password and reloads the entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: _NEW_PASSWORD} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_PASSWORD] == _NEW_PASSWORD + assert mock_setup_entry.call_count == 1 + + +async def test_reconfigure_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_vistapool_client: AsyncMock, + mock_vistapool_auth: MagicMock, +) -> None: + """Test the reconfigure flow surfaces invalid_auth and recovers on retry.""" + mock_config_entry.add_to_hass(hass) + mock_vistapool_auth.authenticate.side_effect = AuthenticationError + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: _NEW_PASSWORD} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_vistapool_auth.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: _NEW_PASSWORD} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_PASSWORD] == _NEW_PASSWORD + assert mock_setup_entry.call_count == 1 + + +async def test_reconfigure_account_mismatch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_vistapool_client: AsyncMock, + mock_vistapool_auth: MagicMock, +) -> None: + """Test the reconfigure flow aborts when credentials belong to a different account.""" + mock_config_entry.add_to_hass(hass) + mock_vistapool_auth.user_id = "a-different-firebase-uid" + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: _NEW_PASSWORD} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "account_mismatch" + assert mock_setup_entry.call_count == 0 From e1d90fd244cd7f35673f6184d9372f7c7b725957 Mon Sep 17 00:00:00 2001 From: Lukas <12813107+lmaertin@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:19:59 +0200 Subject: [PATCH 137/153] Add source selection to samsung_infrared media player (#172794) --- .../samsung_infrared/media_player.py | 37 ++++++++++++- .../components/samsung_infrared/strings.json | 20 +++++++ .../snapshots/test_media_player.ambr | 20 +++++-- .../samsung_infrared/test_media_player.py | 53 +++++++++++++++++++ 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/samsung_infrared/media_player.py b/homeassistant/components/samsung_infrared/media_player.py index 15e26542dfe91..fa2c22d826ed6 100644 --- a/homeassistant/components/samsung_infrared/media_player.py +++ b/homeassistant/components/samsung_infrared/media_player.py @@ -11,13 +11,27 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_DEVICE_TYPE, CONF_INFRARED_EMITTER_ENTITY_ID, SamsungDeviceType +from .const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, + SamsungDeviceType, +) from .entity import SamsungIrEntity PARALLEL_UPDATES = 1 +SOURCE_MAP: dict[str, SamsungTVCode] = { + "tv": SamsungTVCode.TV, + "hdmi_1": SamsungTVCode.HDMI_1, + "hdmi_2": SamsungTVCode.HDMI_2, + "hdmi_3": SamsungTVCode.HDMI_3, + "hdmi_4": SamsungTVCode.HDMI_4, +} + async def async_setup_entry( hass: HomeAssistant, @@ -49,13 +63,17 @@ class SamsungIrTvMediaPlayer( | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_source_list = list(SOURCE_MAP.keys()) + _attr_source = None + _attr_state = MediaPlayerState.ON + _attr_translation_key = "samsung_ir_tv" def __init__(self, entry: ConfigEntry, infrared_emitter_entity_id: str) -> None: """Initialize Samsung IR media player.""" super().__init__(entry, unique_id_suffix="media_player") self._infrared_emitter_entity_id = infrared_emitter_entity_id - self._attr_state = MediaPlayerState.ON async def async_turn_on(self) -> None: """Turn on the TV.""" @@ -96,3 +114,18 @@ async def async_media_pause(self) -> None: async def async_media_stop(self) -> None: """Send stop command.""" await self._send_command(SamsungTVCode.STOP.to_command()) + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + if (code := SOURCE_MAP.get(source)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_source", + translation_placeholders={ + "invalid_source": source, + "valid_sources": ", ".join(self._attr_source_list), + }, + ) + await self._send_command(code.to_command()) + self._attr_source = source + self.async_write_ha_state() diff --git a/homeassistant/components/samsung_infrared/strings.json b/homeassistant/components/samsung_infrared/strings.json index 964ec0013e692..464876d412209 100644 --- a/homeassistant/components/samsung_infrared/strings.json +++ b/homeassistant/components/samsung_infrared/strings.json @@ -123,6 +123,26 @@ "yellow": { "name": "Yellow" } + }, + "media_player": { + "samsung_ir_tv": { + "state_attributes": { + "source": { + "state": { + "hdmi_1": "HDMI 1", + "hdmi_2": "HDMI 2", + "hdmi_3": "HDMI 3", + "hdmi_4": "HDMI 4", + "tv": "[%key:common::generic::tv%]" + } + } + } + } + } + }, + "exceptions": { + "invalid_source": { + "message": "Cannot select input source {invalid_source} for media player. Valid sources: {valid_sources}." } }, "selector": { diff --git a/tests/components/samsung_infrared/snapshots/test_media_player.ambr b/tests/components/samsung_infrared/snapshots/test_media_player.ambr index 89516c8ad72a5..a5610591bd0d7 100644 --- a/tests/components/samsung_infrared/snapshots/test_media_player.ambr +++ b/tests/components/samsung_infrared/snapshots/test_media_player.ambr @@ -6,6 +6,13 @@ ]), 'area_id': None, 'capabilities': dict({ + 'source_list': list([ + 'tv', + 'hdmi_1', + 'hdmi_2', + 'hdmi_3', + 'hdmi_4', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -31,8 +38,8 @@ 'platform': 'samsung_infrared', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'samsung_ir_tv', 'unique_id': '01JTEST0000000000000000000_media_player', 'unit_of_measurement': None, }) @@ -43,7 +50,14 @@ 'assumed_state': True, 'device_class': 'tv', 'friendly_name': 'Samsung TV', - 'supported_features': , + 'source_list': list([ + 'tv', + 'hdmi_1', + 'hdmi_2', + 'hdmi_3', + 'hdmi_4', + ]), + 'supported_features': , }), 'context': , 'entity_id': 'media_player.samsung_tv', diff --git a/tests/components/samsung_infrared/test_media_player.py b/tests/components/samsung_infrared/test_media_player.py index 8b87c797b18ef..e22d56104becf 100644 --- a/tests/components/samsung_infrared/test_media_player.py +++ b/tests/components/samsung_infrared/test_media_player.py @@ -5,12 +5,14 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + SERVICE_SELECT_SOURCE, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, @@ -19,6 +21,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry, snapshot_platform @@ -89,3 +92,53 @@ async def test_media_player_action_sends_correct_code( assert len(mock_infrared_emitter_entity.send_command_calls) == 1 assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code + + +@pytest.mark.parametrize( + ("source", "expected_code"), + [ + ("tv", SamsungTVCode.TV), + ("hdmi_1", SamsungTVCode.HDMI_1), + ("hdmi_2", SamsungTVCode.HDMI_2), + ("hdmi_3", SamsungTVCode.HDMI_3), + ("hdmi_4", SamsungTVCode.HDMI_4), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_media_player_select_source_sends_correct_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + source: str, + expected_code: SamsungTVCode, +) -> None: + """Test selecting a source sends the mapped IR code.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_INPUT_SOURCE: source}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 1 + assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.attributes[ATTR_INPUT_SOURCE] == source + + +@pytest.mark.usefixtures("init_integration") +async def test_media_player_select_source_invalid_raises( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, +) -> None: + """Test selecting an invalid source raises a validation error.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_INPUT_SOURCE: "hdmi_5"}, + blocking=True, + ) + + assert not mock_infrared_emitter_entity.send_command_calls From 7dbce7863a8cb4e6998229d26c2432aacd6424c3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 4 Jun 2026 18:24:53 +0200 Subject: [PATCH 138/153] Bump holidays to 0.98 (#173029) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index fdb45debbef0f..024983c07f565 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.97", "babel==2.15.0"] + "requirements": ["holidays==0.98", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 2bc057d13ccbc..56bf355524692 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.97"] + "requirements": ["holidays==0.98"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b97b3667cdc8..25d383af85bcf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1266,7 +1266,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.97 +holidays==0.98 # homeassistant.components.frontend home-assistant-frontend==20260527.4 From 6e53787d98f7ba49ee251414d7b1b30c8831fcd6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 4 Jun 2026 11:51:02 -0500 Subject: [PATCH 139/153] Bump hassil to 3.6.0 (#173031) --- homeassistant/components/assist_satellite/manifest.json | 2 +- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index dac091be9c97d..5ce409a3ea8aa 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0"] + "requirements": ["hassil==3.6.0"] } diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index bc353271a27d1..0945e52b2c14d 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"] + "requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c38473c183d97..1f4541c58001f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 habluetooth==6.8.1 hass-nabucasa==2.2.0 -hassil==3.5.0 +hassil==3.6.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260527.4 home-assistant-intents==2026.6.1 diff --git a/requirements.txt b/requirements.txt index 5fa2a4e29975c..b19b282fd40c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ cryptography==48.0.0 fnv-hash-fast==2.0.3 ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 -hassil==3.5.0 +hassil==3.6.0 home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.1 httpx==0.28.1 diff --git a/requirements_all.txt b/requirements_all.txt index 25d383af85bcf..780b956fec995 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hass-splunk==0.1.4 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==3.5.0 +hassil==3.6.0 # homeassistant.components.jewish_calendar hdate[astral]==1.2.1 From 983501406f665b29a56ebdd13c5e10937d5be298 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 4 Jun 2026 18:25:22 +0100 Subject: [PATCH 140/153] Deprecate Evohome's `refresh_system` action (#169894) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Joost Lekkerkerker --- homeassistant/components/evohome/const.py | 2 + homeassistant/components/evohome/services.py | 6 +++ homeassistant/components/evohome/strings.json | 8 +++- tests/components/evohome/test_services.py | 45 ++++++++++++++++++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index eab8a8d97a84f..06baf09cfc4c8 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -24,6 +24,8 @@ ATTR_PERIOD: Final = "period" # number of days ATTR_SETPOINT: Final = "setpoint" +# Support for the refresh_system service is being deprecated +REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0" # Support for the reset service calls/presets is being deprecated RESET_BREAKS_IN_HA_VERSION: Final = "2026.11.0" # Support for untargeted service calls to controllers is being deprecated diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index b65a68da1b1a5..6e465338ee745 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -29,6 +29,7 @@ ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, + REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, SERVICE_BREAKS_IN_HA_VERSION, EvoService, @@ -204,6 +205,11 @@ def setup_service_functions( @verify_domain_control(DOMAIN) async def force_refresh(call: ServiceCall) -> None: """Obtain the latest state data via the vendor's RESTful API.""" + async_create_deprecation_issue_once( + hass, + "deprecated_refresh_system_service", + REFRESH_BREAKS_IN_HA_VERSION, + ) await coordinator.async_refresh() @verify_domain_control(DOMAIN) diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 150c1662bf811..047e961c977aa 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -31,13 +31,17 @@ "title": "Evohome 'Clear zone override' action is deprecated" }, "deprecated_controller_service": { - "description": "The `{service}` action without `entity_id` is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Update any automation or script to include the Evohome controller climate entity `entity_id`.", + "description": "The `{service}` action without `entity_id` is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Update any automation or script to include `entity_id`, targeting the controller's climate entity.", "title": "Untargeted Evohome controller action is deprecated" }, "deprecated_preset_reset": { "description": "Using the `Reset` preset on an Evohome controller is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Use the system's Reset button instead.", "title": "Evohome Reset preset is deprecated" }, + "deprecated_refresh_system_service": { + "description": "The `refresh_system` action is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Instead, use the `homeassistant.update_entity` action, targeting the controller's climate entity.", + "title": "Evohome 'Refresh system' action is deprecated" + }, "deprecated_reset_system_service": { "description": "The `reset_system` action is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Use the system's Reset button instead.", "title": "Evohome 'Reset system' action is deprecated" @@ -49,7 +53,7 @@ "name": "Clear zone override" }, "refresh_system": { - "description": "Pulls the latest data from the vendor's servers now, rather than waiting for the next scheduled update.", + "description": "Pulls the latest data from the vendor's servers now, rather than waiting for the next scheduled update (deprecated).", "name": "Refresh system" }, "reset_system": { diff --git a/tests/components/evohome/test_services.py b/tests/components/evohome/test_services.py index 0bc8edf35b96d..f5ac8de6f548f 100644 --- a/tests/components/evohome/test_services.py +++ b/tests/components/evohome/test_services.py @@ -14,6 +14,7 @@ ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, + REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, SERVICE_BREAKS_IN_HA_VERSION, EvoService, @@ -25,14 +26,22 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES +from homeassistant.setup import async_setup_component from .const import TEST_INSTALLS @pytest.mark.parametrize("install", ["default"]) @pytest.mark.usefixtures("evohome") -async def test_refresh_system(hass: HomeAssistant) -> None: - """Test Evohome's refresh_system service (for all temperature control systems).""" +async def test_refresh_system_deprecated( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test Evohome's refresh_system service. + + This service call remains supported during the deprecation window but should cause + a Repair issue. + """ # EvoService.REFRESH_SYSTEM with patch("evohomeasync2.location.Location.update") as mock_fcn: @@ -45,6 +54,38 @@ async def test_refresh_system(hass: HomeAssistant) -> None: mock_fcn.assert_awaited_once_with() + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_refresh_system_service") + assert issue is not None + assert issue.translation_key == "deprecated_refresh_system_service" + assert issue.translation_placeholders == { + "breaks_in_ha_version": REFRESH_BREAKS_IN_HA_VERSION, + } + + +@pytest.mark.parametrize("install", ["default"]) +async def test_update_entity( + hass: HomeAssistant, + ctl_id: str, +) -> None: + """Test that homeassistant.update_entity triggers an appropriate refresh. + + Any evohome entity can be targeted; the API invoked by the shared coordinator + refreshes the whole location. + """ + + await async_setup_component(hass, "homeassistant", {}) + + with patch("evohomeasync2.location.Location.update") as mock_fcn: + await hass.services.async_call( + "homeassistant", + "update_entity", + {}, + target={ATTR_ENTITY_ID: ctl_id}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + @pytest.mark.parametrize("install", TEST_INSTALLS) @pytest.mark.usefixtures("evohome") From 045ba4e1dd9e96fe387d05fef87dbe5ec3f1eeaf Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 4 Jun 2026 19:31:46 +0200 Subject: [PATCH 141/153] API refactor to replace assert (#172862) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/api/__init__.py | 8 +++++--- tests/components/api/test_init.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 3c97c2078ea26..933fca543deec 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -222,7 +222,7 @@ def get(self, request: web.Request) -> web.Response: states = ( state.as_dict_json for state in hass.states.async_all() - if entity_perm(state.entity_id, "read") + if entity_perm(state.entity_id, POLICY_READ) ) response = web.Response( body=b"".join((b"[", b",".join(states), b"]")), @@ -294,8 +294,10 @@ async def post(self, request: web.Request, entity_id: str) -> web.Response: # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK - state = hass.states.get(entity_id) - assert state + if (state := hass.states.get(entity_id)) is None: + return self.json_message( + "Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR + ) resp = self.json(state.as_dict(), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 58a227c7657bf..ddf31392eb5d8 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -124,6 +124,18 @@ async def test_api_state_change_with_bad_state( assert resp.status == HTTPStatus.BAD_REQUEST +async def test_api_state_change_internal_error( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test 500 is returned when state cannot be read back after being set.""" + with patch.object(ha.StateMachine, "async_set"): + resp = await mock_api_client.post( + "/api/states/test.entity", json={"state": "on"} + ) + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert await resp.json() == {"message": "Error storing state."} + + async def test_api_state_change_with_bad_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: From 80e71660e6a19ba86cd5ebf4b4bfcb92c8411e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 4 Jun 2026 19:42:40 +0200 Subject: [PATCH 142/153] Avoid re-registering listeners at common.py from Home Connect (#172851) --- .../components/home_connect/common.py | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index 61e9e56016e4a..e0f26a54a0ec4 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -108,27 +108,32 @@ def _handle_paired_or_connected_appliance( ) if entity.unique_id not in known_entity_unique_ids ) - for event_key in ( - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + if not ( + callbacks_for_appliance := changed_options_listener_remove_callbacks[ + appliance_ha_id + ] ): - changed_options_listener_remove_callback = ( - appliance_coordinator.async_add_listener( - partial( - _create_option_entities, - entity_registry, - appliance_coordinator, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), - event_key, + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + appliance_coordinator.async_add_listener( + partial( + _create_option_entities, + entity_registry, + appliance_coordinator, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + event_key, + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + callbacks_for_appliance.append( + changed_options_listener_remove_callback ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance_ha_id].append( - changed_options_listener_remove_callback - ) known_entity_unique_ids.update( {cast(str, entity.unique_id): appliance_ha_id for entity in entities_to_add} ) From 69fb1e142c78f46669b69be4e3a6cd3146577591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 4 Jun 2026 19:43:04 +0200 Subject: [PATCH 143/153] Fix platfoms fixtures return type at Home Connect (#172849) --- tests/components/home_connect/test_binary_sensor.py | 2 +- tests/components/home_connect/test_button.py | 2 +- tests/components/home_connect/test_climate.py | 2 +- tests/components/home_connect/test_coordinator.py | 2 +- tests/components/home_connect/test_entity.py | 2 +- tests/components/home_connect/test_fan.py | 2 +- tests/components/home_connect/test_light.py | 2 +- tests/components/home_connect/test_number.py | 2 +- tests/components/home_connect/test_select.py | 2 +- tests/components/home_connect/test_sensor.py | 2 +- tests/components/home_connect/test_switch.py | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 674c5e23de1f9..74786f095808c 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -35,7 +35,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.BINARY_SENSOR] diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index 27d7a16589e02..61cdcda0aecc9 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -29,7 +29,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.BUTTON] diff --git a/tests/components/home_connect/test_climate.py b/tests/components/home_connect/test_climate.py index 4b8de1f8fea2b..225d27a9b4aa5 100644 --- a/tests/components/home_connect/test_climate.py +++ b/tests/components/home_connect/test_climate.py @@ -71,7 +71,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.CLIMATE] diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index c4a4125befdb2..b053c5c870ba9 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -81,7 +81,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.SENSOR, Platform.SWITCH] diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index 7cb1fbb811bb9..b9704a046aea8 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -47,7 +47,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.SWITCH] diff --git a/tests/components/home_connect/test_fan.py b/tests/components/home_connect/test_fan.py index 246df6e8878ab..5c0a11ec3c864 100644 --- a/tests/components/home_connect/test_fan.py +++ b/tests/components/home_connect/test_fan.py @@ -55,7 +55,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.FAN] diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 2d1e6448712f2..0bc60e33d73b4 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -48,7 +48,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.LIGHT] diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 8ff2baf94570f..f369ba629f6fa 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -58,7 +58,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.NUMBER] diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 84f775b3ceab9..4f889c483ae20 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -60,7 +60,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.SELECT] diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 48af938161408..ab286a0e95001 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -110,7 +110,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.SENSOR] diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index e5ba84e854b85..867881bf3204f 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -54,7 +54,7 @@ @pytest.fixture -def platforms() -> list[str]: +def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" return [Platform.SWITCH] From d825b6afa8502fdfefbe83f95651de927b208a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 4 Jun 2026 19:43:24 +0200 Subject: [PATCH 144/153] Sort Home Connect service.yaml programs (#172848) --- .../components/home_connect/services.yaml | 190 +++++++++--------- 1 file changed, 95 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index af9a2400459e3..4ec6edc023b1c 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -22,141 +22,147 @@ set_program_and_options: custom_value: false translation_key: programs options: + - consumer_products_cleaning_robot_program_basic_go_home - consumer_products_cleaning_robot_program_cleaning_clean_all - consumer_products_cleaning_robot_program_cleaning_clean_map - - consumer_products_cleaning_robot_program_basic_go_home - - consumer_products_coffee_maker_program_beverage_ristretto + - consumer_products_coffee_maker_program_beverage_caffe_grande + - consumer_products_coffee_maker_program_beverage_caffe_latte + - consumer_products_coffee_maker_program_beverage_cappuccino + - consumer_products_coffee_maker_program_beverage_coffee - consumer_products_coffee_maker_program_beverage_espresso - consumer_products_coffee_maker_program_beverage_espresso_doppio - - consumer_products_coffee_maker_program_beverage_coffee - - consumer_products_coffee_maker_program_beverage_x_l_coffee - - consumer_products_coffee_maker_program_beverage_caffe_grande - consumer_products_coffee_maker_program_beverage_espresso_macchiato - - consumer_products_coffee_maker_program_beverage_cappuccino + - consumer_products_coffee_maker_program_beverage_hot_water - consumer_products_coffee_maker_program_beverage_latte_macchiato - - consumer_products_coffee_maker_program_beverage_caffe_latte - consumer_products_coffee_maker_program_beverage_milk_froth + - consumer_products_coffee_maker_program_beverage_ristretto - consumer_products_coffee_maker_program_beverage_warm_milk - - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner - - consumer_products_coffee_maker_program_coffee_world_grosser_brauner - - consumer_products_coffee_maker_program_coffee_world_verlaengerter - - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun - - consumer_products_coffee_maker_program_coffee_world_wiener_melange - - consumer_products_coffee_maker_program_coffee_world_flat_white - - consumer_products_coffee_maker_program_coffee_world_cortado - - consumer_products_coffee_maker_program_coffee_world_cafe_cortado - - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche + - consumer_products_coffee_maker_program_beverage_x_l_coffee + - consumer_products_coffee_maker_program_coffee_world_americano + - consumer_products_coffee_maker_program_coffee_world_black_eye - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait + - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche + - consumer_products_coffee_maker_program_coffee_world_cafe_cortado + - consumer_products_coffee_maker_program_coffee_world_cortado + - consumer_products_coffee_maker_program_coffee_world_dead_eye - consumer_products_coffee_maker_program_coffee_world_doppio - - consumer_products_coffee_maker_program_coffee_world_kaapi - - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd + - consumer_products_coffee_maker_program_coffee_world_flat_white - consumer_products_coffee_maker_program_coffee_world_galao - consumer_products_coffee_maker_program_coffee_world_garoto - - consumer_products_coffee_maker_program_coffee_world_americano + - consumer_products_coffee_maker_program_coffee_world_grosser_brauner + - consumer_products_coffee_maker_program_coffee_world_kaapi + - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner + - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd - consumer_products_coffee_maker_program_coffee_world_red_eye - - consumer_products_coffee_maker_program_coffee_world_black_eye - - consumer_products_coffee_maker_program_coffee_world_dead_eye - - consumer_products_coffee_maker_program_beverage_hot_water - - dishcare_dishwasher_program_pre_rinse + - consumer_products_coffee_maker_program_coffee_world_verlaengerter + - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun + - consumer_products_coffee_maker_program_coffee_world_wiener_melange + - cooking_common_program_hood_automatic + - cooking_common_program_hood_delayed_shut_off + - cooking_common_program_hood_venting + - cooking_oven_program_heating_mode_3_d_hot_air + - cooking_oven_program_heating_mode_air_fry + - cooking_oven_program_heating_mode_bottom_heating + - cooking_oven_program_heating_mode_bread_baking + - cooking_oven_program_heating_mode_defrost + - cooking_oven_program_heating_mode_desiccation + - cooking_oven_program_heating_mode_dough_proving + - cooking_oven_program_heating_mode_frozen_heatup_special + - cooking_oven_program_heating_mode_grill_large_area + - cooking_oven_program_heating_mode_grill_small_area + - cooking_oven_program_heating_mode_hot_air + - cooking_oven_program_heating_mode_hot_air_100_steam + - cooking_oven_program_heating_mode_hot_air_30_steam + - cooking_oven_program_heating_mode_hot_air_60_steam + - cooking_oven_program_heating_mode_hot_air_80_steam + - cooking_oven_program_heating_mode_hot_air_eco + - cooking_oven_program_heating_mode_hot_air_gentle + - cooking_oven_program_heating_mode_hot_air_grilling + - cooking_oven_program_heating_mode_intensive_heat + - cooking_oven_program_heating_mode_keep_warm + - cooking_oven_program_heating_mode_pizza_setting + - cooking_oven_program_heating_mode_pre_heating + - cooking_oven_program_heating_mode_preheat_ovenware + - cooking_oven_program_heating_mode_proof + - cooking_oven_program_heating_mode_sabbath_programme + - cooking_oven_program_heating_mode_slow_cook + - cooking_oven_program_heating_mode_top_bottom_heating + - cooking_oven_program_heating_mode_top_bottom_heating_eco + - cooking_oven_program_heating_mode_warming_drawer + - cooking_oven_program_microwave_1000_watt + - cooking_oven_program_microwave_180_watt + - cooking_oven_program_microwave_360_watt + - cooking_oven_program_microwave_450_watt + - cooking_oven_program_microwave_600_watt + - cooking_oven_program_microwave_900_watt + - cooking_oven_program_microwave_90_watt + - cooking_oven_program_microwave_max + - cooking_oven_program_steam_modes_steam - dishcare_dishwasher_program_auto_1 - dishcare_dishwasher_program_auto_2 - dishcare_dishwasher_program_auto_3 + - dishcare_dishwasher_program_auto_half_load - dishcare_dishwasher_program_eco_50 - - dishcare_dishwasher_program_quick_45 - - dishcare_dishwasher_program_intensiv_70 - - dishcare_dishwasher_program_normal_65 + - dishcare_dishwasher_program_express_sparkle_65 - dishcare_dishwasher_program_glas_40 - dishcare_dishwasher_program_glass_care - - dishcare_dishwasher_program_night_wash - - dishcare_dishwasher_program_quick_65 - - dishcare_dishwasher_program_normal_45 - dishcare_dishwasher_program_intensiv_45 - - dishcare_dishwasher_program_auto_half_load + - dishcare_dishwasher_program_intensiv_70 - dishcare_dishwasher_program_intensiv_power - dishcare_dishwasher_program_intensive_fixed_zone - - dishcare_dishwasher_program_magic_daily - - dishcare_dishwasher_program_super_60 - dishcare_dishwasher_program_kurz_60 - - dishcare_dishwasher_program_express_sparkle_65 + - dishcare_dishwasher_program_learning_dishwasher - dishcare_dishwasher_program_machine_care - - dishcare_dishwasher_program_steam_fresh + - dishcare_dishwasher_program_magic_daily - dishcare_dishwasher_program_maximum_cleaning - dishcare_dishwasher_program_mixed_load - - dishcare_dishwasher_program_learning_dishwasher + - dishcare_dishwasher_program_night_wash + - dishcare_dishwasher_program_normal_45 + - dishcare_dishwasher_program_normal_65 + - dishcare_dishwasher_program_pre_rinse + - dishcare_dishwasher_program_quick_45 + - dishcare_dishwasher_program_quick_65 + - dishcare_dishwasher_program_steam_fresh + - dishcare_dishwasher_program_super_60 - heating_ventilation_air_conditioning_air_conditioner_program_active_clean - heating_ventilation_air_conditioning_air_conditioner_program_auto - heating_ventilation_air_conditioning_air_conditioner_program_cool - heating_ventilation_air_conditioning_air_conditioner_program_dry - heating_ventilation_air_conditioning_air_conditioner_program_fan - heating_ventilation_air_conditioning_air_conditioner_program_heat - - laundry_care_dryer_program_cotton - - laundry_care_dryer_program_synthetic - - laundry_care_dryer_program_mix + - laundry_care_dryer_program_anti_shrink - laundry_care_dryer_program_blankets - laundry_care_dryer_program_business_shirts + - laundry_care_dryer_program_cotton + - laundry_care_dryer_program_delicates + - laundry_care_dryer_program_dessous - laundry_care_dryer_program_down_feathers - laundry_care_dryer_program_hygiene + - laundry_care_dryer_program_in_basket - laundry_care_dryer_program_jeans + - laundry_care_dryer_program_mix + - laundry_care_dryer_program_my_time_my_drying_time - laundry_care_dryer_program_outdoor - - laundry_care_dryer_program_synthetic_refresh - - laundry_care_dryer_program_towels - - laundry_care_dryer_program_delicates - - laundry_care_dryer_program_super_40 - - laundry_care_dryer_program_shirts_15 - laundry_care_dryer_program_pillow - - laundry_care_dryer_program_anti_shrink - - laundry_care_dryer_program_my_time_my_drying_time + - laundry_care_dryer_program_shirts_15 + - laundry_care_dryer_program_super_40 + - laundry_care_dryer_program_synthetic + - laundry_care_dryer_program_synthetic_refresh - laundry_care_dryer_program_time_cold - - laundry_care_dryer_program_time_warm - - laundry_care_dryer_program_in_basket - laundry_care_dryer_program_time_cold_fix_time_cold_20 - laundry_care_dryer_program_time_cold_fix_time_cold_30 - laundry_care_dryer_program_time_cold_fix_time_cold_60 + - laundry_care_dryer_program_time_warm - laundry_care_dryer_program_time_warm_fix_time_warm_30 - laundry_care_dryer_program_time_warm_fix_time_warm_40 - laundry_care_dryer_program_time_warm_fix_time_warm_60 - - laundry_care_dryer_program_dessous - - cooking_common_program_hood_automatic - - cooking_common_program_hood_venting - - cooking_common_program_hood_delayed_shut_off - - cooking_oven_program_heating_mode_3_d_hot_air - - cooking_oven_program_heating_mode_air_fry - - cooking_oven_program_heating_mode_grill_large_area - - cooking_oven_program_heating_mode_grill_small_area - - cooking_oven_program_heating_mode_pre_heating - - cooking_oven_program_heating_mode_hot_air - - cooking_oven_program_heating_mode_hot_air_eco - - cooking_oven_program_heating_mode_hot_air_gentle - - cooking_oven_program_heating_mode_hot_air_grilling - - cooking_oven_program_heating_mode_top_bottom_heating - - cooking_oven_program_heating_mode_top_bottom_heating_eco - - cooking_oven_program_heating_mode_bottom_heating - - cooking_oven_program_heating_mode_bread_baking - - cooking_oven_program_heating_mode_pizza_setting - - cooking_oven_program_heating_mode_slow_cook - - cooking_oven_program_heating_mode_intensive_heat - - cooking_oven_program_heating_mode_keep_warm - - cooking_oven_program_heating_mode_preheat_ovenware - - cooking_oven_program_heating_mode_frozen_heatup_special - - cooking_oven_program_heating_mode_desiccation - - cooking_oven_program_heating_mode_defrost - - cooking_oven_program_heating_mode_dough_proving - - cooking_oven_program_heating_mode_proof - - cooking_oven_program_heating_mode_hot_air_30_steam - - cooking_oven_program_heating_mode_hot_air_60_steam - - cooking_oven_program_heating_mode_hot_air_80_steam - - cooking_oven_program_heating_mode_hot_air_100_steam - - cooking_oven_program_heating_mode_sabbath_programme - - cooking_oven_program_microwave_90_watt - - cooking_oven_program_microwave_180_watt - - cooking_oven_program_microwave_360_watt - - cooking_oven_program_microwave_450_watt - - cooking_oven_program_microwave_600_watt - - cooking_oven_program_microwave_900_watt - - cooking_oven_program_microwave_1000_watt - - cooking_oven_program_microwave_max - - cooking_oven_program_steam_modes_steam - - cooking_oven_program_heating_mode_warming_drawer + - laundry_care_dryer_program_towels + - laundry_care_washer_dryer_program_cotton + - laundry_care_washer_dryer_program_cotton_eco_4060 + - laundry_care_washer_dryer_program_easy_care + - laundry_care_washer_dryer_program_mix + - laundry_care_washer_dryer_program_wash_and_dry_60 + - laundry_care_washer_dryer_program_wash_and_dry_90 - laundry_care_washer_program_auto_30 - laundry_care_washer_program_auto_40 - laundry_care_washer_program_auto_60 @@ -190,12 +196,6 @@ set_program_and_options: - laundry_care_washer_program_towels - laundry_care_washer_program_water_proof - laundry_care_washer_program_wool - - laundry_care_washer_dryer_program_cotton - - laundry_care_washer_dryer_program_cotton_eco_4060 - - laundry_care_washer_dryer_program_mix - - laundry_care_washer_dryer_program_easy_care - - laundry_care_washer_dryer_program_wash_and_dry_60 - - laundry_care_washer_dryer_program_wash_and_dry_90 air_conditioner_options: collapsed: true fields: From 467c2fdd57201e7385979fb9c3e3a105793fe88d Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:43:56 +0200 Subject: [PATCH 145/153] Add light platform to Vistapool (#172549) Co-authored-by: Claude Co-authored-by: Joost Lekkerkerker --- .../components/vistapool/__init__.py | 7 +- homeassistant/components/vistapool/light.py | 73 +++++++++ .../components/vistapool/strings.json | 5 + .../vistapool/snapshots/test_light.ambr | 60 ++++++++ tests/components/vistapool/test_light.py | 138 ++++++++++++++++++ 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vistapool/light.py create mode 100644 tests/components/vistapool/snapshots/test_light.ambr create mode 100644 tests/components/vistapool/test_light.py diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index fd7321a16625a..0f21964ffa626 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -16,7 +16,12 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, +] @dataclass diff --git a/homeassistant/components/vistapool/light.py b/homeassistant/components/vistapool/light.py new file mode 100644 index 0000000000000..66e44a1227f41 --- /dev/null +++ b/homeassistant/components/vistapool/light.py @@ -0,0 +1,73 @@ +"""Vistapool Light entities.""" + +from typing import Any + +from aioaquarite import AquariteError + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + +_VALUE_PATH = "light.status" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Vistapool light for every pool on the account.""" + async_add_entities( + VistapoolLight(coordinator) + for coordinator in entry.runtime_data.coordinators.values() + ) + + +class VistapoolLight(VistapoolEntity, LightEntity): + """Representation of a Vistapool pool light.""" + + _attr_translation_key = "pool_light" + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the light entity.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("pool_light") + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + value = self.coordinator.get_value(_VALUE_PATH) + if value is None: + return None + return value in (True, "1") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + await self._async_set_value(1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._async_set_value(0) + + async def _async_set_value(self, value: int) -> None: + """Send a value update via the Vistapool cloud API.""" + try: + await self.coordinator.api.set_value( + self.coordinator.pool_id, _VALUE_PATH, value + ) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 557c9c420626c..3dc5b716b4264 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -44,6 +44,11 @@ "name": "LED next color" } }, + "light": { + "pool_light": { + "name": "[%key:component::light::title%]" + } + }, "number": { "electrolysis_setpoint": { "name": "Electrolysis setpoint" diff --git a/tests/components/vistapool/snapshots/test_light.ambr b/tests/components/vistapool/snapshots/test_light.ambr new file mode 100644 index 0000000000000..c3d8ac624c47c --- /dev/null +++ b/tests/components/vistapool/snapshots/test_light.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_all_entities[light.my_pool_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.my_pool_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pool_light', + 'unique_id': 'ABCDEF1234567890-pool_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.my_pool_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'My Pool Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.my_pool_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vistapool/test_light.py b/tests/components/vistapool/test_light.py new file mode 100644 index 0000000000000..5429ac14e1e3d --- /dev/null +++ b/tests/components/vistapool/test_light.py @@ -0,0 +1,138 @@ +"""Tests for the Vistapool light platform.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioaquarite import AquariteError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def _only_light_platform() -> Generator[None]: + """Restrict integration setup to the light platform for these tests.""" + with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.LIGHT]): + yield + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test light entities for the default fixture.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_light_string_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the light coerces a numeric-as-string status to bool.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "light": {"status": "1"}, + } + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("light.my_pool_light").state == STATE_ON + + +@pytest.mark.parametrize( + ("service", "expected_value"), + [ + pytest.param(SERVICE_TURN_ON, 1, id="turn_on"), + pytest.param(SERVICE_TURN_OFF, 0, id="turn_off"), + ], +) +async def test_light_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], + service: str, + expected_value: int, +) -> None: + """Test turn_on / turn_off write light.status via set_value.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.my_pool_light"}, + blocking=True, + ) + + mock_vistapool_client.set_value.assert_awaited_once_with( + "ABCDEF1234567890", "light.status", expected_value + ) + + +async def test_light_set_value_raises_on_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test action raises HomeAssistantError when the library fails.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_vistapool_client.set_value.side_effect = AquariteError("boom") + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.my_pool_light"}, + blocking=True, + ) + assert excinfo.value.translation_key == "set_failed" + + +async def test_light_default_fixture_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test the light reports off in the default fixture (light.status=0).""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("light.my_pool_light").state == STATE_OFF From e22b03f9420b1617b4535aa40c0ef06c97b73fa9 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 4 Jun 2026 19:46:31 +0200 Subject: [PATCH 146/153] Add support for openSensor and drutexSmart (#169910) --- .../components/blebox/binary_sensor.py | 4 + homeassistant/components/blebox/const.py | 7 ++ homeassistant/components/blebox/sensor.py | 61 ++++++++++----- homeassistant/components/blebox/strings.json | 13 ++++ tests/components/blebox/test_binary_sensor.py | 59 ++++++++++++++- tests/components/blebox/test_sensor.py | 75 ++++++++++++++++++- 6 files changed, 197 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index 3fd1df93638f2..6087546ef6b1d 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -21,6 +21,10 @@ key="moisture", device_class=BinarySensorDeviceClass.MOISTURE, ), + BinarySensorEntityDescription( + key="open", + device_class=BinarySensorDeviceClass.WINDOW, + ), ) diff --git a/homeassistant/components/blebox/const.py b/homeassistant/components/blebox/const.py index b25e880c48a49..ef264becbffb7 100644 --- a/homeassistant/components/blebox/const.py +++ b/homeassistant/components/blebox/const.py @@ -14,6 +14,13 @@ DEFAULT_HOST = "192.168.0.2" DEFAULT_PORT = 80 +OPEN_STATUS: dict[int, str] = { + 0: "open", + 1: "unclosed_or_unlocked", + 2: "ajar", + 3: "closed_but_unlocked", + 4: "closed", +} LIGHT_MAX_KELVINS = 6500 # 154 Mireds LIGHT_MIN_KELVINS = 2700 # 370 Mireds diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index a528f006b6c5a..bf8486518ccc8 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -1,5 +1,7 @@ """BleBox sensor entities.""" +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime import blebox_uniapi.sensor @@ -26,96 +28,113 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from . import BleBoxConfigEntry +from .const import OPEN_STATUS from .coordinator import BleBoxCoordinator from .entity import BleBoxEntity PARALLEL_UPDATES = 0 -SENSOR_TYPES = ( - SensorEntityDescription( +@dataclass(kw_only=True, frozen=True) +class BleBoxSensorEntityDescription(SensorEntityDescription): + """Describes a BleBox sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda v: v + + +SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = ( + BleBoxSensorEntityDescription( key="pm1", device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="pm2_5", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="pm10", device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="powerConsumption", translation_key="power_consumption", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="wind", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="forwardActiveEnergy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="reverseActiveEnergy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="reactivePower", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="activePower", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="apparentPower", device_class=SensorDeviceClass.APPARENT_POWER, native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="voltage", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, ), - SensorEntityDescription( + BleBoxSensorEntityDescription( key="frequency", device_class=SensorDeviceClass.FREQUENCY, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + BleBoxSensorEntityDescription( + key="openStatus", + translation_key="open_status", + device_class=SensorDeviceClass.ENUM, + icon="mdi:window-open", + options=list(OPEN_STATUS.values()), + value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None, + ), ) @@ -138,20 +157,22 @@ async def async_setup_entry( class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity): """Representation of a BleBox sensor feature.""" + entity_description: BleBoxSensorEntityDescription + def __init__( self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.sensor.BaseSensor, - description: SensorEntityDescription, + description: BleBoxSensorEntityDescription, ) -> None: """Initialize a BleBox sensor feature.""" super().__init__(coordinator, feature) self.entity_description = description @property - def native_value(self): + def native_value(self) -> StateType: """Return the state.""" - return self._feature.native_value + return self.entity_description.value_fn(self._feature.native_value) @property def last_reset(self) -> datetime | None: diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index cd13ec0ed1a7b..cbe6be653fe47 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -35,6 +35,19 @@ } } }, + "entity": { + "sensor": { + "open_status": { + "state": { + "ajar": "Ajar", + "closed": "[%key:common::state::closed%]", + "closed_but_unlocked": "Closed but unlocked", + "open": "[%key:common::state::open%]", + "unclosed_or_unlocked": "Unclosed or unlocked" + } + } + } + }, "exceptions": { "bad_value": { "message": "Turning on the light failed: {error}" diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index 79da8b41ee409..814c06cab0512 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -29,6 +29,22 @@ def airsensor_fixture() -> tuple[AsyncMock, str]: return feature, "binary_sensor.my_rain_sensor_windrainsensor_0_rain" +@pytest.fixture(name="open_sensor") +def open_sensor_fixture() -> tuple[AsyncMock, str]: + """Return a default open/window binary sensor fixture.""" + feature: AsyncMock = mock_feature( + "binary_sensors", + blebox_uniapi.binary_sensor.Open, + unique_id="BleBox-openSensor-1afe34db9437-0.open", + full_name="openSensor-0.open", + device_class="open", + ) + product = feature.product + type(product).name = PropertyMock(return_value="My open sensor") + type(product).model = PropertyMock(return_value="openSensor") + return feature, "binary_sensor.my_open_sensor_opensensor_0_open" + + async def test_init( rainsensor: AsyncMock, device_registry: dr.DeviceRegistry, hass: HomeAssistant ) -> None: @@ -46,3 +62,44 @@ async def test_init( device = device_registry.async_get(entry.device_id) assert device.name == "My rain sensor" + + +async def test_open_sensor_init( + open_sensor: tuple[AsyncMock, str], + device_registry: dr.DeviceRegistry, + hass: HomeAssistant, +) -> None: + """Test open/window binary sensor initialisation.""" + _, entity_id = open_sensor + entry = await async_setup_entity(hass, entity_id) + assert entry.unique_id == "BleBox-openSensor-1afe34db9437-0.open" + + state = hass.states.get(entity_id) + assert state.name == "My open sensor openSensor-0.open" + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW + + device = device_registry.async_get(entry.device_id) + assert device.name == "My open sensor" + assert device.model == "openSensor" + + +@pytest.mark.parametrize( + ("is_open", "expected_state"), + [ + pytest.param(True, STATE_ON, id="open"), + pytest.param(False, STATE_OFF, id="closed"), + ], +) +async def test_open_sensor_state( + open_sensor: tuple[AsyncMock, str], + hass: HomeAssistant, + is_open: bool, + expected_state: str, +) -> None: + """Test open/window binary sensor reports open and closed states correctly.""" + feature_mock, entity_id = open_sensor + feature_mock.state = is_open + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.state == expected_state diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index ddac2db094ea1..0bce8658e6fea 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -6,7 +6,8 @@ import blebox_uniapi import pytest -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.blebox.const import OPEN_STATUS +from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -158,3 +159,75 @@ async def test_airsensor_update(airsensor, hass: HomeAssistant) -> None: ) assert state.state == "49" + + +@pytest.fixture(name="open_status_sensor") +def open_status_sensor_fixture(): + """Return a default openStatus sensor mock.""" + feature = mock_feature( + "sensors", + blebox_uniapi.sensor.GenericSensor, + unique_id="BleBox-openSensor-1afe34db9437-0.openStatus", + full_name="openSensor-0.openStatus", + device_class="openStatus", + native_value=None, + ) + product = feature.product + type(product).name = PropertyMock(return_value="My open sensor") + type(product).model = PropertyMock(return_value="openSensor") + return (feature, "sensor.my_open_sensor_opensensor_0_openstatus") + + +async def test_open_status_sensor_init(open_status_sensor, hass: HomeAssistant) -> None: + """Test openStatus sensor initial state is unknown.""" + _, entity_id = open_status_sensor + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == list(OPEN_STATUS.values()) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("raw_value", "expected_state"), + [ + pytest.param(0, "open", id="0_open"), + pytest.param(1, "unclosed_or_unlocked", id="1_unclosed_or_unlocked"), + pytest.param(2, "ajar", id="2_ajar"), + pytest.param(3, "closed_but_unlocked", id="3_closed_but_unlocked"), + pytest.param(4, "closed", id="4_closed"), + ], +) +async def test_open_status_sensor_value_mapping( + open_status_sensor, + hass: HomeAssistant, + raw_value: int, + expected_state: str, +) -> None: + """Test that each raw numeric openStatus value maps to the correct string state.""" + feature_mock, entity_id = open_status_sensor + + feature_mock.native_value = raw_value + + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.state == expected_state + assert state.state in OPEN_STATUS.values() + + +async def test_open_status_sensor_none_value( + open_status_sensor, hass: HomeAssistant +) -> None: + """Test that a None native_value yields an unknown state.""" + feature_mock, entity_id = open_status_sensor + + def set_none(): + feature_mock.native_value = None + + feature_mock.async_update = AsyncMock(side_effect=set_none) + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN From 3173e56bf0f54cd8e44e768ec848290b5905f058 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:54:44 +0200 Subject: [PATCH 147/153] Fix Vistapool button test isolation by deepcopying _LED_DATA. (#172829) Co-authored-by: Claude From e2f3a3232e16def3c4783707e9cc4ee906590bd3 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:05:37 +0200 Subject: [PATCH 148/153] Vistapool: add diagnostics support (#172824) Co-authored-by: Claude --- .../components/vistapool/diagnostics.py | 36 +++++ .../components/vistapool/quality_scale.yaml | 2 +- .../vistapool/snapshots/test_diagnostics.ambr | 148 ++++++++++++++++++ .../components/vistapool/test_diagnostics.py | 36 +++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vistapool/diagnostics.py create mode 100644 tests/components/vistapool/snapshots/test_diagnostics.ambr create mode 100644 tests/components/vistapool/test_diagnostics.py diff --git a/homeassistant/components/vistapool/diagnostics.py b/homeassistant/components/vistapool/diagnostics.py new file mode 100644 index 0000000000000..244022dfa7383 --- /dev/null +++ b/homeassistant/components/vistapool/diagnostics.py @@ -0,0 +1,36 @@ +"""Diagnostics support for Vistapool.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import VistapoolConfigEntry + +TO_REDACT = { + CONF_PASSWORD, + CONF_USERNAME, + "city", + "lat", + "lng", + "street", + "title", + "unique_id", + "wifi", + "zipcode", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: VistapoolConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a Vistapool config entry.""" + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "pools": [ + async_redact_data(coordinator.data, TO_REDACT) + for coordinator in entry.runtime_data.coordinators.values() + ], + } diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index d69380658ecb2..b3f3badeaa571 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -40,7 +40,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: status: exempt diff --git a/tests/components/vistapool/snapshots/test_diagnostics.ambr b/tests/components/vistapool/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..c8b1d42cd6651 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_diagnostics.ambr @@ -0,0 +1,148 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'vistapool', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'pools': list([ + dict({ + 'backwash': dict({ + 'status': 0, + }), + 'filtration': dict({ + 'intel': dict({ + 'temp': 24, + 'time': '600', + }), + 'interval1': dict({ + 'from': 28800, + 'to': 36000, + }), + 'interval2': dict({ + 'from': 46800, + 'to': 50400, + }), + 'interval3': dict({ + 'from': 68400, + 'to': 70200, + }), + 'manVel': 2, + 'mode': 1, + 'status': 1, + 'timerVel1': 1, + 'timerVel2': 1, + 'timerVel3': 0, + }), + 'form': dict({ + 'city': '**REDACTED**', + 'country': 'BE', + 'lat': '**REDACTED**', + 'lng': '**REDACTED**', + 'street': '**REDACTED**', + 'zipcode': '**REDACTED**', + }), + 'hidro': dict({ + 'cloration_enabled': 0, + 'cover': 0, + 'cover_enabled': 0, + 'current': 50, + 'fl1': 0, + 'fl2': 0, + 'is_electrolysis': True, + 'level': 100, + 'low': 0, + 'maxAllowedValue': 220, + }), + 'light': dict({ + 'status': 0, + }), + 'main': dict({ + 'RSSI': -65, + 'hasCD': 0, + 'hasCL': 0, + 'hasHidro': 1, + 'hasIO': 0, + 'hasLED': 0, + 'hasPH': 1, + 'hasRX': 1, + 'hasUV': 0, + 'localTime': 1775995380, + 'temperature': 25.5, + 'version': 825, + }), + 'modules': dict({ + 'ph': dict({ + 'al3': 0, + 'current': '742', + 'pump_high_on': 0, + 'pump_low_on': 0, + 'status': dict({ + 'high_value': '751', + 'low_value': '650', + }), + 'tank': 0, + }), + 'rx': dict({ + 'current': 707, + 'pump_status': 0, + 'status': dict({ + 'value': 700, + }), + 'tank': 0, + }), + }), + 'present': True, + 'relays': dict({ + 'filtration': dict({ + 'heating': dict({ + 'status': 0, + }), + }), + 'relay1': dict({ + 'info': dict({ + 'onoff': 0, + 'status': 0, + }), + }), + 'relay2': dict({ + 'info': dict({ + 'onoff': 0, + 'status': 0, + }), + }), + 'relay3': dict({ + 'info': dict({ + 'onoff': 0, + 'status': 0, + }), + }), + 'relay4': dict({ + 'info': dict({ + 'onoff': 0, + 'status': 0, + }), + }), + }), + 'wifi': '**REDACTED**', + }), + ]), + }) +# --- diff --git a/tests/components/vistapool/test_diagnostics.py b/tests/components/vistapool/test_diagnostics.py new file mode 100644 index 0000000000000..d95c00b514b29 --- /dev/null +++ b/tests/components/vistapool/test_diagnostics.py @@ -0,0 +1,36 @@ +"""Tests for the Vistapool diagnostics.""" + +from typing import Any +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test config entry diagnostics.""" + mock_pool_data["wifi"] = "gateway-serial-id" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) From 227c43630a45e0f818504a8f07f5c03b80f2592d Mon Sep 17 00:00:00 2001 From: jdoughty04 Date: Thu, 4 Jun 2026 14:09:16 -0400 Subject: [PATCH 149/153] Add media player missing image coverage (#172641) --- tests/components/media_player/test_init.py | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 360fe2e162bd6..869cfcf7ef463 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -143,6 +143,37 @@ async def test_get_image_http_remote( assert content == b"image" +async def test_get_image_http_missing_image( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test advertised local image with missing bytes returns not found.""" + with ( + patch( + "homeassistant.components.demo.media_player.DemoYoutubePlayer.media_image_url", + None, + ), + patch( + "homeassistant.components.demo.media_player.DemoYoutubePlayer.media_image_hash", + "missing-image", + ), + ): + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("media_player.bedroom") + client = await hass_client_no_auth() + + with patch( + "homeassistant.components.media_player.MediaPlayerEntity.async_get_media_image", + return_value=(None, None), + ): + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.NOT_FOUND + + async def test_get_image_http_log_credentials_redacted( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, From 8aac0c5b6e69ae1843b102871228988c86fdaf15 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 Jun 2026 20:17:50 +0200 Subject: [PATCH 150/153] Convert LinkPlay configuration_url to string for device registry (#173034) --- homeassistant/components/linkplay/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 0bfb34af42c07..f2e244dfa5586 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -51,7 +51,7 @@ def __init__(self, bridge: LinkPlayBridge) -> None: ) self._attr_device_info = dr.DeviceInfo( - configuration_url=bridge.endpoint, + configuration_url=str(bridge.endpoint), connections=connections, hw_version=bridge.device.properties["hardware"], identifiers={(DOMAIN, bridge.device.uuid)}, From f9fea56a8c4365b65552ca9b3018fb6ec591bc38 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 Jun 2026 21:12:24 +0200 Subject: [PATCH 151/153] Add tests of legacy device tracker states to person tests (#173023) --- tests/components/person/test_init.py | 122 +++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 679b74476e65d..bbf56af628cb1 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -421,6 +421,26 @@ async def test_setup_router_ble_trackers( ATTR_IN_ZONES: ["zone.work"], }, ) +# Legacy trackers come from integrations that predate `in_zones`, so they report +# only a state and a source type (no `in_zones`). +_LEGACY_HOME: tuple[str, dict[str, Any]] = ( + "home", + {ATTR_SOURCE_TYPE: SourceType.ROUTER}, +) +_LEGACY_NOT_HOME: tuple[str, dict[str, Any]] = ( + "not_home", + {ATTR_SOURCE_TYPE: SourceType.ROUTER}, +) +# A legacy GPS tracker reports coordinates but no `in_zones`. +_LEGACY_GPS: tuple[str, dict[str, Any]] = ( + "not_home", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_LATITUDE: 5.0, + ATTR_LONGITUDE: 6.0, + ATTR_GPS_ACCURACY: 8, + }, +) async def _async_setup_person_two_trackers(hass: HomeAssistant, user_id: str) -> None: @@ -456,6 +476,18 @@ async def _async_setup_person_two_trackers(hass: HomeAssistant, user_id: str) -> }, id="home_beats_gps", ), + # A legacy "home" tracker (no in_zones) likewise outranks GPS. + pytest.param( + _LEGACY_HOME, + _GPS_NOT_HOME, + "home", + { + ATTR_LATITUDE: 32.87336, + ATTR_LONGITUDE: -117.22743, + ATTR_SOURCE: DEVICE_TRACKER, + }, + id="legacy_home_beats_gps", + ), # A non-GPS "home" tracker outranks a scanner in another zone. pytest.param( _ROUTER_HOME, @@ -584,6 +616,19 @@ async def test_state_priority_overrides_recency( }, id="home_newer", ), + # A pair of legacy "home" trackers (no in_zones) likewise picks the + # most recent. + pytest.param( + _LEGACY_HOME, + _LEGACY_HOME, + "home", + { + ATTR_LATITUDE: 32.87336, + ATTR_LONGITUDE: -117.22743, + ATTR_SOURCE: DEVICE_TRACKER_2, + }, + id="legacy_home_newer", + ), ], ) async def test_most_recent_state_in_bucket_wins( @@ -662,6 +707,83 @@ async def test_scanner_associated_with_other_zone( } +@pytest.mark.parametrize( + ("tracker", "expected_state", "expected_extra"), + [ + # A legacy "home" tracker has no coordinates of its own, so they come + # from the home zone. It reports no `in_zones`. + pytest.param( + _LEGACY_HOME, + "home", + { + ATTR_LATITUDE: 32.87336, + ATTR_LONGITUDE: -117.22743, + ATTR_SOURCE: DEVICE_TRACKER, + }, + id="home", + ), + # A legacy "not_home" tracker contributes no coordinates and no zones. + pytest.param( + _LEGACY_NOT_HOME, + "not_home", + {ATTR_SOURCE: DEVICE_TRACKER}, + id="not_home", + ), + # A legacy GPS tracker contributes its own coordinates but no zones. + pytest.param( + _LEGACY_GPS, + "not_home", + { + ATTR_GPS_ACCURACY: 8, + ATTR_LATITUDE: 5.0, + ATTR_LONGITUDE: 6.0, + ATTR_SOURCE: DEVICE_TRACKER, + }, + id="gps", + ), + ], +) +async def test_legacy_device_tracker( + hass: HomeAssistant, + hass_admin_user: MockUser, + tracker: tuple[str, dict[str, Any]], + expected_state: str, + expected_extra: dict[str, Any], +) -> None: + """Test a legacy tracker that reports a state but no in_zones.""" + hass.set_state(CoreState.not_running) + user_id = hass_admin_user.id + config = { + DOMAIN: { + "id": "1234", + "name": "tracked person", + "user_id": user_id, + "device_trackers": DEVICE_TRACKER, + } + } + assert await async_setup_component(hass, DOMAIN, config) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + hass.states.async_set(DEVICE_TRACKER, tracker[0], tracker[1]) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == expected_state + assert ( + state.attributes + == { + ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], + ATTR_EDITABLE: False, + ATTR_FRIENDLY_NAME: "tracked person", + ATTR_ID: "1234", + ATTR_IN_ZONES: [], + ATTR_USER_ID: user_id, + } + | expected_extra + ) + + async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: From 711830b01fc00d8535bba0c0c31c86c853952f85 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 Jun 2026 21:19:19 +0200 Subject: [PATCH 152/153] Add tracking_type capability attribute to device tracker (#173027) --- .../components/device_tracker/__init__.py | 2 ++ .../components/device_tracker/const.py | 13 +++++++ .../components/device_tracker/entity.py | 8 +++++ .../components/device_tracker/strings.json | 7 ++++ .../snapshots/test_device_tracker.ambr | 5 ++- .../components/device_tracker/test_entity.py | 27 ++++++++++++-- tests/components/device_tracker/test_init.py | 9 ++++- .../snapshots/test_device_tracker.ambr | 1 + .../fing/snapshots/test_device_tracker.ambr | 15 ++++++-- .../snapshots/test_device_tracker.ambr | 5 ++- .../snapshots/test_device_tracker.ambr | 5 ++- .../ituran/snapshots/test_device_tracker.ambr | 5 ++- .../snapshots/test_device_tracker.ambr | 2 ++ .../lojack/snapshots/test_device_tracker.ambr | 5 ++- .../mobile_app/test_device_tracker.py | 14 ++++++++ tests/components/mqtt/test_diagnostics.py | 1 + .../snapshots/test_device_tracker.ambr | 5 ++- .../snapshots/test_device_tracker.ambr | 5 ++- .../snapshots/test_device_tracker.ambr | 35 +++++++++++++++---- .../snapshots/test_device_tracker.ambr | 1 + .../snapshots/test_device_tracker.ambr | 10 ++++-- .../snapshots/test_device_tracker.ambr | 12 +++++-- .../tessie/snapshots/test_device_tracker.ambr | 10 ++++-- .../tile/snapshots/test_device_tracker.ambr | 5 ++- .../snapshots/test_device_tracker.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_device_tracker.ambr | 5 ++- .../unifi/snapshots/test_device_tracker.ambr | 15 ++++++-- .../victron_gx/test_device_tracker.py | 5 ++- .../snapshots/test_device_tracker.ambr | 10 ++++-- .../volvo/snapshots/test_device_tracker.ambr | 20 ++++++++--- 31 files changed, 228 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index bd6b3dde3cfb5..8f1489256a6a2 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -22,6 +22,7 @@ ATTR_LOCATION_NAME, ATTR_MAC, ATTR_SOURCE_TYPE, + ATTR_TRACKING_TYPE, CONF_ASSOCIATED_ZONE, CONF_CONSIDER_HOME, CONF_NEW_DEVICE_DEFAULTS, @@ -36,6 +37,7 @@ PLATFORM_TYPE_LEGACY, SCAN_INTERVAL, SourceType, + TrackingType, ) from .entity import ( # noqa: F401 BaseScannerEntity, diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index f2fb138b5b978..2f9e496e2a4f8 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -25,6 +25,18 @@ class SourceType(StrEnum): BLUETOOTH_LE = "bluetooth_le" +class TrackingType(StrEnum): + """Tracking type for device trackers. + + Describes how the tracker determines presence: by the device's geographic + position (e.g. GPS) or by its connection to a known endpoint (e.g. a router + or beacon associated with a zone). + """ + + CONNECTION = "connection" + POSITION = "position" + + CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) @@ -47,6 +59,7 @@ class SourceType(StrEnum): ATTR_LOCATION_NAME: Final = "location_name" ATTR_MAC: Final = "mac" ATTR_SOURCE_TYPE: Final = "source_type" +ATTR_TRACKING_TYPE: Final = "tracking_type" ATTR_CONSIDER_HOME: Final = "consider_home" ATTR_IP: Final = "ip" diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index 497e9ab1f53dc..7a669775b0f84 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -48,11 +48,13 @@ ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, + ATTR_TRACKING_TYPE, CONF_ASSOCIATED_ZONE, CONNECTED_DEVICE_REGISTERED, DOMAIN, LOGGER, SourceType, + TrackingType, ) _LOGGER = logging.getLogger(__name__) @@ -238,6 +240,9 @@ class TrackerEntity( """Base class for a tracked device.""" entity_description: TrackerEntityDescription + _attr_capability_attributes: dict[str, Any] = { + ATTR_TRACKING_TYPE: TrackingType.POSITION + } _attr_in_zones: list[str] | None = None _attr_latitude: float | None = None _attr_location_accuracy: float = 0 @@ -411,6 +416,9 @@ class BaseScannerEntity(BaseTrackerEntity): addresses being used to identify the device. """ + _attr_capability_attributes: dict[str, Any] = { + ATTR_TRACKING_TYPE: TrackingType.CONNECTION + } _scanner_option_associated_zone: str = zone.ENTITY_ID_HOME _scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 55dad4e74523c..6724c563dfd7e 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -40,6 +40,13 @@ "gps": "GPS", "router": "Router" } + }, + "tracking_type": { + "name": "Tracking type", + "state": { + "connection": "Connection", + "position": "Position" + } } } } diff --git a/tests/components/autoskope/snapshots/test_device_tracker.ambr b/tests/components/autoskope/snapshots/test_device_tracker.ambr index 5d600717c6d4f..db10dcd2cb5b8 100644 --- a/tests/components/autoskope/snapshots/test_device_tracker.ambr +++ b/tests/components/autoskope/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -47,6 +49,7 @@ 'latitude': 50.1109221, 'longitude': 8.6821267, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_vehicle', diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index 2cd8b3533221f..230398378a0e2 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -11,6 +11,7 @@ ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, + ATTR_TRACKING_TYPE, CONF_ASSOCIATED_ZONE, CONNECTED_DEVICE_REGISTERED, DOMAIN, @@ -19,6 +20,7 @@ ScannerEntity, SourceType, TrackerEntity, + TrackingType, ) from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow @@ -479,6 +481,19 @@ async def test_load_unload_entry_tracker( assert not state +def test_tracking_type_capability_attribute() -> None: + """Test the tracking_type capability attribute set by each base class.""" + assert MockTrackerEntity().capability_attributes == { + ATTR_TRACKING_TYPE: TrackingType.POSITION + } + assert MockBaseScannerEntity().capability_attributes == { + ATTR_TRACKING_TYPE: TrackingType.CONNECTION + } + assert MockScannerEntity().capability_attributes == { + ATTR_TRACKING_TYPE: TrackingType.CONNECTION + } + + @pytest.mark.parametrize( ( "battery_level", @@ -772,7 +787,9 @@ async def test_tracker_entity_state( state = hass.states.get(entity_id) assert state assert state.state == expected_state - assert state.attributes == expected_attributes + assert state.attributes == expected_attributes | { + ATTR_TRACKING_TYPE: TrackingType.POSITION + } async def test_base_scanner_entity_state( @@ -789,6 +806,7 @@ async def test_base_scanner_entity_state( assert entity_state assert entity_state.attributes == { ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: [], } assert entity_state.state == STATE_NOT_HOME @@ -803,6 +821,7 @@ async def test_base_scanner_entity_state( # entity_id is reported. assert entity_state.attributes == { ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: ["zone.home"], } @@ -815,6 +834,7 @@ async def test_base_scanner_entity_state( # is_connected is None -> empty in_zones (always reported). assert entity_state.attributes == { ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: [], } @@ -908,6 +928,7 @@ async def test_base_scanner_entity_in_zones_when_connected( assert entity_state.state == STATE_HOME assert entity_state.attributes == { ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: expected_in_zones, } @@ -1265,6 +1286,7 @@ async def test_scanner_entity_state( assert entity_state assert entity_state.attributes == { ATTR_SOURCE_TYPE: SourceType.ROUTER, + ATTR_TRACKING_TYPE: TrackingType.CONNECTION, ATTR_IN_ZONES: [], ATTR_IP: ip_address, ATTR_MAC: mac_address, @@ -1722,4 +1744,5 @@ def longitude(self) -> float | None: state = hass.states.get(entity_id) assert state assert state.state == "unavailable" - assert state.attributes == {} + # Capability attributes are reported even when the entity is unavailable. + assert state.attributes == {ATTR_TRACKING_TYPE: TrackingType.POSITION} diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 89812c49c0b64..482f1bb9e60e9 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import ( SourceType, TrackerEntity, + TrackingType, const, legacy, ) @@ -801,7 +802,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: state = hass.states.get(entity1.entity_id) assert state assert state.state == STATE_UNKNOWN - assert state.attributes == {"in_zones": [], "source_type": SourceType.ROUTER} + assert state.attributes == { + "in_zones": [], + "source_type": SourceType.ROUTER, + "tracking_type": TrackingType.POSITION, + } state = hass.states.get(entity2.entity_id) assert state @@ -809,6 +814,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: assert state.attributes == { "in_zones": [], "source_type": SourceType.GPS, + "tracking_type": TrackingType.POSITION, "latitude": 10.0, "longitude": 5.0, "gps_accuracy": 1, @@ -820,6 +826,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: assert state.attributes == { "in_zones": [], "source_type": SourceType.ROUTER, + "tracking_type": TrackingType.POSITION, } diff --git a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr index a44c2f0fb2b3e..8cca165439231 100644 --- a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr +++ b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr @@ -9,6 +9,7 @@ ]), 'mac': '00:00:5E:00:53:01', 'source_type': , + 'tracking_type': , 'wifi': 'Main', }), 'context': , diff --git a/tests/components/fing/snapshots/test_device_tracker.ambr b/tests/components/fing/snapshots/test_device_tracker.ambr index 47df83f163cc0..7b6b618aea0ef 100644 --- a/tests/components/fing/snapshots/test_device_tracker.ambr +++ b/tests/components/fing/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -47,6 +49,7 @@ 'ip': '192.168.50.1', 'mac': '00:00:00:00:00:01', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.freebsd_router', @@ -62,7 +65,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -104,6 +109,7 @@ 'ip': '192.168.50.3', 'mac': '00:00:00:00:00:03', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.pc_home', @@ -119,7 +125,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -161,6 +169,7 @@ 'ip': '192.168.50.2', 'mac': '00:00:00:00:00:02', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.samsung_the_frame_55', diff --git a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr index dfd98e35b77d0..cc0d68530cb82 100644 --- a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr +++ b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -47,6 +49,7 @@ 'latitude': 52.520008, 'longitude': 13.404954, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.fluffy', diff --git a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr index 9e74d2f1b942d..06494a284cab8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': 35.5402913, 'longitude': -82.5527055, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.garden_test_mower_1', diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr index 8912c20546857..baa6048916ce1 100644 --- a/tests/components/ituran/snapshots/test_device_tracker.ambr +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': 25.0, 'longitude': -71.0, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.mock_model', diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index 0704be9aa3cb4..2df2e6b0037db 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -27,6 +27,7 @@ 'zone.home', ]), 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.demo_scanner', @@ -45,6 +46,7 @@ 'latitude': 32.87336, 'longitude': -117.22743, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.demo_tracker', diff --git a/tests/components/lojack/snapshots/test_device_tracker.ambr b/tests/components/lojack/snapshots/test_device_tracker.ambr index dbb2bab555f97..46adb788fb13a 100644 --- a/tests/components/lojack/snapshots/test_device_tracker.ambr +++ b/tests/components/lojack/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': 37.7749, 'longitude': -122.4194, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.2021_honda_accord', diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py index 01a9961fa0bbc..b8a73e95941d2 100644 --- a/tests/components/mobile_app/test_device_tracker.py +++ b/tests/components/mobile_app/test_device_tracker.py @@ -225,6 +225,7 @@ async def test_sending_location( "altitude": 50.0, "course": 60, "speed": 70, + "tracking_type": "position", "vertical_accuracy": 80, } | expected_attributes @@ -262,6 +263,7 @@ async def test_sending_location( "altitude": 5.0, "course": 6, "speed": 7, + "tracking_type": "position", "vertical_accuracy": 8, "in_zones": [], } @@ -347,6 +349,7 @@ async def test_restoring_location( "longitude": 20.0, "gps_accuracy": 30, "in_zones": ["zone.home"], + "tracking_type": "position", }, { "data": { @@ -373,6 +376,7 @@ async def test_restoring_location( "speed": 70, "vertical_accuracy": 80, "in_zones": [], + "tracking_type": "position", }, { "data": { @@ -399,6 +403,7 @@ async def test_restoring_location( "speed": 70, "vertical_accuracy": 80, "in_zones": ["zone.office"], + "tracking_type": "position", }, { "data": { @@ -483,6 +488,7 @@ async def test_saving_state( "speed": 70, "vertical_accuracy": 80, "in_zones": ["zone.home"], + "tracking_type": "position", }, ), # Coordinates outside any zone @@ -501,6 +507,7 @@ async def test_saving_state( "gps_accuracy": 3, "battery_level": 4, "in_zones": [], + "tracking_type": "position", }, ), # Last update was a named location only (no coords) @@ -523,6 +530,7 @@ async def test_saving_state( "speed": 70, "vertical_accuracy": 80, "in_zones": [], + "tracking_type": "position", }, ), # Last update was an in_zones list (no coords) @@ -545,6 +553,7 @@ async def test_saving_state( "speed": 70, "vertical_accuracy": 80, "in_zones": ["zone.office"], + "tracking_type": "position", }, ), # Empty in_zones list - not_home @@ -559,6 +568,7 @@ async def test_saving_state( "source_type": "gps", "battery_level": 40, "in_zones": [], + "tracking_type": "position", }, ), ], @@ -620,6 +630,7 @@ async def test_restoring_state( "speed": 70, "vertical_accuracy": 80, "in_zones": ["zone.home"], + "tracking_type": "position", }, ), # Coordinates outside any zone @@ -641,6 +652,7 @@ async def test_restoring_state( "gps_accuracy": 3, "battery_level": 4, "in_zones": [], + "tracking_type": "position", }, ), # Last update was a named location only (no coords). The location name @@ -666,6 +678,7 @@ async def test_restoring_state( "speed": 70, "vertical_accuracy": 80, "in_zones": [], + "tracking_type": "position", }, ), ], @@ -762,4 +775,5 @@ async def test_restoring_state_invalid_extra_data( "friendly_name": "Test 1", "source_type": "gps", "in_zones": [], + "tracking_type": "position", } diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index d39816752ad10..f2372db87323b 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -240,6 +240,7 @@ async def test_redact_diagnostics( "latitude": "**REDACTED**", "longitude": "**REDACTED**", "source_type": "gps", + "tracking_type": "position", }, "entity_id": "device_tracker.mqtt_unique", "last_changed": ANY, diff --git a/tests/components/nrgkick/snapshots/test_device_tracker.ambr b/tests/components/nrgkick/snapshots/test_device_tracker.ambr index 36f19af9448ce..1845fc93cc913 100644 --- a/tests/components/nrgkick/snapshots/test_device_tracker.ambr +++ b/tests/components/nrgkick/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': 47.0748, 'longitude': 15.4376, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.nrgkick_test_gps_tracker', diff --git a/tests/components/paj_gps/snapshots/test_device_tracker.ambr b/tests/components/paj_gps/snapshots/test_device_tracker.ambr index b110e65c321fa..3280c53133804 100644 --- a/tests/components/paj_gps/snapshots/test_device_tracker.ambr +++ b/tests/components/paj_gps/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -47,6 +49,7 @@ 'latitude': 52.0, 'longitude': 13.0, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.device_1', diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 1ee9a115af25a..4579ba7eb8407 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -43,6 +45,7 @@ 'in_zones': list([ ]), 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.reg_zoe_50_location', @@ -58,7 +61,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -93,6 +98,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'REG-ZOE-50 Location', + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.reg_zoe_50_location', @@ -108,7 +114,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -149,6 +157,7 @@ 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.reg_captur_fuel_location', @@ -164,7 +173,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -205,6 +216,7 @@ 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.reg_captur_phev_location', @@ -220,7 +232,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -261,6 +275,7 @@ 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.reg_meg_0_location', @@ -276,7 +291,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -317,6 +334,7 @@ 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.reg_twingo_iii_location', @@ -332,7 +350,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -373,6 +393,7 @@ 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.reg_zoe_50_location', diff --git a/tests/components/template/snapshots/test_device_tracker.ambr b/tests/components/template/snapshots/test_device_tracker.ambr index 66214b061d65e..790579bb0b944 100644 --- a/tests/components/template/snapshots/test_device_tracker.ambr +++ b/tests/components/template/snapshots/test_device_tracker.ambr @@ -9,6 +9,7 @@ 'latitude': 10.0, 'longitude': 40.0, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.template_device_tracker', diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index c7729ce7b1987..4e402d8e26318 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': -30.222626, 'longitude': -97.6236871, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_location', @@ -61,7 +64,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -102,6 +107,7 @@ 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_route', diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 926b5a5022d95..1214016c9c6ed 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': -30.222626, 'longitude': -97.6236871, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_location', @@ -61,7 +64,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -102,6 +107,7 @@ 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_route', @@ -121,6 +127,7 @@ 'latitude': -30.222626, 'longitude': -97.6236871, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_location', @@ -140,6 +147,7 @@ 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_route', diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index 1bab66b19b87e..a564cdf850ed4 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -48,6 +50,7 @@ 'longitude': -97.6236871, 'source_type': , 'speed': None, + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_location', @@ -63,7 +66,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -104,6 +109,7 @@ 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.test_route', diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index f5dc0cab21a34..17c9cd1de1d73 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -51,6 +53,7 @@ 'longitude': 1, 'ring_state': 'STOPPED', 'source_type': , + 'tracking_type': , 'voip_state': 'OFFLINE', }), 'context': , diff --git a/tests/components/tplink_omada/snapshots/test_device_tracker.ambr b/tests/components/tplink_omada/snapshots/test_device_tracker.ambr index 7869bce93286d..9afdc88bcfc95 100644 --- a/tests/components/tplink_omada/snapshots/test_device_tracker.ambr +++ b/tests/components/tplink_omada/snapshots/test_device_tracker.ambr @@ -10,6 +10,7 @@ 'ip': '192.168.1.102', 'mac': '2C-71-FF-ED-34-83', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.banana', @@ -27,6 +28,7 @@ ]), 'mac': '2C-71-FF-ED-34-83', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.banana', diff --git a/tests/components/traccar_server/snapshots/test_diagnostics.ambr b/tests/components/traccar_server/snapshots/test_diagnostics.ambr index 7d97448c5d1ef..83d719d37dcf9 100644 --- a/tests/components/traccar_server/snapshots/test_diagnostics.ambr +++ b/tests/components/traccar_server/snapshots/test_diagnostics.ambr @@ -112,6 +112,7 @@ 'source_type': 'gps', 'traccar_id': 0, 'tracker': 'traccar_server', + 'tracking_type': 'position', }), 'state': 'not_home', }), @@ -452,6 +453,7 @@ 'source_type': 'gps', 'traccar_id': 0, 'tracker': 'traccar_server', + 'tracking_type': 'position', }), 'state': 'not_home', }), diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr index 102df40cbed7e..d687731c59df6 100644 --- a/tests/components/tractive/snapshots/test_device_tracker.ambr +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': 22.333, 'longitude': 44.555, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.tracker_device_id_123', diff --git a/tests/components/unifi/snapshots/test_device_tracker.ambr b/tests/components/unifi/snapshots/test_device_tracker.ambr index 2205b42157c08..dc6d209de3840 100644 --- a/tests/components/unifi/snapshots/test_device_tracker.ambr +++ b/tests/components/unifi/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'ip': '10.0.1.1', 'mac': '00:00:00:00:01:01', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.switch_1', @@ -61,7 +64,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -101,6 +106,7 @@ ]), 'mac': '00:00:00:00:00:02', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.wd_client_1', @@ -116,7 +122,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -157,6 +165,7 @@ 'ip': '10.0.0.1', 'mac': '00:00:00:00:00:01', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.ws_client_1', diff --git a/tests/components/victron_gx/test_device_tracker.py b/tests/components/victron_gx/test_device_tracker.py index 0d51f0d142ea2..fb987d5927e8a 100644 --- a/tests/components/victron_gx/test_device_tracker.py +++ b/tests/components/victron_gx/test_device_tracker.py @@ -3,7 +3,7 @@ from victron_mqtt import Hub as VictronVenusHub from victron_mqtt.testing import finalize_injection, inject_message -from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker import SourceType, TrackingType from homeassistant.components.victron_gx.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -62,6 +62,7 @@ async def test_victron_device_tracker( "gps_accuracy": 0, "friendly_name": "GPS Location", "in_zones": [], + "tracking_type": TrackingType.POSITION, } device = device_registry.async_get_device( @@ -107,6 +108,7 @@ async def test_victron_device_tracker( "gps_accuracy": 0, "friendly_name": "GPS Location", "in_zones": [], + "tracking_type": TrackingType.POSITION, } # Send GPS fix lost to exercise the non-GpsLocation reset branch. @@ -127,4 +129,5 @@ async def test_victron_device_tracker( "speed": None, "friendly_name": "GPS Location", "in_zones": [], + "tracking_type": TrackingType.POSITION, } diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr index 3db2eeb064039..5079e450ab984 100644 --- a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'ip': '192.168.1.11', 'mac': 'yy:yy:yy:yy:yy:yy', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.landevice1', @@ -61,7 +64,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -103,6 +108,7 @@ 'ip': '192.168.1.10', 'mac': 'xx:xx:xx:xx:xx:xx', 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.wifidevice0', diff --git a/tests/components/volvo/snapshots/test_device_tracker.ambr b/tests/components/volvo/snapshots/test_device_tracker.ambr index 63da19b122fdf..363454a433f3a 100644 --- a/tests/components/volvo/snapshots/test_device_tracker.ambr +++ b/tests/components/volvo/snapshots/test_device_tracker.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -46,6 +48,7 @@ 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.volvo_ex30_location', @@ -61,7 +64,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -102,6 +107,7 @@ 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.volvo_s90_location', @@ -117,7 +123,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -158,6 +166,7 @@ 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.volvo_xc40_location', @@ -173,7 +182,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'tracking_type': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -214,6 +225,7 @@ 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , + 'tracking_type': , }), 'context': , 'entity_id': 'device_tracker.volvo_xc90_location', From 07dc2346ded935a903365e0c78ab5d5e5d4bcaca Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:26:18 +0200 Subject: [PATCH 153/153] Bump py-synologydsm-api to 2.9.0 (#173041) --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index cec61912b82a7..d3b9f2be9a448 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.7.3"], + "requirements": ["py-synologydsm-api==2.9.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Basic:1", diff --git a/requirements_all.txt b/requirements_all.txt index 780b956fec995..18b9f40543f69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1942,7 +1942,7 @@ py-schluter==0.1.7 py-sucks==0.9.11 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.3 +py-synologydsm-api==2.9.0 # homeassistant.components.unifi_access py-unifi-access==1.3.0