From f479b93ac78018126d2e1f46e311e949ddb7553e Mon Sep 17 00:00:00 2001 From: briangunderson Date: Mon, 6 Jan 2025 20:54:36 +0000 Subject: [PATCH 01/16] Add humidity tracking to Venstar component --- homeassistant/components/climate/const.py | 4 ++++ homeassistant/components/venstar/climate.py | 2 ++ homeassistant/components/venstar/const.py | 1 + 3 files changed, 7 insertions(+) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 111401a2251968..01db7414a9b57d 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -25,6 +25,9 @@ class HVACMode(StrEnum): # Device is in Dry/Humidity mode DRY = "dry" + # Device is in Humidify mode + HUMIDIFY = "humidify" + # Only the fan is on, not fan and another mode like cool FAN_ONLY = "fan_only" @@ -91,6 +94,7 @@ class HVACAction(StrEnum): IDLE = "idle" OFF = "off" PREHEATING = "preheating" + HUMIDIFYING = "humidifying" CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction] diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index c5323e1e9a82cf..e8751d48c85c2d 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -39,6 +39,7 @@ from .const import ( _LOGGER, ATTR_FAN_STATE, + ATTR_HUMIDIFIER_STATE, ATTR_HVAC_STATE, CONF_HUMIDIFIER, DEFAULT_SSL, @@ -196,6 +197,7 @@ def extra_state_attributes(self): return { ATTR_FAN_STATE: self._client.fanstate, ATTR_HVAC_STATE: self._client.state, + ATTR_HUMIDIFIER_STATE: self._client.hum_active, } @property diff --git a/homeassistant/components/venstar/const.py b/homeassistant/components/venstar/const.py index a485adad8e79a3..2e12e44dcdfe6e 100644 --- a/homeassistant/components/venstar/const.py +++ b/homeassistant/components/venstar/const.py @@ -6,6 +6,7 @@ ATTR_FAN_STATE = "fan_state" ATTR_HVAC_STATE = "hvac_mode" +ATTR_HUMIDIFIER_STATE = "humidifier_state" CONF_HUMIDIFIER = "humidifier" From b861e4eabeccafc670bfcec939cd4162515c5e53 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 7 Jan 2025 00:08:02 +0100 Subject: [PATCH 02/16] Revert "Remove deprecated supported features warning in ..." (multiple) (#134933) --- homeassistant/components/camera/__init__.py | 26 ++++++++-- homeassistant/components/cover/__init__.py | 4 ++ .../components/media_player/__init__.py | 51 ++++++++++++------- homeassistant/components/vacuum/__init__.py | 17 ++++++- homeassistant/helpers/entity.py | 27 +++++++++- tests/components/camera/test_init.py | 20 ++++++++ tests/components/cover/test_init.py | 19 +++++++ tests/components/media_player/test_init.py | 22 +++++++- tests/components/vacuum/test_init.py | 36 +++++++++++++ tests/helpers/test_entity.py | 26 ++++++++++ 10 files changed, 221 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 725fc84adc341b..4d718433fca2d7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -516,6 +516,19 @@ def supported_features(self) -> CameraEntityFeature: """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> CameraEntityFeature: + """Return the supported features as CameraEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = CameraEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -569,7 +582,7 @@ def frontend_stream_type(self) -> StreamType | None: self._deprecate_attr_frontend_stream_type_logged = True return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None if ( self._webrtc_provider @@ -798,7 +811,9 @@ def async_update_token(self) -> None: async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM + self.__supports_stream = ( + self.supported_features_compat & CameraEntityFeature.STREAM + ) await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -838,7 +853,7 @@ async def _async_get_supported_webrtc_provider[_T]( self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] ) -> _T | None: """Get first provider that supports this camera.""" - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None return await fn(self.hass, self) @@ -896,7 +911,7 @@ def _invalidate_camera_capabilities_cache(self) -> None: def camera_capabilities(self) -> CameraCapabilities: """Return the camera capabilities.""" frontend_stream_types = set() - if CameraEntityFeature.STREAM in self.supported_features: + if CameraEntityFeature.STREAM in self.supported_features_compat: if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) @@ -916,7 +931,8 @@ def async_write_ha_state(self) -> None: """ super().async_write_ha_state() if self.__supports_stream != ( - supports_stream := self.supported_features & CameraEntityFeature.STREAM + supports_stream := self.supported_features_compat + & CameraEntityFeature.STREAM ): self.__supports_stream = supports_stream self._invalidate_camera_capabilities_cache() diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 9ce526712f0363..001bff51991be1 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,6 +300,10 @@ def state_attributes(self) -> dict[str, Any]: def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: + if type(features) is int: # noqa: E721 + new_features = CoverEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features return features supported_features = ( diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index e7bbe1d19bd29f..291b1ec1e2a1ca 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -773,6 +773,19 @@ def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> MediaPlayerEntityFeature: + """Return the supported features as MediaPlayerEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = MediaPlayerEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError @@ -912,85 +925,87 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features + return MediaPlayerEntityFeature.PLAY in self.supported_features_compat @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features + return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features + return MediaPlayerEntityFeature.STOP in self.supported_features_compat @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features + return MediaPlayerEntityFeature.SEEK in self.supported_features_compat @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features + return ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat + ) @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features + return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1019,7 +1034,7 @@ async def async_volume_up(self) -> None: if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1037,7 +1052,7 @@ async def async_volume_down(self) -> None: if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1080,7 +1095,7 @@ def media_image_local(self) -> str | None: def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if ( source_list := self.source_list @@ -1286,7 +1301,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 46e35bb3e1108e..6fe2c3e2a5b3da 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -312,7 +312,7 @@ def battery_icon(self) -> str: @property def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features: + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -330,7 +330,7 @@ def fan_speed_list(self) -> list[str]: def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -369,6 +369,19 @@ def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> VacuumEntityFeature: + """Return the supported features as VacuumEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = VacuumEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 91845cdf5214d3..19076c4edc0003 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping import dataclasses -from enum import Enum, auto +from enum import Enum, IntFlag, auto import functools as ft import logging import math @@ -1639,6 +1639,31 @@ def _suggest_report_issue(self) -> str: self.hass, integration_domain=platform_name, module=type(self).__module__ ) + @callback + def _report_deprecated_supported_features_values( + self, replacement: IntFlag + ) -> None: + """Report deprecated supported features values.""" + if self._deprecated_supported_features_reported is True: + return + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s, please %s" + ), + self.entity_id, + type(self), + repr(replacement), + report_issue, + ) + class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index a3045e27cf1db3..32520fcad23cb9 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -826,6 +826,26 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCamera(camera.Camera): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockCamera() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "MockCamera" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CameraEntityFeature.ON_OFF" in caplog.text + caplog.clear() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text + + @pytest.mark.usefixtures("mock_camera") async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index e43b64b16a79ad..646c44e4ac2879 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,6 +2,8 @@ from enum import Enum +import pytest + from homeassistant.components import cover from homeassistant.components.cover import CoverState from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE @@ -153,3 +155,20 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s def test_all() -> None: """Test module.__all__ is correctly set.""" help_test_all(cover) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCoverEntity(cover.CoverEntity): + _attr_supported_features = 1 + + entity = MockCoverEntity() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "MockCoverEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CoverEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 7c64f846df1651..a45fa5b6668749 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -129,7 +129,7 @@ def test_support_properties(property_suffix: str) -> None: entity3 = MediaPlayerEntity() entity3._attr_supported_features = feature entity4 = MediaPlayerEntity() - entity4._attr_supported_features = all_features & ~feature + entity4._attr_supported_features = all_features - feature assert getattr(entity1, f"support_{property_suffix}") is False assert getattr(entity2, f"support_{property_suffix}") is True @@ -447,3 +447,23 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockMediaPlayerEntity(MediaPlayerEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockMediaPlayerEntity() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "MockMediaPlayerEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "MediaPlayerEntityFeature.PAUSE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index db6cd242f3fa95..8babd9fa2659e2 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -272,6 +272,42 @@ def send_command( assert "test" in strings +async def test_supported_features_compat(hass: HomeAssistant) -> None: + """Test StateVacuumEntity using deprecated feature constants features.""" + + features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + ) + + class _LegacyConstantsStateVacuum(StateVacuumEntity): + _attr_supported_features = int(features) + _attr_fan_speed_list = ["silent", "normal", "pet hair"] + + entity = _LegacyConstantsStateVacuum() + assert isinstance(entity.supported_features, int) + assert entity.supported_features == int(features) + assert entity.supported_features_compat is ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + ) + assert entity.state_attributes == { + "battery_level": None, + "battery_icon": "mdi:battery-unknown", + "fan_speed": None, + } + assert entity.capability_attributes == { + "fan_speed_list": ["silent", "normal", "pet hair"] + } + assert entity._deprecated_supported_features_reported + + async def test_vacuum_not_log_deprecated_state_warning( hass: HomeAssistant, mock_vacuum_entity: MockVacuum, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index dc579ab6e8d76f..2bf441f70fd22b 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -4,6 +4,7 @@ from collections.abc import Iterable import dataclasses from datetime import timedelta +from enum import IntFlag import logging import threading from typing import Any @@ -2485,6 +2486,31 @@ def _attr_attribution(self): return "🤡" +async def test_entity_report_deprecated_supported_features_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reporting deprecated supported feature values only happens once.""" + ent = entity.Entity() + + class MockEntityFeatures(IntFlag): + VALUE1 = 1 + VALUE2 = 2 + + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + in caplog.text + ) + assert "MockEntityFeatures.VALUE2" in caplog.text + + caplog.clear() + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + not in caplog.text + ) + + async def test_remove_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From a1944f95c0cdd4cae501583f9b3ff525080d118a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 7 Jan 2025 00:25:42 +0100 Subject: [PATCH 03/16] Fix spelling of "ID", slightly reword action descriptions (#134778) --- homeassistant/components/abode/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json index b3d57042754585..c6887d78042622 100644 --- a/homeassistant/components/abode/strings.json +++ b/homeassistant/components/abode/strings.json @@ -34,17 +34,17 @@ "services": { "capture_image": { "name": "Capture image", - "description": "Request a new image capture from a camera device.", + "description": "Requests a new image capture from a camera device.", "fields": { "entity_id": { "name": "Entity", - "description": "Entity id of the camera to request an image." + "description": "Entity ID of the camera to request an image from." } } }, "change_setting": { "name": "Change setting", - "description": "Change an Abode system setting.", + "description": "Changes an Abode system setting.", "fields": { "setting": { "name": "Setting", @@ -58,11 +58,11 @@ }, "trigger_automation": { "name": "Trigger automation", - "description": "Trigger an Abode automation.", + "description": "Triggers an Abode automation.", "fields": { "entity_id": { "name": "Entity", - "description": "Entity id of the automation to trigger." + "description": "Entity ID of the automation to trigger." } } } From 77a2c67001c27995ac794c217fe85bedd51bf0af Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 7 Jan 2025 00:48:22 +0100 Subject: [PATCH 04/16] UnifiProtect Refactor light control methods to use new API (#134625) --- .../components/unifiprotect/light.py | 11 +++---- tests/components/unifiprotect/test_light.py | 32 ++++++++----------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 486a8956e0c788..fcdfe5e85b8ae8 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -7,7 +7,7 @@ from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -71,13 +71,10 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - hass_brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) - unifi_brightness = hass_to_unifi_brightness(hass_brightness) - - _LOGGER.debug("Turning on light with brightness %s", unifi_brightness) - await self.device.set_light(True, unifi_brightness) + _LOGGER.debug("Turning on light") + await self.device.api.set_light_is_led_force_on(self.device.id, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" _LOGGER.debug("Turning off light") - await self.device.set_light(False) + await self.device.api.set_light_is_led_force_on(self.device.id, False) diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 724ed108673c35..85de85cd1c1656 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -95,42 +95,38 @@ async def test_light_update( async def test_light_turn_on( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity turn off.""" + """Test light entity turn on.""" + + light._api = ufp.api + light.api.set_light_is_led_force_on = AsyncMock() await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__pydantic_fields__["set_light"] = Mock(final=False, frozen=False) - light.set_light = AsyncMock() - await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, - blocking=True, + "light", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - light.set_light.assert_called_once_with(True, 3) + assert light.api.set_light_is_led_force_on.called + assert light.api.set_light_is_led_force_on.call_args == ((light.id, True),) async def test_light_turn_off( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity turn on.""" + """Test light entity turn off.""" + + light._api = ufp.api + light.api.set_light_is_led_force_on = AsyncMock() await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) entity_id = "light.test_light" - light.__pydantic_fields__["set_light"] = Mock(final=False, frozen=False) - light.set_light = AsyncMock() - await hass.services.async_call( - "light", - "turn_off", - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + "light", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - light.set_light.assert_called_once_with(False) + assert light.api.set_light_is_led_force_on.called + assert light.api.set_light_is_led_force_on.call_args == ((light.id, False),) From a4986fb40e2657a779f3ecea2273e1deee894953 Mon Sep 17 00:00:00 2001 From: Eli Schleifer <1265982+EliSchleifer@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:49:58 -0800 Subject: [PATCH 05/16] add proxy view for unifiprotect to grab snapshot at specific time (#133546) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/__init__.py | 8 +- .../components/unifiprotect/views.py | 81 +++++++ tests/components/unifiprotect/test_views.py | 226 ++++++++++++++++++ 3 files changed, 314 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index ed409a6eea045b..ba255bb7f7c0b8 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -45,7 +45,12 @@ async_create_api_client, async_get_devices, ) -from .views import ThumbnailProxyView, VideoEventProxyView, VideoProxyView +from .views import ( + SnapshotProxyView, + ThumbnailProxyView, + VideoEventProxyView, + VideoProxyView, +) _LOGGER = logging.getLogger(__name__) @@ -173,6 +178,7 @@ async def _async_setup_entry( data_service.async_setup() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) hass.http.register_view(ThumbnailProxyView(hass)) + hass.http.register_view(SnapshotProxyView(hass)) hass.http.register_view(VideoProxyView(hass)) hass.http.register_view(VideoEventProxyView(hass)) diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index 9bf6ed024f5d71..cc2e1c6a5fc629 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -44,6 +44,34 @@ def async_generate_thumbnail_url( return f"{url}?{urlencode(params)}" +@callback +def async_generate_snapshot_url( + nvr_id: str, + camera_id: str, + timestamp: datetime, + width: int | None = None, + height: int | None = None, +) -> str: + """Generate URL for event thumbnail.""" + + url_format = SnapshotProxyView.url + if TYPE_CHECKING: + assert url_format is not None + url = url_format.format( + nvr_id=nvr_id, + camera_id=camera_id, + timestamp=timestamp.replace(microsecond=0).isoformat(), + ) + + params = {} + if width is not None: + params["width"] = str(width) + if height is not None: + params["height"] = str(height) + + return f"{url}?{urlencode(params)}" + + @callback def async_generate_event_video_url(event: Event) -> str: """Generate URL for event video.""" @@ -188,6 +216,59 @@ async def get( return web.Response(body=thumbnail, content_type="image/jpeg") +class SnapshotProxyView(ProtectProxyView): + """View to proxy snapshots at specified time from UniFi Protect.""" + + url = "/api/unifiprotect/snapshot/{nvr_id}/{camera_id}/{timestamp}" + name = "api:unifiprotect_snapshot" + + async def get( + self, request: web.Request, nvr_id: str, camera_id: str, timestamp: str + ) -> web.Response: + """Get snapshot.""" + + data = self._get_data_or_404(nvr_id) + if isinstance(data, web.Response): + return data + + camera = self._async_get_camera(data, camera_id) + if camera is None: + return _404(f"Invalid camera ID: {camera_id}") + if not camera.can_read_media(data.api.bootstrap.auth_user): + return _403(f"User cannot read media from camera: {camera.id}") + + width: int | str | None = request.query.get("width") + height: int | str | None = request.query.get("height") + + if width is not None: + try: + width = int(width) + except ValueError: + return _400("Invalid width param") + if height is not None: + try: + height = int(height) + except ValueError: + return _400("Invalid height param") + + try: + timestamp_dt = datetime.fromisoformat(timestamp) + except ValueError: + return _400("Invalid timestamp") + + try: + snapshot = await camera.get_snapshot( + width=width, height=height, dt=timestamp_dt + ) + except ClientError as err: + return _404(err) + + if snapshot is None: + return _404("snapshot not found") + + return web.Response(body=snapshot, content_type="image/jpeg") + + class VideoProxyView(ProtectProxyView): """View to proxy video clips from UniFi Protect.""" diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index 0f1b779168045a..f787089b83fe53 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -12,6 +12,7 @@ from homeassistant.components.unifiprotect.views import ( async_generate_event_video_url, async_generate_proxy_event_video_url, + async_generate_snapshot_url, async_generate_thumbnail_url, ) from homeassistant.core import HomeAssistant @@ -169,6 +170,231 @@ async def test_thumbnail_invalid_entry_entry_id( assert response.status == 404 +async def test_snapshot_bad_nvr_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad NVR id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(ufp.api.bootstrap.nvr.id, "bad_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_camera_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad camera id.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(camera.id, "bad_id") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_camera_perms( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad camera perms.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + + ufp.api.bootstrap.auth_user.all_permissions = [] + ufp.api.bootstrap.auth_user._perm_cache = {} + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 403 + ufp.api.request.assert_not_called() + + +async def test_snapshot_bad_timestamp( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot URL with bad timestamp params.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + url = url.replace(fixed_now.replace(microsecond=0).isoformat(), "bad_time") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_snapshot_client_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot triggers client error at API.""" + + ufp.api.get_camera_snapshot = AsyncMock(side_effect=ClientError()) + + tomorrow = fixed_now + timedelta(days=1) + + await init_entry(hass, ufp, [camera]) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, tomorrow) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.get_camera_snapshot.assert_called_once() + + +async def test_snapshot_notfound( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot not found.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=None) + + tomorrow = fixed_now + timedelta(days=1) + + await init_entry(hass, ufp, [camera]) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, tomorrow) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + ufp.api.get_camera_snapshot.assert_called_once() + + +@pytest.mark.parametrize(("width", "height"), [("test", None), (None, "test")]) +async def test_snapshot_bad_params( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, + width: Any, + height: Any, +) -> None: + """Test invalid bad query parameters.""" + + ufp.api.request = AsyncMock() + await init_entry(hass, ufp, [camera]) + + url = async_generate_snapshot_url( + ufp.api.bootstrap.nvr.id, camera.id, fixed_now, width=width, height=height + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 400 + ufp.api.request.assert_not_called() + + +async def test_snapshot( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, +) -> None: + """Test snapshot at timestamp in URL.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=b"testtest") + await init_entry(hass, ufp, [camera]) + + # replace microseconds to match behavior of underlying library + fixed_now = fixed_now.replace(microsecond=0) + url = async_generate_snapshot_url(ufp.api.bootstrap.nvr.id, camera.id, fixed_now) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + # verify when height is None that it is called with camera high channel height + height = camera.high_camera_channel.height + + assert response.status == 200 + assert response.content_type == "image/jpeg" + assert await response.content.read() == b"testtest" + ufp.api.get_camera_snapshot.assert_called_once_with( + camera.id, None, height, dt=fixed_now + ) + + +@pytest.mark.parametrize(("width", "height"), [(123, None), (None, 456), (123, 456)]) +async def test_snapshot_with_dimensions( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, + fixed_now: datetime, + width: Any, + height: Any, +) -> None: + """Test snapshot at timestamp in URL with specified width and height.""" + + ufp.api.get_camera_snapshot = AsyncMock(return_value=b"testtest") + await init_entry(hass, ufp, [camera]) + + # Replace microseconds to match behavior of underlying library + fixed_now = fixed_now.replace(microsecond=0) + url = async_generate_snapshot_url( + ufp.api.bootstrap.nvr.id, camera.id, fixed_now, width=width, height=height + ) + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + # Assertions + assert response.status == 200 + assert response.content_type == "image/jpeg" + assert await response.content.read() == b"testtest" + ufp.api.get_camera_snapshot.assert_called_once_with( + camera.id, width, height, dt=fixed_now + ) + + async def test_video_bad_event( hass: HomeAssistant, ufp: MockUFPFixture, From deb4d423b99a7cef0619cd5c1f06842740787b96 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Tue, 7 Jan 2025 09:12:10 +0100 Subject: [PATCH 06/16] Update Shelly integration: Remove double "Error fetching ..." from error messages (#134950) refactor: Remove double "Error fetching" from error messages --- homeassistant/components/shelly/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f20b283cacf9b0..8273c7626eb252 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -371,7 +371,7 @@ async def _async_update_data(self) -> None: try: await self.device.update() except DeviceConnectionError as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed(repr(err)) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -456,7 +456,7 @@ async def _async_update_data(self) -> None: return await self.device.update_shelly() except (DeviceConnectionError, MacAddressMismatchError) as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed(repr(err)) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: From d105805a16746567bbdc0ca7f24452f150018ba8 Mon Sep 17 00:00:00 2001 From: Kelyan Pegeot Selme <75201282+kelyaenn@users.noreply.github.com> Date: Tue, 7 Jan 2025 10:13:40 +0100 Subject: [PATCH 07/16] Bump renault-api to 0.2.9 (#134858) * chore: Bump Renault api version * Update requirements_all.txt * Update requirements_test_all.txt --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index a4817fc84e6385..1a599afe4e4487 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.2.8"] + "requirements": ["renault-api==0.2.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b59530867e067..73ed9a39dc3322 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2584,7 +2584,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.8 +renault-api==0.2.9 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98c3226081909a..e5d6675bce3a05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2084,7 +2084,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.8 +renault-api==0.2.9 # homeassistant.components.renson renson-endura-delta==1.7.2 From bc34b78e00e067e48cd086713d3b1ceabd9c5c5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jan 2025 23:44:22 -1000 Subject: [PATCH 08/16] Bump zeroconf to 0.137.2 (#134942) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 9ad92bb4bc7460..98fa02a716eff7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.136.2"] + "requirements": ["zeroconf==0.137.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8ed4638c50dcdb..a9a74fddc06647 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.136.2 +zeroconf==0.137.2 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 89bd2ab3b50839..3a8db6a981cf54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dependencies = [ "voluptuous-openapi==0.0.5", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.136.2" + "zeroconf==0.137.2" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index f31f9e06e81fdd..e728049fe51fc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.136.2 +zeroconf==0.137.2 diff --git a/requirements_all.txt b/requirements_all.txt index 73ed9a39dc3322..7b452497b6b3df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3112,7 +3112,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.136.2 +zeroconf==0.137.2 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5d6675bce3a05..17135df2a63017 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2498,7 +2498,7 @@ yt-dlp[default]==2024.12.23 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.136.2 +zeroconf==0.137.2 # homeassistant.components.zeversolar zeversolar==0.3.2 From c3b77e3828ce404b39e2044cce59bcb2b48c3ad9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 7 Jan 2025 12:03:44 +0100 Subject: [PATCH 09/16] Change "id" to uppercase for consistency (#134971) --- homeassistant/components/yale_smart_alarm/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index bd3ba0f01863e7..ebcf0b3af63ba6 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -92,7 +92,7 @@ "message": "Could not set lock, check system ready for lock" }, "could_not_trigger_panic": { - "message": "Could not trigger panic button for entity id {entity_id}: {error}" + "message": "Could not trigger panic button for entity ID {entity_id}: {error}" } } } From 856b59207bb536d99b767b8a688f343f7de679b6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 7 Jan 2025 12:08:37 +0100 Subject: [PATCH 10/16] Use sentence case, capitalize "IP Secure" and "ID" (#134966) --- homeassistant/components/knx/strings.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 80ff1105e155de..e7fbfcf5f2fd34 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -3,9 +3,9 @@ "step": { "connection_type": { "title": "KNX connection", - "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.) \n\n 'Tunneling' will connect to a specific KNX IP interface over a tunnel. \n\n 'Routing' will use Multicast to communicate with KNX IP routers.", + "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", "data": { - "connection_type": "KNX Connection Type" + "connection_type": "KNX connection type" }, "data_description": { "connection_type": "Please select the connection type you want to use for your KNX connection." @@ -33,7 +33,7 @@ "title": "Tunnel settings", "description": "Please enter the connection information of your tunneling device.", "data": { - "tunneling_type": "KNX Tunneling Type", + "tunneling_type": "KNX tunneling type", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "route_back": "Route back / NAT mode", @@ -48,11 +48,11 @@ } }, "secure_key_source_menu_tunnel": { - "title": "KNX IP-Secure", + "title": "KNX IP Secure", "description": "How do you want to configure KNX/IP Secure?", "menu_options": { - "secure_knxkeys": "Use a `.knxkeys` file providing IP secure keys", - "secure_tunnel_manual": "Configure IP secure credentials manually" + "secure_knxkeys": "Use a `.knxkeys` file providing IP Secure keys", + "secure_tunnel_manual": "Configure IP Secure credentials manually" } }, "secure_key_source_menu_routing": { @@ -60,7 +60,7 @@ "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", "menu_options": { "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_routing_manual": "Configure IP secure backbone key manually" + "secure_routing_manual": "Configure IP Secure backbone key manually" } }, "secure_knxkeys": { @@ -86,7 +86,7 @@ }, "secure_tunnel_manual": { "title": "Secure tunnelling", - "description": "Please enter your IP secure information.", + "description": "Please enter your IP Secure information.", "data": { "user_id": "User ID", "user_password": "User password", @@ -443,7 +443,7 @@ }, "entity_id": { "name": "Entity", - "description": "Entity id whose state or attribute shall be exposed." + "description": "Entity ID whose state or attribute shall be exposed." }, "attribute": { "name": "Entity attribute", From 30bbca57763e140f363aaed8bb4a61cb58bb301d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 7 Jan 2025 13:18:02 +0100 Subject: [PATCH 11/16] Increase cloud backup download timeout (#134961) Increese download timeout --- homeassistant/components/cloud/backup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 43c87da39a3338..0f137553d3447a 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -114,7 +114,11 @@ async def async_download_backup( raise BackupAgentError("Failed to get download details") from err try: - resp = await self._cloud.websession.get(details["url"]) + resp = await self._cloud.websession.get( + details["url"], + timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h + ) + resp.raise_for_status() except ClientError as err: raise BackupAgentError("Failed to download backup") from err From 28b2f2cb7d0f2c35f9d8721c41492a12061b9acd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:22:16 +0100 Subject: [PATCH 12/16] Simplify onewire config-flow (#134952) --- .../components/onewire/config_flow.py | 52 ++++++------------- 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 3889db2a069356..55b575e5f62c2c 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -39,21 +39,16 @@ ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ +async def validate_input( + hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] +) -> None: + """Validate the user input allows us to connect.""" hub = OneWireHub(hass) - - host = data[CONF_HOST] - port = data[CONF_PORT] - # Raises CannotConnect exception on failure - await hub.connect(host, port) - - # Return info that you want to store in the config entry. - return {"title": host} + try: + await hub.connect(data[CONF_HOST], data[CONF_PORT]) + except CannotConnect: + errors["base"] = "cannot_connect" class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): @@ -61,41 +56,24 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize 1-Wire config flow.""" - self.onewire_config: dict[str, Any] = {} - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle 1-Wire config flow start. - - Let user manually input configuration. - """ + """Handle 1-Wire config flow start.""" errors: dict[str, str] = {} if user_input: - # Prevent duplicate entries - self._async_abort_entries_match( - { - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } - ) - - self.onewire_config.update(user_input) + # Prevent duplicate entries (host+port) + self._async_abort_entries_match(user_input) - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - else: + await validate_input(self.hass, user_input, errors) + if not errors: return self.async_create_entry( - title=info["title"], data=self.onewire_config + title=user_input[CONF_HOST], data=user_input ) return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), errors=errors, ) From 47b5b8971446b0af0cd19f52abafe60bef3b783e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:06:19 +0100 Subject: [PATCH 13/16] Set parallel-updates and scan-interval explicitly in onewire (#134953) --- homeassistant/components/onewire/binary_sensor.py | 4 ++++ homeassistant/components/onewire/sensor.py | 4 ++++ homeassistant/components/onewire/switch.py | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 5607fd7ed1d00a..8cd34273d45af6 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta import os from homeassistant.components.binary_sensor import ( @@ -19,6 +20,9 @@ from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) + @dataclass(frozen=True) class OneWireBinarySensorEntityDescription( diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 2dca53af1cfc9d..29ba2475a9d78a 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Mapping import dataclasses +from datetime import timedelta import logging import os from types import MappingProxyType @@ -41,6 +42,9 @@ from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) + @dataclasses.dataclass(frozen=True) class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescription): diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index ec0bc44e03fee5..071d739629eb9b 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta import os from typing import Any @@ -16,6 +17,9 @@ from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) + @dataclass(frozen=True) class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescription): From fe6396891203f88c721b0662f52012143134991e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jan 2025 05:08:12 -0800 Subject: [PATCH 14/16] Update roborock tests to only load the platform under test (#134694) --- tests/components/roborock/conftest.py | 19 ++++++++++++++----- .../components/roborock/test_binary_sensor.py | 9 +++++++++ tests/components/roborock/test_button.py | 7 +++++++ tests/components/roborock/test_image.py | 9 ++++++++- tests/components/roborock/test_number.py | 7 +++++++ tests/components/roborock/test_select.py | 8 +++++++- tests/components/roborock/test_sensor.py | 8 ++++++++ tests/components/roborock/test_switch.py | 7 +++++++ tests/components/roborock/test_time.py | 7 +++++++ tests/components/roborock/test_vacuum.py | 9 +++++++++ 10 files changed, 83 insertions(+), 7 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 44084574e01cea..d65bf7c61d72c5 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -1,5 +1,6 @@ """Global fixtures for Roborock integration.""" +from collections.abc import Generator from copy import deepcopy from unittest.mock import patch @@ -14,7 +15,7 @@ CONF_USER_DATA, DOMAIN, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -167,13 +168,21 @@ def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: return mock_entry +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, -) -> MockConfigEntry: + platforms: list[Platform], +) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - return mock_roborock_entry + with patch("homeassistant.components.roborock.PLATFORMS", platforms): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_roborock_entry diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index e70dac5ffc9182..0e4b338f469d9f 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test Roborock Binary Sensor.""" +import pytest + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.BINARY_SENSOR] + + async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 43ef043f79c9fb..0a7efe83513ebd 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -6,12 +6,19 @@ import roborock from homeassistant.components.button import SERVICE_PRESS +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.BUTTON] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index c884baef123340..e240dccf7ebdef 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -5,10 +5,11 @@ from http import HTTPStatus from unittest.mock import patch +import pytest from roborock import RoborockException from homeassistant.components.roborock import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +20,12 @@ from tests.typing import ClientSessionGenerator +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.IMAGE] + + async def test_floorplan_image( hass: HomeAssistant, setup_entry: MockConfigEntry, diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index 7e87b49253e683..bfd8cc6da2bedc 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -6,12 +6,19 @@ import roborock from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.NUMBER] + + @pytest.mark.parametrize( ("entity_id", "value"), [ diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 4859af0f79034a..7f25141306b8ef 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -7,7 +7,7 @@ from roborock.exceptions import RoborockException from homeassistant.components.roborock import DOMAIN -from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN +from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -17,6 +17,12 @@ from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SELECT] + + @pytest.mark.parametrize( ("entity_id", "value"), [ diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 908754f3b921b8..9421f59ee56fa7 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from roborock import DeviceData, HomeDataDevice from roborock.const import ( FILTER_REPLACE_TIME, @@ -12,6 +13,7 @@ from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.version_1_apis import RoborockMqttClientV1 +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .mock_data import CONSUMABLE, STATUS, USER_DATA @@ -19,6 +21,12 @@ from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SENSOR] + + async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" assert len(hass.states.async_all("sensor")) == 38 diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 5de3c208c1e9de..2476bfe497c8b8 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -6,12 +6,19 @@ import roborock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SWITCH] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index 836a86bd1147fc..eb48e8e537f12b 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -7,12 +7,19 @@ import roborock from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.TIME] + + @pytest.mark.parametrize( ("entity_id"), [ diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 449ba7bca954a0..d9d4340ec837c8 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -40,6 +40,15 @@ DEVICE_ID = "abc123" +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + # Note: Currently the Image platform is required to make these tests pass since + # some initialization of the coordinator happens as a side effect of loading + # image platform. Fix that and remove IMAGE here. + return [Platform.VACUUM, Platform.IMAGE] + + async def test_registry_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 17094382e2249115db4f1a5d81d3312b81b5ab3b Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 08:26:42 -0600 Subject: [PATCH 15/16] Update HEOS Quality Scale docs-related items (#134466) Update docs items --- homeassistant/components/heos/quality_scale.yaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 4b657f359f7255..4cd3943452147d 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -14,7 +14,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done @@ -60,14 +60,12 @@ rules: status: todo comment: Explore if this is possible. discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: - status: todo - comment: Has some troublehsooting setps, but needs to be improved + docs-troubleshooting: done docs-use-cases: done dynamic-devices: todo entity-category: done From 198fb959e772c50e2c8d566844bf4cd7a8e91fdc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Jan 2025 15:42:07 +0100 Subject: [PATCH 16/16] Fix DSMR migration (#134990) --- homeassistant/components/dsmr/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 213e948bafb91e..f2b88c6c59833c 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -460,7 +460,6 @@ def rename_old_gas_to_mbus( ent_reg.async_update_entity( entity.entity_id, new_unique_id=mbus_device_id, - device_id=mbus_device_id, ) except ValueError: LOGGER.debug(