From fce17c8e6fea48448c66dcdafc67d0bde6540568 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 May 2026 16:07:37 +0000 Subject: [PATCH 001/132] 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 75db16195c71..3b7dd647da7f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: 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 9f4452966ed3..d569543cdfd7 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/132] 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 e50a0f260570..42661e4029b9 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -271,7 +271,7 @@ class MqttValve(MqttEntity, ValveEntity): 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 @@ class MqttValve(MqttEntity, ValveEntity): 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 @@ class MqttValve(MqttEntity, ValveEntity): 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 cbd6fdc15d37..9b19f391cd9f 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/132] 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 33ccb0993a54..f9024a802e25 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -38,11 +38,13 @@ from homeassistant.components.media_player import ( ) 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 @@ class AppleTvMediaPlayer( @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 @@ class AppleTvMediaPlayer( 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 @@ class AppleTvMediaPlayer( 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 c8da75fb1e2d..fad0672e1161 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 2b01fc1bb090..1f72c218e391 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/132] 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 89a1e176a59e..66d3317a095f 100644 --- a/homeassistant/components/zeversolar/coordinator.py +++ b/homeassistant/components/zeversolar/coordinator.py @@ -8,7 +8,7 @@ import zeversolar 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 @@ class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]): 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 83e2a619906b..e3733208cef3 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/132] 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 8c06abaf7ab4..b65bea04e2a6 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -8,7 +8,7 @@ import os 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 @@ from homeassistant.components.http import ( 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 6d20743cc574..533bf4d0a91f 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 f32d8173ddd1..e6f2dd34a4bb 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -8,7 +8,7 @@ from typing import Any 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/132] 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 34327304888f..2cbf3497d1d9 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -5,8 +5,12 @@ from typing import Any 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 @@ CONFIG_SCHEMA = vol.Schema( ) 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_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 752ed02de597..920c36d97b7b 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -299,12 +299,8 @@ class Analytics: 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 @@ class Analytics: 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 000000000000..cf64b30c8ba3 --- /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 e7357bc4a375..b021fcf84906 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 00f8e0d4164e..abff985475ff 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 0bc899ef9877..7653c9c77cb1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -59,6 +59,7 @@ FLOWS = { "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 2459a7320ed2..589ae9a5b514 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -6,15 +6,18 @@ from unittest.mock import patch 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/132] 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 781e86e54fcd..a11c1ad36d26 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 84a442b8f680..faf5a7180186 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 dc8f676ae3ad..441fb6ad1603 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/132] 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 1cd3840a3312..a93652c0e978 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 aa832c37a81e..c4bcc9e18549 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -349,15 +349,12 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): 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 faf5a7180186..ecc79bfc7aca 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 3aff9fe1b9b2..048ed16f2fb0 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/132] 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 2d05508795f0..e9e4fdf66b14 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 ecc79bfc7aca..946e084eb950 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 2b4462290f0e..b02f1c70e6a7 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 4d3001d8454a..857d26c25885 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/132] 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 | 170 ++++++++---------- homeassistant/components/zha/entity.py | 2 +- homeassistant/components/zha/fan.py | 8 +- homeassistant/components/zha/helpers.py | 119 ++++++------ 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, 262 insertions(+), 406 deletions(-) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 0e9ac369ff92..140d0304dbd7 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -80,31 +80,31 @@ class ZHAAlarmControlPanel(ZHAEntity, AlarmControlPanelEntity): """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 2c5291a5c910..619b2de151fb 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -52,7 +52,7 @@ class ZHAButton(ZHAEntity, ButtonEntity): 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 ce3fc93054c5..21be4a82e136 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -203,25 +203,25 @@ class Thermostat(ZHAEntity, ClimateEntity): ) 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 4859fb1aa938..f27fc8583c31 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -75,3 +75,7 @@ MFG_CLUSTER_ID_START = 0xFC00 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 0d9628995845..f6e9aa60a3af 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -122,31 +122,31 @@ class ZhaCover(ZHAEntity, CoverEntity): """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 @@ class ZhaCover(ZHAEntity, CoverEntity): ) 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 @@ class ZhaCover(ZHAEntity, CoverEntity): ) 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 3cdfed053840..3c5327131561 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 @@ ACTION_SCHEMA = vol.Any( 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 @@ SERVICE_NAMES = { 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 - - 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: + except (KeyError, AttributeError) as err: raise InvalidDeviceAutomationConfig( - f"Unable to execute cluster handler action -" - f" cluster handler: {cluster_handler_name} action:" - f" {action_type}" - ) - - 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}" - ) - + f"ZHA device {config[CONF_DEVICE_ID]} not found" + ) from err try: - await getattr(action_cluster_handler, action_type)(**config) - except ZHAException as err: - raise HomeAssistantError(err) from err + 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 -ZHA_ACTION_TYPES = { - ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action, - ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: ( - _execute_cluster_handler_command_based_action - ), +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"], + ) + + +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"], + ) + + +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 a133b8f42a71..a885320a3356 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -206,7 +206,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): 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 61315f91a53e..c37b2d063359 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -92,7 +92,7 @@ class ZhaFan(FanEntity, ZHAEntity): """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 @@ class ZhaFan(FanEntity, ZHAEntity): ) 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 ec7ef8e1fde7..3d4d7f2deecc 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 @@ from zha.application.const import ( 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.application.platforms import GroupEntity, PlatformEntity 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 homeassistant.util.logging import HomeAssistantQueueHandler 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 @@ from .const import ( 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 @@ from .const import ( DEFAULT_DATABASE_NAME, DEVICE_PAIRING_STATUS, DOMAIN, + SIGNAL_DEVICE_RECONFIGURE_EVENT, ZHA_ALARM_OPTIONS, ZHA_OPTIONS, ) @@ -450,50 +444,46 @@ class ZHADeviceProxy(EventBase): ) @callback - def handle_zha_channel_configure_reporting( + 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, + SIGNAL_DEVICE_RECONFIGURE_EVENT, + { + "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_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_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_configure_reporting", + "zha_channel_msg_data": { + "cluster_name": event.cluster_name, + "cluster_id": event.cluster_id, + "attributes": event.attributes, }, }, ) @callback - def handle_zha_channel_cfg_done( - self, event: ClusterHandlerConfigurationComplete - ) -> None: - """Handle a ZHA cluster configure reporting 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_CFG_DONE, - }, - ) - - @callback - def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None: - """Handle a ZHA cluster bind event.""" - 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 @@ class ZHADeviceProxy(EventBase): 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 @@ class ZHADeviceProxy(EventBase): 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 @@ class ZHAGatewayProxy(EventBase): 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 @@ class ZHAGatewayProxy(EventBase): ) 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.""" + 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" - @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 + if str(exc): + message = f"{message}: {exc}" - return handler + 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 4d1c0cd9c71c..9c9c1aea6d0e 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -171,7 +171,7 @@ class Light(LightEntity, ZHAEntity): """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 @@ class Light(LightEntity, ZHAEntity): ) 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 dc27ec7a6fa7..53c959a78514 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -94,19 +94,19 @@ class ZhaDoorLock(ZHAEntity, LockEntity): """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 @@ class ZhaDoorLock(ZHAEntity, LockEntity): ) 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 83282af03028..bba334981a1d 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 eba532010d5b..b6bca9a3ccc9 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -78,7 +78,7 @@ class ZhaNumber(ZHAEntity, RestoreNumber): """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 72dbad201372..d0e97155b10b 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -58,7 +58,7 @@ class ZHAEnumSelectEntity(ZHAEntity, SelectEntity): """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 aa083b5e227f..e88a0053a9aa 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 @@ class ZHASiren(ZHAEntity, SirenEntity): """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 @@ class ZHASiren(ZHAEntity, SirenEntity): ) 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 46f4bb309980..c86f82bbdcf1 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -49,13 +49,13 @@ class Switch(ZHAEntity, SwitchEntity): """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 0b63aab62a8e..d5364fccb50f 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -181,7 +181,7 @@ class ZHAFirmwareUpdateEntity( 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 985e4bf01ab9..bbca47f09ded 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -29,12 +29,6 @@ from zha.application.const import ( 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 @@ from zha.application.helpers import ( 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 @@ import zigpy.zdo.types as zdo_types 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 @@ from .const import ( GROUP_IDS, GROUP_NAME, MFG_CLUSTER_ID_START, + SIGNAL_DEVICE_RECONFIGURE_EVENT, ZHA_ALARM_OPTIONS, ZHA_OPTIONS, ) @@ -180,13 +182,13 @@ SERVICE_SCHEMAS: dict[str, VolSchemaType] = { { 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 @@ SERVICE_SCHEMAS: dict[str, VolSchemaType] = { { 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 websocket_reconfigure_node( 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 @@ def async_load_api(hass: HomeAssistant) -> 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 @@ def async_load_api(hass: HomeAssistant) -> 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 @@ def async_load_api(hass: HomeAssistant) -> 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 946e084eb950..0f4d746559a9 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 6ebee4ee365a..2f8c8550d87b 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 6d7739243b4b..eb6162781333 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 d1bb4406ad01..05211ce4ceee 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 75380d516689..8fd03d73c209 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -5,10 +5,7 @@ from datetime import timedelta 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 ba9ca95dea6d..fb59ac08deb4 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -20,8 +20,12 @@ from zha.application.const import ( 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 test_websocket_reconfigure( "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/132] 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 4573fe0fefd8..e52db0bdded6 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio 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 @@ class CoreLocalBackupAgent(LocalBackupAgent): 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 @@ class CoreLocalBackupAgent(LocalBackupAgent): 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 ea5b7be8f0e4..88ec3d44cf84 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1978,7 +1978,13 @@ class CoreBackupReaderWriter(BackupReaderWriter): 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 197a12336099..953b4ab4c102 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -6,7 +6,7 @@ import copy 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.async_iterator import ( 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 bdb42910d758..ffd059b50b82 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 fcb4a8533afd..b2be5c257997 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -14,6 +14,7 @@ import pytest 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/132] 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 39140394cbcb..50455d05682e 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 6450c3a3cec5..2e6b23c7c0fc 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 0f4d746559a9..f3ec8f22164a 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/132] 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 ab150473deaf..1c7842f6cac5 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 f3ec8f22164a..1ca128e78885 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/132] 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 e1c69df7d4aa..3e7d591d58d2 100644 --- a/homeassistant/components/omie/sensor.py +++ b/homeassistant/components/omie/sensor.py @@ -52,6 +52,11 @@ class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity): 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 28a39f6c2d7e..20330d46bfa4 100644 --- a/tests/components/omie/test_sensor.py +++ b/tests/components/omie/test_sensor.py @@ -10,35 +10,35 @@ import pytest 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/132] 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 3bfdbb158599..8088322a5f9e 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 d8eea99f1e97..1617603faec1 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 1ca128e78885..29288f09bdeb 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/132] 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 9ff6689654d8..9456dee1e60e 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 29288f09bdeb..479e0b7a5465 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/132] 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 55c91ce6131c..048ab20171fd 100644 --- a/homeassistant/components/duckdns/coordinator.py +++ b/homeassistant/components/duckdns/coordinator.py @@ -50,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[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/132] 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 de32c14d754d..7cd3fc195558 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/132] 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 cf13af76de4f..9a6a2c6a5cda 100644 --- a/homeassistant/components/yamaha_musiccast/entity.py +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -63,7 +63,7 @@ class MusicCastDeviceEntity(MusicCastEntity): }, 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/132] 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 34234cc4704b..2ccf8f622834 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -94,7 +94,7 @@ async def async_setup_entry( 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 d56fb93d4e69..1fe044123c2a 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -29,12 +29,13 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: 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 61b6ac2637db..289f3780a9dc 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 3c9707b34c6c..b19e4df002d7 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/132] 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 9f845b032047..d5b5003029a5 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 e792afab2c29..95029f0c0a35 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -39,6 +39,7 @@ DEVICE_FIXTURES = [ "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 000000000000..b12f1adb2f39 --- /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 000000000000..bd2fb8d00aa1 --- /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 1303388717dc..1f4c540c9685 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 90d326d1a1a9..b827e2183c0f 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 3e8f614b213b..0ef795e66763 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 d5015d17fff2..ca67688ef67a 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/132] 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 d181601e172f..8a9a437e966c 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -52,11 +52,7 @@ _LOGGER = logging.getLogger(__name__) 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 = entry + self.entry: JellyfinConfigEntry + self.client: JellyfinClient + self.api: Any + self.url: str - self.client = client - self.api = client.jellyfin - self.url = jellyfin_url(client, "") + 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 = 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 @@ class JellyfinSource(MediaSource): 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/132] 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 72519b575618..6ed6834345a4 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 1a1c99adbde5..ed394d349b84 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/132] 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 041c05579b5e..5b687eff196e 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -178,17 +178,21 @@ class HueLight(HueBaseEntity, LightEntity): @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 937177dc4317..dac308cb9788 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/132] 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 ec0c39972ba3..c785f385bd71 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 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity): ) 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/132] 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 219c696a63b1..c68fe7e9e055 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -86,7 +86,6 @@ class DucoCoordinator(DataUpdateCoordinator[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 @@ class DucoCoordinator(DataUpdateCoordinator[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 08cbb79bf528..17b07c25739f 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 33cd8535acb2..b216900b3933 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/132] 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 99bf345bbf97..9e6a93d7080c 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 f8a05b77bab6..e6c7c2b3059b 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/132] 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 65fc54da8bd5..bc28cf93a7a1 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 e5368a6c9bcb..c25fac49108e 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/132] 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 7fb9ed5658b6..222a33816f83 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 2e6b23c7c0fc..2b78353a61c9 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 67fcca937f8f..090bb31ed415 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 @@ To update, run python3 -m script.hassfest 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 479e0b7a5465..89fad0c9ee35 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/132] 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 85686ca23204..8c7f55131e87 100644 --- a/homeassistant/components/volvo/lock.py +++ b/homeassistant/components/volvo/lock.py @@ -75,6 +75,10 @@ class VolvoLock(VolvoEntity, LockEntity): 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 dfabc82d6547..e63bd2f3d213 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 @@ from homeassistant.components.lock import ( 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 homeassistant.helpers import entity_registry as er 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/132] 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 04f54582f125..915de70ffc28 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 c291e7a77a0c..6bfb43e86ade 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/132] 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 60d9abcb35cc..d1a3a01ded8c 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 89fad0c9ee35..42dc3f8fafe3 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/132] 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 c0218ea31c51..c160b253fb93 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -34,7 +34,11 @@ from requests import RequestException 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 @@ from .const import ( 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 76a3ca7cdddb..7b91caa7e5b5 100644 --- a/tests/components/growatt_server/test_init.py +++ b/tests/components/growatt_server/test_init.py @@ -20,6 +20,7 @@ from homeassistant.components.growatt_server.const import ( 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/132] 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 567a7a6f34fe..2f91d76aba9a 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 @@ async def async_get_calendars( 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 cb4082967e71..39c3350d4b97 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -8,6 +8,7 @@ from typing import Any 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/132] 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 7a86028482cc..2e9993867de1 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5457,7 +5457,6 @@ async def async_get_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_get_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_get_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/132] 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 cd1fb307eb1e..eeb90e7ca56e 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 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): _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 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): 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 6e7cb7dd928d..5367dcc43773 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/132] 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 66d56b73db92..681fe41c9f13 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -138,6 +138,8 @@ SAVE_DELAY = 1 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 @@ class ConfigEntry[_DataT = Any]: 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 0edc0335b7f4..51dafd768391 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/132] 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 9a735f550fc1..1eebe201c821 100644 --- a/homeassistant/components/alexa_devices/button.py +++ b/homeassistant/components/alexa_devices/button.py @@ -39,11 +39,8 @@ async def async_setup_entry( 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 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): 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/132] 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 499b3b475a45..60c98d1e76f2 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 d907061dd995..4bfb43db8193 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/132] 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 577a045ffe03..7330973b6935 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 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { 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/132] 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 19de54ddd203..0ebda4b0e2a2 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_TO_PRESET_MODE_MAP = { 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 6eb82df0b9e7..4b99faf790f9 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -4,6 +4,8 @@ import asyncio 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 @@ from homeassistant.components.climate import ( 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/132] 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 aaa2c8963736..3f4559f2188e 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -29,7 +29,11 @@ class IRobotEntity(Entity): 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/132] 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 546edea009ce..c2edf1ed8f63 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -468,7 +468,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): 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/132] 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 1592895936da..72f8966ad718 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DISCOVERY, CONF_PLATFORM, + CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD, ) @@ -50,6 +51,7 @@ from .client import ( 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 @@ from .const import ( 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 cc60d489b906..338bebd897c4 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -9,6 +9,7 @@ from functools import lru_cache, partial from itertools import chain, groupby import logging from operator import attrgetter +import queue import socket import ssl import time @@ -92,6 +93,8 @@ from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabl _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 @@ class MqttClientSetup: 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 2e9993867de1..6bfadb63155a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -8,7 +8,6 @@ from dataclasses import dataclass 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 @@ from cryptography.hazmat.primitives.serialization import ( 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.json import JSON_DECODE_EXCEPTIONS, json_loads 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 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5 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_get_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 38ac997d073e..75dfac16cf66 100644 --- a/homeassistant/components/mqtt/repairs.py +++ b/homeassistant/components/mqtt/repairs.py @@ -5,12 +5,10 @@ from typing import TYPE_CHECKING 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 @@ class MQTTDeviceEntryMigration(RepairsFlow): ) -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 7cd3fc195558..730a039163a1 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 efe5d0f1a4e3..b0d2c42803bd 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator, Generator 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 a99963372bc2..046e3bb15581 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 @@ async def test_handle_message_callback( 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 @@ async def test_handle_message_callback( 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 ac99acc5c82b..eec430050455 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -130,6 +130,7 @@ MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n" 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 a9e19494e319..9335af092a3e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -5,6 +5,7 @@ from copy import deepcopy 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.components.sensor import SensorDeviceClass 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 @@ from tests.common import ( 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 b7da34c44501..bc7b9dd42945 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.conftest import ClientSessionGenerator 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/132] 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 ee02e7390ebc..cf697b993cec 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -20,6 +20,8 @@ from denonavr.const import ( from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, + AvrIncompleteResponseError, + AvrInvalidResponseError, AvrNetworkError, AvrProcessingError, AvrTimoutError, @@ -191,6 +193,17 @@ def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R]( 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 24b12c085074..662334a96968 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 @@ from homeassistant.components.denonavr.services import ( 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/132] 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 eda6937c5dd2..ed0ac62b44d3 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 002f32e772cc..009cce247af4 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/132] 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 07e68ba40abf..af64e36b1993 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -196,4 +196,6 @@ class EphEmberThermostat(ClimateEntity): @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/132] 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 5b871b985b74..3fb405d04bac 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -854,6 +854,9 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity): 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/132] 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 7e3161ebde56..479c29108a3a 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 d95b1846f3ec..9232530ef0df 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -4,7 +4,7 @@ from typing import Any 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/132] 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 1ae2a9425f9c..b5dded48c20d 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -102,14 +102,12 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity): """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 74e5368f1283..97e4606fb046 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 809262e93907..3aeca0734aea 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 49cfc9a9eb88..c941a2a45f0f 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -60,7 +60,6 @@ class ToonSwitch(ToonEntity, SwitchEntity): 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 @@ class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity): 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 @@ class ToonProgramSwitch(ToonSwitch, ToonDisplayDeviceEntity): 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 @@ class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): 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/132] 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 dd68b8cd6a81..c523872fa403 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -112,3 +112,8 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity): 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 dc0f409ac7d6..c486230139a4 100644 --- a/tests/components/wled/test_update.py +++ b/tests/components/wled/test_update.py @@ -8,6 +8,10 @@ import pytest 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.const import ( ) 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/132] 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 33a129a1aa46..92079afce589 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 2471db9df64e..797fab8fd14c 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/132] 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 194b14512e7d..0c8f90ac3d7a 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/132] 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 4622d561456d..816ba0b621ad 100644 --- a/homeassistant/components/yoto/application_credentials.py +++ b/homeassistant/components/yoto/application_credentials.py @@ -6,8 +6,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( 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 1cd258e15ae8..19e5e1040893 100644 --- a/homeassistant/components/yoto/config_flow.py +++ b/homeassistant/components/yoto/config_flow.py @@ -8,7 +8,7 @@ from yoto_api import YotoError, get_account_id 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 @@ class YotoOAuth2FlowHandler( """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/132] 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 fb39425e1ae1..876b22f06901 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -80,5 +80,12 @@ class MeteoAlertBinarySensor(BinarySensorEntity): 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/132] 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 109a9488f6bb..1be4bf6364a5 100644 --- a/homeassistant/components/indevolt/binary_sensor.py +++ b/homeassistant/components/indevolt/binary_sensor.py @@ -46,6 +46,8 @@ BINARY_SENSORS: Final = ( 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 eb6379191938..1331d7b0356f 100644 --- a/homeassistant/components/indevolt/sensor.py +++ b/homeassistant/components/indevolt/sensor.py @@ -939,12 +939,24 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity): @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 6dcb05a0612a..676d4ed1d221 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 c122ef7f1da7..e18c4c3425e1 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 1e7a2cc4c9f3..6f143b00a579 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 247dc34899bb..32597a0ecbb1 100644 --- a/tests/components/indevolt/test_sensor.py +++ b/tests/components/indevolt/test_sensor.py @@ -4,7 +4,12 @@ from datetime import timedelta 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/132] 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 35e43f593ccd..f5cf79519706 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -284,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): 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 0af9cc7d71e2..1bdb4b4ac637 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -5,6 +5,8 @@ from typing import Any 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/132] 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 50455d05682e..9cada8b15704 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 2b78353a61c9..a208df50cda3 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 42dc3f8fafe3..10e9399003e0 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/132] 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 61a7fa6de26f..1f8caa91eeda 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -27,6 +27,7 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, + BluetoothReachabilityIntent, BluetoothScannerDevice, BluetoothScanningMode, HaBluetoothConnector, @@ -55,6 +56,7 @@ from . import passive_update_processor, websocket_api 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 @@ __all__ = [ "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 5440bdf5a706..dc5d5fff44c5 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, cast 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 ab188f2799d4..dffdcf943d78 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -10,9 +10,11 @@ from homeassistant.components.bluetooth import ( 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 @@ async def test_async_scanner_devices_by_address_connectable( 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/132] 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 45f3cbdcf940..9a2d8f4a47bc 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -6,6 +6,7 @@ from typing import Any 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 5ddd6d561d77..076be9c30dbd 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/132] 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 ede2f718b4db..049b7ddfc4fc 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 f1b240f3ce6e..edd4e1e10a53 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 10e9399003e0..028fa837d86f 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/132] 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 81ac70f0b798..aab6df65c5ba 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -4,7 +4,7 @@ from logging import getLogger 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 @@ class ImmichMediaSource(MediaSource): ], ) - 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 @@ class ImmichMediaSource(MediaSource): 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 @@ class ImmichMediaSource(MediaSource): 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 @@ class ImmichMediaSource(MediaSource): 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 @@ class ImmichMediaSource(MediaSource): 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 @@ class ImmichMediaSource(MediaSource): 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 @@ class ImmichMediaSource(MediaSource): 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 4c465ea3d2bc..3a1e61f89759 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 37450d5a0227..faad3c5ee8e4 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -5,7 +5,7 @@ import tempfile 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/132] 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 5a098f2b93b6..13b13be3d62f 100644 --- a/homeassistant/components/avea/config_flow.py +++ b/homeassistant/components/avea/config_flow.py @@ -8,6 +8,7 @@ import avea 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 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN): 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 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN): 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 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN): { 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 3cdfeb26b325..ca1ed8885240 100644 --- a/tests/components/avea/test_config_flow.py +++ b/tests/components/avea/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch 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 homeassistant.data_entry_flow import FlowResultType 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/132] 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 d647e3bd84e2..7915e336b522 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -530,7 +530,7 @@ class MqttAttributesMixin(Entity): 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/132] 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 cc4745e51cc4..b189cfe31458 100644 --- a/homeassistant/components/schlage/entity.py +++ b/homeassistant/components/schlage/entity.py @@ -42,4 +42,8 @@ class SchlageEntity(CoordinatorEntity[SchlageDataUpdateCoordinator]): @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 6695191dcf08..334d3e62edaf 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 71ba04f23189..9756141005d6 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -15,8 +15,14 @@ from homeassistant.components.schlage.const import ( 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/132] 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 bba334981a1d..6353c4d8abba 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 028fa837d86f..7dd7646ff16a 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/132] 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 5915804aa138..005d85e9e1c9 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -938,3 +938,15 @@ class AvmWrapper(FritzBoxTools): "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 43ed80f6396d..0165e37de0c3 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 4415f563ffd9..3e6d9741ca9c 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -97,6 +97,14 @@ MOCK_FB_SERVICES: dict[str, dict[str, Any]] = { }, "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 f67aaf40bcd9..af5fe8460dc1 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/132] 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 f0cb0c392360..a01310dcb6f7 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 7dd7646ff16a..cae87bd7a5ce 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/132] 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 81d9f09fd682..b04be74029e6 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 4f4f9adb90ad..2e6ede4931de 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 a57181521dca..dff813529d26 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/132] 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 40a7776e83b0..f8d6066fcbdd 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 3ca7c5a50e49..e7fa8f32db0c 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -353,11 +353,10 @@ class EcovacsVacuum( 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/132] 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 fd1fe831b687..eac2b3c06fd5 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 cae87bd7a5ce..0cb78d1d4207 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/132] 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 4c522d1feb1c..552dbb17567c 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -36,6 +36,11 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): 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 6d5131d07e9c..b895b9b0edeb 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -89,11 +89,7 @@ class TedeeLockEntity(TedeeEntity, LockEntity): @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/132] 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 b3952b101e91..ff30db228be8 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 e2127560366f..a15cec0b466c 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 + assert len(task.attachments) == 1 - # 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 - - # 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/132] 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 1f20b0a37b65..5951b197c92a 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 42d6a973e5b7..b4e01f6ee80e 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 b81e59514e40..81e4e9694824 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/132] 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 69863d7e1170..a21b6a59a3a1 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1295,7 +1295,7 @@ class MediaPlayerImageView(HomeAssistantView): 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 501d822836c8..7a58aa86d901 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 8d31d7efe907..360fe2e162bd 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 72b8e2513c09..49c2fca3c39a 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 06d9a5880a60..36794a6ec440 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/132] 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 106f5235ff04..1f27690aa838 100644 --- a/homeassistant/components/lg_tv_rs232/config_flow.py +++ b/homeassistant/components/lg_tv_rs232/config_flow.py @@ -70,7 +70,7 @@ class LGTVRS232ConfigFlow(ConfigFlow, domain=DOMAIN): 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 dcf2275f3886..8e7fa1420d40 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/132] 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 1c7a7db18445..f01d2a2c723e 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 2170fa763585..33ce08242d98 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/132] 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 a7dc4506c44c..857b81470cda 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -175,10 +175,10 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): 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 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): 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/132] 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 47029baa103d..75a964401212 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/132] 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 48e80959d48e..082a16c787bc 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -29,7 +29,8 @@ class TractiveEntity(Entity): 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 bf5e75a2bec7..b0ff950e7e4c 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/132] 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 1c7842f6cac5..2c39adf76e6b 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 0cb78d1d4207..199f560378f0 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/132] 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 02f3b40e2e64..c1ab18b3c885 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 2c5224b0f3ef..5aa472f39ff6 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -80,9 +80,10 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity): @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 199f560378f0..ff1390613471 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/132] 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 3b7dd647da7f..0f024cf0a488 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: 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 d569543cdfd7..8c8c05238339 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/132] 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 80a693c50135..4a67f5758172 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -204,7 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): 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 e222ab02820c..fe94b98b1184 100644 --- a/homeassistant/components/alexa_devices/media_player.py +++ b/homeassistant/components/alexa_devices/media_player.py @@ -156,9 +156,11 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): @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 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): 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 0ec611a8d62c..fcb8ab13b9c5 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 73854f0de76d..427d38e3a4b2 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 8d1399514e73..c470eeafa473 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/132] 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 4afa4cf8dff4..4bb19944224d 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -23,14 +23,18 @@ from homeassistant.helpers.selector import ( 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 efaa0e9d9723..8a216e0d3cf1 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_MAX_ENTRIES: Final[int] = 20 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 7b2d4a890377..31af12d144b1 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -18,7 +18,13 @@ from homeassistant.helpers.storage import Store 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 @@ class FeedReaderCoordinator( 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 bbd0f82bcfa0..a394265a3331 100644 --- a/tests/components/feedreader/const.py +++ b/tests/components/feedreader/const.py @@ -4,9 +4,10 @@ from homeassistant.components.feedreader.const import ( 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 c9fc89179dbc..6dfcf863395e 100644 --- a/tests/components/feedreader/test_config_flow.py +++ b/tests/components/feedreader/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant 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 0a336141e69d..f7d64eafbe88 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -19,6 +19,7 @@ from homeassistant.util import dt as dt_util 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/132] 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 bb33aa393883..55a4880995b8 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -320,7 +320,7 @@ class AFSAPIDevice(MediaPlayerEntity): @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/132] 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 32283056fdc4..907819e123fe 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 @@ PLATFORMS: list[Platform] = [ 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 3e79161b3d36..7039d426704a 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 6deaffa7403b..c0e12f4d4c65 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 ff1390613471..16d58b29249a 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 9b13aabf7715..65ec6b2c05f6 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 56d0b18d1c37..e33ca3ca4091 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 e18c4c3425e1..78b8ae266666 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 6f143b00a579..8797eeeedeaa 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 3f495e6d99ae..3a0daa247edf 100644 --- a/tests/components/indevolt/test_init.py +++ b/tests/components/indevolt/test_init.py @@ -4,13 +4,14 @@ from unittest.mock import AsyncMock 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/132] 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 1be4bf6364a5..2e372d35a032 100644 --- a/homeassistant/components/indevolt/binary_sensor.py +++ b/homeassistant/components/indevolt/binary_sensor.py @@ -25,8 +25,8 @@ PARALLEL_UPDATES = 0 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 @@ BINARY_SENSORS: Final = ( 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 @@ BINARY_SENSORS: Final = ( 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 e33ca3ca4091..323307c038d3 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 8797eeeedeaa..e9204025047f 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/132] 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 dcecf26c7da3..409db9418098 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -8,6 +8,7 @@ from bleak.backends.device import BLEDevice 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 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]): 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 6e1143fb9790..2e876f0c1e69 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 50351f2290dc..7cf03940190d 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 @@ from homeassistant.components.airthings_ble.const import ( 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/132] 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 222a33816f83..dd098b7d63c5 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 a208df50cda3..4ba8714da4e0 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 090bb31ed415..222de95d497c 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 @@ To update, run python3 -m script.hassfest 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 16d58b29249a..a09726e63090 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/132] 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 26231a8d58ec..539db81dc054 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -12,6 +12,7 @@ from yalexs_ble import ( ) 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 @@ from .const import ( 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 @@ async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> # 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 c4e4210a0b64..595130107599 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 000000000000..1ad6f260c2bf --- /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/132] 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 f9d6face867e..5640af75a7e5 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -7,9 +7,11 @@ from typing import Any 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 @@ class INKBIRDActiveBluetoothProcessorCoordinator( 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 46cc7ae374c7..e2f1afb0be87 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 9509dccabffe..13b125abf9eb 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -14,6 +14,7 @@ from inkbird_ble import ( 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/132] 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 14a41952b44d..a6f8bf56efd4 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -5,12 +5,13 @@ import asyncio 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 7d777781ab11..0cc44653ce2f 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 000000000000..f5b1bdf3d5d7 --- /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/132] 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 9cada8b15704..aa7dc564f145 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 4ba8714da4e0..91720420417e 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 a09726e63090..123ce1c607af 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/132] 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 957d17a55d4a..9472ea7008a2 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -8,13 +8,14 @@ from eq3btsmart import Thermostat 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 c98a47b2d5c1..d7ed397dcd59 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 000000000000..e8cb060959d2 --- /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/132] 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 9e6a93d7080c..ed68476e6931 100644 --- a/homeassistant/components/matter/ble_proxy.py +++ b/homeassistant/components/matter/ble_proxy.py @@ -23,6 +23,7 @@ from matter_ble_proxy import ( ) from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, BluetoothScanningMode, async_ble_device_from_address, async_register_callback, @@ -51,11 +52,18 @@ class HaBluetoothScanSource(BleScanSource): 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 e6c7c2b3059b..47072dbdb5d8 100644 --- a/tests/components/matter/test_ble_proxy.py +++ b/tests/components/matter/test_ble_proxy.py @@ -22,7 +22,7 @@ from homeassistant.components.matter.ble_proxy import ( 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 @@ async def test_scan_source_callback_forwards_advertisement( 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/132] 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 4eb853ff931c..ff048b519462 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -6,6 +6,7 @@ from bleak import BleakError 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 ef366540db92..edf93d1becea 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 f10ae1fa7430..83c7e6d3162f 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/132] 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 1a9f3cc57e6b..0bc5374c7ece 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -10,11 +10,13 @@ from bleak_retry_connector import ( 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 769cbb6a652c..ad6b07b7581c 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 000000000000..606409d530f1 --- /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/132] 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 1291825efaa3..d485723844f7 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -4,12 +4,16 @@ import logging 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 cd9568564411..70621c6eba04 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 247a1f55d055..197c39929dc9 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/132] 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 32131808af7c..73dee442372a 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 a11c6637e571..3a6abc4c64ed 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/132] 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 45a51c246a3a..696f0aa20a3e 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 630bc3d94ac3..25d9a7e52ab6 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -92,6 +92,7 @@ class IcloudAccount: 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 @@ class IcloudAccount: 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/132] 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 0f024cf0a488..1a48c532af0e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: 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 8c8c05238339..d680d28b9d44 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/132] 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 | 237 ++++++++++++------ 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, 488 insertions(+), 98 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 a85006d17814..8e9c1ddbad8b 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -12,13 +12,19 @@ from homeassistant.const import ( 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 a9829425570a..90d90c50565e 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 346a43e5b260..b511a57c79c6 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 550cec19e413..608e154beaf3 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 @@ from homeassistant.helpers import ( 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 @@ _LOGGER = logging.getLogger(__name__) _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 +_DOMAIN_SPECS: dict[str, DomainSpec] = { + "person": DomainSpec(), + "device_tracker": DomainSpec(), +} -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) +class LegacyZoneTrigger(Trigger): + """Legacy zone trigger (platform: zone).""" - @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"] + @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) - if (from_s and not location.has_location(from_s)) or ( - to_s and not location.has_location(to_s) - ): - return + @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 - 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, + 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 ) - return + to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False - 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]}" - ) - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": platform_type, + 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(hass, entity_id, zone_automation_listener) + 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, +} + + +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 000000000000..8828c8b128ac --- /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 3ea44c2fd035..1c86dde4ad57 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 16cbb7b54184..8d268fe5be6b 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 f1fb9cc2e808..930e1556f611 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/132] 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 90d90c50565e..f582e6b65a49 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 b511a57c79c6..43d0c2986c2e 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 608e154beaf3..13d067b0ff27 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -10,7 +10,9 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_EVENT, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, ) from homeassistant.core import ( @@ -203,10 +205,80 @@ class LeftZoneTrigger(ZoneTriggerBase): 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 8828c8b128ac..81526345b799 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 0fab4ae0716a..c542c0399908 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 @@ async def assert_trigger_options_supported( # 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 8d268fe5be6b..648ef7dad698 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/132] 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 fe94b98b1184..34fdeeed01da 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 @@ STANDARD_SUPPORTED_FEATURES = ( ) -@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 @@ async def async_setup_entry( 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 @@ async def async_setup_entry( 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 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): self, coordinator: AmazonDevicesCoordinator, serial_num: str, - description: AmazonDevicesMediaPlayerEntityDescription, + description: MediaPlayerEntityDescription, ) -> None: """Initialize.""" self._prev_volume: int | None = None @@ -214,7 +200,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): @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 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity): **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/132] 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 1a3ebee74a01..ff56bba933c2 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 @@ from homeassistant.helpers.device_registry import ( 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/132] 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 c9b763e9ab16..e193033647fd 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/132] 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 8e4bdebfb762..531939735030 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 @@ SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()} 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 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): 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 ff56bba933c2..326519f64b86 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/132] 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 2fb9d53ee0d4..2f41732d03dc 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 123ce1c607af..a576bd1f3cd6 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/132] 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 130648f5a279..c2f712ea6e14 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -4,6 +4,7 @@ from typing import Any, Unpack, cast 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.const import ( 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 @@ class ZoneCondition(Condition): 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 000000000000..3853bc9beb75 --- /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 f582e6b65a49..7d082d5f0d1d 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 43d0c2986c2e..133bf0e15ab6 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 391593019b33..4a8d7408f216 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/132] 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 61750efbb4ac..4c1b7c2e71e1 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -218,8 +218,8 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): 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 fd696f67c0ef..716759acdf57 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/132] 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 1c86dde4ad57..730352886cd7 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -330,6 +330,16 @@ BEHAVIOR_FIRST: Final = "first" 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 @@ ENTITY_STATE_TRIGGER_SCHEMA = vol.Schema( 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 930e1556f611..e3d8157f0cfe 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -53,6 +53,7 @@ from homeassistant.helpers.trigger import ( 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/132] 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 26422a19d28e..c62240b07bf4 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 d1a8fd6c90e6..d1b519616c23 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/132] 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 a1c466b9400f..0bcbad5b6275 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -39,6 +39,7 @@ from homeassistant.helpers import ( entity, target as target_helpers, template, + trace, ) from homeassistant.helpers.condition import ( async_from_config as async_condition_from_config, @@ -1061,10 +1062,24 @@ async def handle_subscribe_condition( 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 ba1fe3b1fc12..7bd5c16000f4 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -24,6 +24,11 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S 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 e16bf0882233..8b21a87fb66c 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Coroutine, Generator 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 @@ class TraceElement: 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 @@ class TraceElement: """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 @@ class TraceElement: 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 @@ trace_id_cv: ContextVar[tuple[str, str] | None] = ContextVar( 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 e2f76d330f12..dc96bec45710 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 2e4146213ac4..6fd2c9b11730 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/132] 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 46096f6f2b87..6034ede44498 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1953,6 +1953,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema( [ { 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 e6a9c1bf5ddb..f30b9d2b838c 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/132] 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 5bae9bfb462c..09c1770fcbfd 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/132] 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 2c39adf76e6b..69b63993fc1e 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 a576bd1f3cd6..456774dff978 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 8a5772f6af6b..959f7762a366 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -113,7 +113,7 @@ TEST_DEVICE_2 = AmazonDevice( 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_INITIAL = AmazonVocalRecord( 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/132] 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 5cc4530e97a9..799e4ae9329d 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 456774dff978..4493d5e1f38e 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/132] 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 dd098b7d63c5..5f7b5ac88d0a 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 91720420417e..2756a9f39526 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 4493d5e1f38e..b7bd52a143e5 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/132] 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 222de95d497c..9312e58e8912 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 @@ To update, run python3 -m script.hassfest 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/132] Catch errors when setting up condition in WS subscribe_condition (#172895) --- .../components/websocket_api/commands.py | 18 ++++- .../components/websocket_api/test_commands.py | 65 +++++++++++++++---- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 0bcbad5b6275..5343d696a975 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 dc96bec45710..0b66d62139d2 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2969,17 +2969,6 @@ async def test_subscribe_condition_template_error( ), }, ), - # Validated 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" - ), - }, - ), ], ) async def test_subscribe_condition_error( @@ -3001,6 +2990,60 @@ async def test_subscribe_condition_error( 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']" + ), + }, + ), + # 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_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": "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 + + # 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 ) -> None: From 083af9ccc7dd9ba1d39cc942371031e864b3dfd9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Jun 2026 11:20:13 +0200 Subject: [PATCH 122/132] 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 c2f712ea6e14..14f95166f05b 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -10,7 +10,9 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID, + CONF_FOR, CONF_OPTIONS, + CONF_TARGET, CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -23,6 +25,8 @@ from homeassistant.helpers.automation import ( 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 @@ class NotInZoneCondition(_ZoneTargetConditionBase): 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 3853bc9beb75..2294ecd2c2b4 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 7d082d5f0d1d..5ff8e4944319 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 133bf0e15ab6..912cbff16e76 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 c542c0399908..d3f53c5b1a60 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 @@ async def assert_condition_options_supported( # 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 4a8d7408f216..d2b944b8862b 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -8,6 +8,7 @@ import pytest 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 @@ TARGET_ZONE = ZONE_HOME @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/132] 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 1a48c532af0e..c191d4b1b88b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: 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 d680d28b9d44..ffa70f3e015d 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/132] 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 40629a05a16e..bc353271a27d 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 2756a9f39526..62353de1e32f 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 edd4e1e10a53..0afa12c73f3a 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 b7bd52a143e5..f0109a69a5b4 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/132] 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 5343d696a975..68a52fca9ab6 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 0b66d62139d2..71771e355bdf 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/132] 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 13df78301ae0..5a876eda8e8c 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 @@ PLATFORMS: list[Platform] = [Platform.LIGHT] 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 d52f3c6f6afb..99d731b07d5d 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 7263b5e10177..b2d0399cea9a 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/132] 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 5f7b5ac88d0a..83d3f469ca53 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 62353de1e32f..d6d831709ef9 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 9312e58e8912..6887f2debfac 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 @@ To update, run python3 -m script.hassfest 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 f0109a69a5b4..be0c6bea47f5 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/132] 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 c191d4b1b88b..c6c4f03dc034 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: 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 ffa70f3e015d..4c28abaf9e5c 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/132] 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 abb29c202e52..10c48451acfa 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 be0c6bea47f5..2ec462d56b47 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/132] 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 5fc232f21ea2..b83660221144 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 @@ class OPNsenseDeviceTrackerEntity( 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 a1baa8b89dc2..0f30e2a24c42 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/132] 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 c6c4f03dc034..290ab9ad5549 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: 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 4c28abaf9e5c..578942d0c053 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/132] 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 b25e8ddf0675..488dd58e7fd6 100644 --- a/tests/components/braviatv/conftest.py +++ b/tests/components/braviatv/conftest.py @@ -6,6 +6,23 @@ from unittest.mock import AsyncMock, patch 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."""