From eb4b75a9a7b5bb7299cfabb1821896c0ad2b6e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 13 Aug 2025 15:56:04 +0200 Subject: [PATCH 01/73] Extend UnitOfApparentPower with 'mVA' (#150422) Co-authored-by: Martin Hjelmare --- homeassistant/components/number/const.py | 2 +- homeassistant/components/recorder/statistics.py | 2 ++ .../components/recorder/websocket_api.py | 2 ++ homeassistant/components/sensor/const.py | 4 +++- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 15 +++++++++++++++ tests/components/sensor/test_init.py | 1 - tests/util/test_unit_conversion.py | 16 ++++++++++++++++ 8 files changed, 40 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index bfb74d621c3..373814cae9a 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -88,7 +88,7 @@ class NumberDeviceClass(StrEnum): APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7326519b14e..20fd1a3ce28 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -42,6 +42,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.collection import chunked_or_all from homeassistant.util.enum import try_parse_enum from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -193,6 +194,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **dict.fromkeys(ApparentPowerConverter.VALID_UNITS, ApparentPowerConverter), **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter), **dict.fromkeys( BloodGlucoseConcentrationConverter.VALID_UNITS, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d052631c5f6..310e2fc85c5 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -59,6 +60,7 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10 UNIT_SCHEMA = vol.Schema( { + vol.Optional("apparent_power"): vol.In(ApparentPowerConverter.VALID_UNITS), vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS), vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 5f9d5ec9ca0..251a233e1fa 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -46,6 +46,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -117,7 +118,7 @@ class SensorDeviceClass(StrEnum): APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" @@ -528,6 +529,7 @@ STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.APPARENT_POWER: ApparentPowerConverter, SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter, SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, diff --git a/homeassistant/const.py b/homeassistant/const.py index b678e02569c..8e340d8468b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -588,6 +588,7 @@ ATTR_PERSONS: Final = "persons" class UnitOfApparentPower(StrEnum): """Apparent power units.""" + MILLIVOLT_AMPERE = "mVA" VOLT_AMPERE = "VA" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 5bde108dfc1..610cf5db7a9 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -382,6 +383,20 @@ class MassConverter(BaseUnitConverter): } +class ApparentPowerConverter(BaseUnitConverter): + """Utility to convert apparent power values.""" + + UNIT_CLASS = "apparent_power" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000, + UnitOfApparentPower.VOLT_AMPERE: 1, + } + VALID_UNITS = { + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + } + + class PowerConverter(BaseUnitConverter): """Utility to convert power values.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 98fb9d6604a..ce21f6ea8ab 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2958,7 +2958,6 @@ def test_device_class_units_are_complete() -> None: def test_device_class_converters_are_complete() -> None: """Test that the device class converters enum is complete.""" no_converter_device_classes = { - SensorDeviceClass.APPARENT_POWER, SensorDeviceClass.AQI, SensorDeviceClass.BATTERY, SensorDeviceClass.CO, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 537cfb33c31..1ef66584952 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -38,6 +39,7 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -83,6 +85,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { EnergyConverter, InformationConverter, MassConverter, + ApparentPowerConverter, PowerConverter, PressureConverter, ReactiveEnergyConverter, @@ -138,6 +141,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, 1000, ), + ApparentPowerConverter: ( + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + 1000, + ), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), PressureConverter: (UnitOfPressure.HPA, UnitOfPressure.INHG, 33.86389), ReactiveEnergyConverter: ( @@ -615,6 +623,14 @@ _CONVERTED_VALUE: dict[ (1, UnitOfMass.STONES, 14, UnitOfMass.POUNDS), (1, UnitOfMass.STONES, 224, UnitOfMass.OUNCES), ], + ApparentPowerConverter: [ + ( + 10, + UnitOfApparentPower.MILLIVOLT_AMPERE, + 0.01, + UnitOfApparentPower.VOLT_AMPERE, + ), + ], PowerConverter: [ (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), (10, UnitOfPower.MEGA_WATT, 10e6, UnitOfPower.WATT), From 721f9a40d85b34c7f2ee8226dc805bd489fd72ab Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Aug 2025 09:35:37 -0500 Subject: [PATCH 02/73] Add volume up/down intents for media players (#150443) --- .../components/media_player/intent.py | 132 ++++++++++++- tests/components/media_player/test_intent.py | 176 +++++++++++++++++- 2 files changed, 300 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index be365694579..9b714fdf52d 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -1,5 +1,6 @@ """Intents for the media_player integration.""" +import asyncio from collections.abc import Iterable from dataclasses import dataclass, field import logging @@ -14,21 +15,21 @@ from homeassistant.const import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, + STATE_PLAYING, ) from homeassistant.core import Context, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers.entity_component import EntityComponent -from . import ( +from . import MediaPlayerDeviceClass, MediaPlayerEntity +from .browse_media import SearchMedia +from .const import ( + ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, - MediaPlayerDeviceClass, - SearchMedia, -) -from .const import ( - ATTR_MEDIA_FILTER_CLASSES, MediaClass, MediaPlayerEntityFeature, MediaPlayerState, @@ -39,6 +40,7 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative" INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" _LOGGER = logging.getLogger(__name__) @@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: device_classes={MediaPlayerDeviceClass}, ), ) + intent.async_register(hass, MediaSetVolumeRelativeHandler()) intent.async_register(hass, MediaSearchAndPlayHandler()) @@ -354,3 +357,120 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): response.async_set_speech_slots({"media": first_result.as_dict()}) response.response_type = intent.IntentResponseType.ACTION_DONE return response + + +class MediaSetVolumeRelativeHandler(intent.IntentHandler): + """Handler for setting relative volume.""" + + description = "Increases or decreases the volume of a media player" + + intent_type = INTENT_SET_VOLUME_RELATIVE + slot_schema = { + vol.Required("volume_step"): vol.Any( + "up", + "down", + vol.All( + vol.Coerce(int), + vol.Range(min=-100, max=100), + lambda val: val / 100, + ), + ), + # Optional name/area/floor slots handled by intent matcher + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + slots = self.async_validate_slots(intent_obj.slots) + volume_step = slots["volume_step"]["value"] + + # Entity name to match + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + # Get area/floor info + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + + # Find matching entities + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains={DOMAIN}, + assistant=intent_obj.assistant, + features=MediaPlayerEntityFeature.VOLUME_SET, + ) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + + if not match_result.is_match: + # No targets + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + if ( + match_result.is_match + and (len(match_result.states) > 1) + and ("name" not in intent_obj.slots) + ): + # Multiple targets not by name, so we need to check state + match_result.states = [ + s for s in match_result.states if s.state == STATE_PLAYING + ] + if not match_result.states: + # No media players are playing + raise intent.MatchFailedError( + result=intent.MatchTargetsResult( + is_match=False, no_match_reason=intent.MatchFailedReason.STATE + ), + constraints=match_constraints, + preferences=match_preferences, + ) + + target_entity_ids = {s.entity_id for s in match_result.states} + target_entities = [ + e for e in component.entities if e.entity_id in target_entity_ids + ] + + if volume_step == "up": + coros = [e.async_volume_up() for e in target_entities] + elif volume_step == "down": + coros = [e.async_volume_down() for e in target_entities] + else: + coros = [ + e.async_set_volume_level( + max(0.0, min(1.0, e.volume_level + volume_step)) + ) + for e in target_entities + ] + + try: + await asyncio.gather(*coros) + except HomeAssistantError as err: + _LOGGER.error("Error setting relative volume: %s", err) + raise intent.IntentHandleError( + f"Error setting relative volume: {err}" + ) from err + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_states(match_result.states) + return response diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index d1dc03ed12a..2b585319826 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -1,5 +1,8 @@ """The tests for the media_player platform.""" +import math +from unittest.mock import patch + import pytest from homeassistant.components.media_player import ( @@ -13,12 +16,17 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_SET, BrowseMedia, MediaClass, + MediaPlayerEntity, MediaType, SearchMedia, intent as media_player_intent, ) -from homeassistant.components.media_player.const import MediaPlayerEntityFeature +from homeassistant.components.media_player.const import ( + MediaPlayerEntityFeature, + MediaPlayerState, +) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, STATE_IDLE, STATE_PAUSED, @@ -32,8 +40,10 @@ from homeassistant.helpers import ( floor_registry as fr, intent, ) +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service async def test_pause_media_player_intent(hass: HomeAssistant) -> None: @@ -873,3 +883,165 @@ async def test_search_and_play_media_player_intent_with_media_class( "media_class": {"value": "invalid_class"}, }, ) + + +@pytest.mark.parametrize( + ("direction", "volume_change", "volume_change_int"), + [("up", 0.1, 20), ("down", -0.1, -20)], +) +async def test_volume_relative_media_player_intent( + hass: HomeAssistant, direction: str, volume_change: float, volume_change_int: int +) -> None: + """Test relative volume intents for media players.""" + assert await async_setup_component(hass, DOMAIN, {}) + await media_player_intent.async_setup_intents(hass) + + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + default_volume = 0.5 + + class VolumeTestMediaPlayer(MediaPlayerEntity): + _attr_supported_features = MediaPlayerEntityFeature.VOLUME_SET + _attr_volume_level = default_volume + _attr_volume_step = 0.1 + _attr_state = MediaPlayerState.IDLE + + async def async_set_volume_level(self, volume): + self._attr_volume_level = volume + + idle_entity = VolumeTestMediaPlayer() + idle_entity.hass = hass + idle_entity.platform = MockEntityPlatform(hass) + idle_entity.entity_id = f"{DOMAIN}.idle_media_player" + await component.async_add_entities([idle_entity]) + + hass.states.async_set( + idle_entity.entity_id, + STATE_IDLE, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Idle Media Player", + }, + ) + + idle_expected_volume = default_volume + + # Only 1 media player is present, so it's targeted even though its idle + assert idle_entity.volume_level is not None + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + + # Multiple media players (playing one should be targeted) + playing_entity = VolumeTestMediaPlayer() + playing_entity.hass = hass + playing_entity.platform = MockEntityPlatform(hass) + playing_entity.entity_id = f"{DOMAIN}.playing_media_player" + await component.async_add_entities([playing_entity]) + + hass.states.async_set( + playing_entity.entity_id, + STATE_PLAYING, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Playing Media Player", + }, + ) + + playing_expected_volume = default_volume + assert playing_entity.volume_level is not None + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # We can still target by name even if the media player is idle + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}, "name": {"value": "Idle media player"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Set relative volume by percent + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": volume_change_int}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change_int / 100 + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Test error in method + with ( + patch.object( + playing_entity, "async_volume_up", side_effect=RuntimeError("boom!") + ), + pytest.raises(intent.IntentError), + ): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": "up"}}, + ) + + # Multiple idle media players should not match + hass.states.async_set( + playing_entity.entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + + # Test feature not supported + for entity_id in (idle_entity.entity_id, playing_entity.entity_id): + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) From b40aab479aa176b74c77c3b82e3bc975e72b0fbf Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:21:36 -0700 Subject: [PATCH 03/73] Change monetary translation to 'Monetary balance' (#150054) --- homeassistant/components/sensor/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index c69bf99eff0..a8d06f8c0e9 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -25,7 +25,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", - "is_monetary": "Current {entity_name} balance", + "is_monetary": "Current {entity_name} monetary balance", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -81,7 +81,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", - "monetary": "{entity_name} balance changes", + "monetary": "{entity_name} monetary balance changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", @@ -223,7 +223,7 @@ "name": "Moisture" }, "monetary": { - "name": "Balance" + "name": "Monetary balance" }, "nitrogen_dioxide": { "name": "Nitrogen dioxide" From d18cc3d6c396955225f81b31c0aa90b5474ec3b6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:32:50 +0200 Subject: [PATCH 04/73] Fix RuntimeWarning in squeezebox tests (#150582) --- tests/components/squeezebox/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 97aca31fa05..2dd9403d53f 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -271,6 +271,7 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: "homeassistant.components.squeezebox.Player", autospec=True ) as mock_player: mock_player.async_browse = AsyncMock(side_effect=mock_async_browse) + mock_player.async_query = AsyncMock(return_value=MagicMock()) mock_player.generate_image_url_from_track_id = MagicMock( return_value="http://lms.internal:9000/html/images/favorites.png" ) From 13376ef8963e2f98cdc2ca5936e17224965eb953 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:33:02 +0200 Subject: [PATCH 05/73] Fix RuntimeWarning in asuswrt tests (#150580) --- tests/components/asuswrt/conftest.py | 2 +- tests/components/asuswrt/test_sensor.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index e6dd42a23fd..95c8f3dbf74 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -222,7 +222,7 @@ def mock_controller_connect_http_sens_fail(connect_http): def mock_controller_connect_http_sens_detect(): """Mock a successful sensor detection using http library.""" - def _get_sensors_side_effect(datatype): + async def _get_sensors_side_effect(datatype): if datatype == AsusData.TEMPERATURE: return list(MOCK_TEMPERATURES_HTTP) if datatype == AsusData.CPU: diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 3ce3246c1d6..c782605aab3 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -377,7 +377,6 @@ async def test_cpu_sensors_http( connect_http_sens_detect, ) -> None: """Test creating AsusWRT cpu sensors.""" - connect_http_sens_detect(AsusData.CPU) config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) config_entry.add_to_hass(hass) From 2c0ed2cbfe2075b5de3290647eb63bff20593f35 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Aug 2025 11:57:25 -0500 Subject: [PATCH 06/73] Add intent for setting fan speed (#150576) --- homeassistant/components/fan/intent.py | 31 +++++++++++++++++++++ tests/components/fan/test_intent.py | 37 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 homeassistant/components/fan/intent.py create mode 100644 tests/components/fan/test_intent.py diff --git a/homeassistant/components/fan/intent.py b/homeassistant/components/fan/intent.py new file mode 100644 index 00000000000..ef088a4bba9 --- /dev/null +++ b/homeassistant/components/fan/intent.py @@ -0,0 +1,31 @@ +"""Intents for the fan integration.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import ATTR_PERCENTAGE, DOMAIN, SERVICE_TURN_ON + +INTENT_FAN_SET_SPEED = "HassFanSetSpeed" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the fan intents.""" + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_FAN_SET_SPEED, + DOMAIN, + SERVICE_TURN_ON, + description="Sets a fan's speed by percentage", + required_domains={DOMAIN}, + platforms={DOMAIN}, + required_slots={ + ATTR_PERCENTAGE: intent.IntentSlotInfo( + description="The speed percentage of the fan", + value_schema=vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + ) + }, + ), + ) diff --git a/tests/components/fan/test_intent.py b/tests/components/fan/test_intent.py new file mode 100644 index 00000000000..450d81e9dff --- /dev/null +++ b/tests/components/fan/test_intent.py @@ -0,0 +1,37 @@ +"""Intent tests for the fan platform.""" + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN, + SERVICE_TURN_ON, + intent as fan_intent, +) +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from tests.common import async_mock_service + + +async def test_set_speed_intent(hass: HomeAssistant) -> None: + """Test set speed intent for fans.""" + await fan_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_fan" + hass.states.async_set(entity_id, STATE_OFF) + calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + + response = await intent.async_handle( + hass, + "test", + fan_intent.INTENT_FAN_SET_SPEED, + {"name": {"value": "test fan"}, ATTR_PERCENTAGE: {"value": 50}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data == {"entity_id": entity_id, "percentage": 50} From f7726a7563defa2cb34db2223f343f80b57e5800 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:23:26 +0200 Subject: [PATCH 07/73] Update pre-commit-hooks to 6.0.0 (#150583) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 610fed902ad..d87187b55be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] From bda82e19a548107d04b0dde8b6be7784fb74af53 Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:53:21 +0100 Subject: [PATCH 08/73] Pi_hole - Account for auth succeeding when it shouldn't (#150413) --- homeassistant/components/pi_hole/__init__.py | 7 +++++++ tests/components/pi_hole/__init__.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index f73b7156d3e..ae51fe166c4 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -217,6 +217,13 @@ async def determine_api_version( _LOGGER.debug( "Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6 ) + else: + # It seems that occasionally the auth can succeed unexpectedly when there is a valid session + _LOGGER.warning( + "Authenticated with %s through v6 API, but succeeded with an incorrect password. This is a known bug", + holeV6.base_url, + ) + return 6 holeV5 = api_by_version(hass, entry, 5, password="wrong_token") try: await holeV5.get_data() diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index c20f22ac58d..c2edb51e066 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -221,12 +221,16 @@ def _create_mocked_hole( if wrong_host: raise HoleConnectionError("Cannot authenticate with Pi-hole: err") password = getattr(mocked_hole, "password", None) + if ( raise_exception or incorrect_app_password + or api_version == 5 or (api_version == 6 and password not in ["newkey", "apikey"]) ): - if api_version == 6: + if api_version == 6 and ( + incorrect_app_password or password not in ["newkey", "apikey"] + ): raise HoleError("Authentication failed: Invalid password") raise HoleConnectionError From 12c346f55090c2a1dd1c3e765153528aad49dac7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:53:55 +0200 Subject: [PATCH 09/73] Update orjson to 3.11.2 (#150588) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 21f481bcb51..6ffee5f28d0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.11.1 +orjson==3.11.2 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index c4b4bfcdd7d..1f74056ac91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.11.1", + "orjson==3.11.2", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index f0f49ac519b..502e1e225cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ cryptography==45.0.3 Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.11.1 +orjson==3.11.2 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From b3d3284f5c275e1ab5da815a8048882de6791f3c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:55:22 +0200 Subject: [PATCH 10/73] Update types packages (#150586) --- requirements_test.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 2f680240f6e..9df62168b19 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -35,19 +35,19 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20250708 +types-aiofiles==24.1.0.20250809 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250626 +types-croniter==6.0.0.20250809 types-caldav==1.3.0.20250516 types-chardet==0.1.5 types-decorator==5.2.0.20250324 -types-pexpect==4.9.0.20250516 -types-protobuf==6.30.2.20250703 -types-psutil==7.0.0.20250601 -types-pyserial==3.5.0.20250326 -types-python-dateutil==2.9.0.20250708 +types-pexpect==4.9.0.20250809 +types-protobuf==6.30.2.20250809 +types-psutil==7.0.0.20250801 +types-pyserial==3.5.0.20250809 +types-python-dateutil==2.9.0.20250809 types-python-slugify==8.0.2.20240310 -types-pytz==2025.2.0.20250516 -types-PyYAML==6.0.12.20250516 -types-requests==2.32.4.20250611 +types-pytz==2025.2.0.20250809 +types-PyYAML==6.0.12.20250809 +types-requests==2.32.4.20250809 types-xmltodict==0.13.0.3 From cf68214c4d5472d79b1df8a14a766b8c9d7654f3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Aug 2025 13:58:57 -0500 Subject: [PATCH 11/73] Bump hassil to 3.1.0 (#150584) --- homeassistant/components/assist_satellite/entity.py | 10 +++++----- .../components/assist_satellite/manifest.json | 2 +- homeassistant/components/conversation/default_agent.py | 10 +++++----- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e7a10ef63f6..3d562544c68 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -11,7 +11,7 @@ import time from typing import Any, Literal, final from hassil import Intents, recognize -from hassil.expression import Expression, ListReference, Sequence +from hassil.expression import Expression, Group, ListReference from hassil.intents import WildcardSlotList from homeassistant.components import conversation, media_source, stt, tts @@ -413,7 +413,7 @@ class AssistSatelliteEntity(entity.Entity): for intent in intents.intents.values(): for intent_data in intent.data: for sentence in intent_data.sentences: - _collect_list_references(sentence, wildcard_names) + _collect_list_references(sentence.expression, wildcard_names) for wildcard_name in wildcard_names: intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) @@ -727,9 +727,9 @@ class AssistSatelliteEntity(entity.Entity): def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" - if isinstance(expression, Sequence): - seq: Sequence = expression - for item in seq.items: + if isinstance(expression, Group): + grp: Group = expression + for item in grp.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 97362f157e4..184de576050 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==2.2.3"] + "requirements": ["hassil==3.1.0"] } diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bed4b4c0dd6..3fb305098e7 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -14,7 +14,7 @@ import re import time from typing import IO, Any, cast -from hassil.expression import Expression, ListReference, Sequence, TextChunk +from hassil.expression import Expression, Group, ListReference, TextChunk from hassil.intents import ( Intents, SlotList, @@ -1183,7 +1183,7 @@ class DefaultAgent(ConversationEntity): for trigger_intent in trigger_intents.intents.values(): for intent_data in trigger_intent.data: for sentence in intent_data.sentences: - _collect_list_references(sentence, wildcard_names) + _collect_list_references(sentence.expression, wildcard_names) for wildcard_name in wildcard_names: trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) @@ -1520,9 +1520,9 @@ def _get_match_error_response( def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" - if isinstance(expression, Sequence): - seq: Sequence = expression - for item in seq.items: + if isinstance(expression, Group): + grp: Group = expression + for item in grp.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 31adffad064..e7d096212ba 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": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.7.30"] + "requirements": ["hassil==3.1.0", "home-assistant-intents==2025.7.30"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6ffee5f28d0..5fa00656e5a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.0.1 hass-nabucasa==0.111.2 -hassil==2.2.3 +hassil==3.1.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250811.0 home-assistant-intents==2025.7.30 diff --git a/requirements_all.txt b/requirements_all.txt index 7bfc45ae522..220b23cb47e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ hass-splunk==0.1.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==2.2.3 +hassil==3.1.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3cc54a40ab8..e3f832b464b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -998,7 +998,7 @@ hass-nabucasa==0.111.2 # homeassistant.components.assist_satellite # homeassistant.components.conversation -hassil==2.2.3 +hassil==3.1.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 4b8aafce70f..6dbb086f273 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -31,7 +31,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ PyTurboJPEG==1.8.0 \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ - hassil==2.2.3 \ + hassil==3.1.0 \ home-assistant-intents==2025.7.30 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ From 4f640148168e92e913ecbdd1b5941292e097bd7e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Aug 2025 23:34:12 +0200 Subject: [PATCH 12/73] Add wind gust sensor to OpenWeatherMap (#150607) --- .../components/openweathermap/sensor.py | 8 ++ tests/components/openweathermap/conftest.py | 2 +- .../openweathermap/snapshots/test_sensor.ambr | 120 ++++++++++++++++++ .../snapshots/test_weather.ambr | 3 + 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 87b7860afb5..2860abbe64c 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -51,6 +51,7 @@ from .const import ( ATTR_API_WEATHER, ATTR_API_WEATHER_CODE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, ATTRIBUTION, DOMAIN, @@ -93,6 +94,13 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key=ATTR_API_WIND_GUST, + name="Wind gust", + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( key=ATTR_API_WIND_BEARING, name="Wind bearing", diff --git a/tests/components/openweathermap/conftest.py b/tests/components/openweathermap/conftest.py index f7de53b8f97..7c7de776acf 100644 --- a/tests/components/openweathermap/conftest.py +++ b/tests/components/openweathermap/conftest.py @@ -77,8 +77,8 @@ def owm_client_mock() -> Generator[AsyncMock]: cloud_coverage=75, visibility=10000, wind_speed=9.83, + wind_gust=11.81, wind_bearing=199, - wind_gust=None, rain={"1h": 1.21}, snow=None, condition=WeatherCondition( diff --git a/tests/components/openweathermap/snapshots/test_sensor.ambr b/tests/components/openweathermap/snapshots/test_sensor.ambr index 11a1feb721f..de953861f80 100644 --- a/tests/components/openweathermap/snapshots/test_sensor.ambr +++ b/tests/components/openweathermap/snapshots/test_sensor.ambr @@ -1239,6 +1239,66 @@ 'state': '199', }) # --- +# name: test_sensor_states[current][sensor.openweathermap_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.openweathermap_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.516', + }) +# --- # name: test_sensor_states[current][sensor.openweathermap_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2108,6 +2168,66 @@ 'state': '199', }) # --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.openweathermap_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.516', + }) +# --- # name: test_sensor_states[v3.0][sensor.openweathermap_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index 760160a96f4..073715c87ec 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -74,6 +74,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), @@ -137,6 +138,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), @@ -200,6 +202,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 199, + 'wind_gust_speed': 42.52, 'wind_speed': 35.39, 'wind_speed_unit': , }), From f58b2177a2a58c699951b05d23f364e6c924a206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 13 Aug 2025 23:42:47 +0200 Subject: [PATCH 13/73] Bump pymiele to 0.5.4 (#150605) --- homeassistant/components/miele/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index b8ca0535c3e..63ace343dc8 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -8,7 +8,7 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "bronze", - "requirements": ["pymiele==0.5.3"], + "requirements": ["pymiele==0.5.4"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 220b23cb47e..05cff194cf4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.3 +pymiele==0.5.4 # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3f832b464b..70db04edfa1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1782,7 +1782,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.3 +pymiele==0.5.4 # homeassistant.components.mochad pymochad==0.2.0 From b5db0e98b495ecd766244f9a166ce4ac1eda9e37 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:44:07 +0200 Subject: [PATCH 14/73] Bump pyenphase to 2.3.0 (#150600) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index e337dac74e0..d9a3dd0bdce 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.3"], + "requirements": ["pyenphase==2.3.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 05cff194cf4..97baa819716 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1960,7 +1960,7 @@ pyegps==0.2.5 pyemoncms==0.1.2 # homeassistant.components.enphase_envoy -pyenphase==2.2.3 +pyenphase==2.3.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70db04edfa1..961211a8662 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ pyegps==0.2.5 pyemoncms==0.1.2 # homeassistant.components.enphase_envoy -pyenphase==2.2.3 +pyenphase==2.3.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 999980789121dc08b0e98d5674bd720f8dc78376 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Aug 2025 23:48:20 +0200 Subject: [PATCH 15/73] Use OptionsFlowWithReload in coinbase (#150587) --- homeassistant/components/coinbase/__init__.py | 29 ------------------- .../components/coinbase/config_flow.py | 8 +++-- homeassistant/components/coinbase/sensor.py | 17 +++++++++++ tests/components/coinbase/test_config_flow.py | 4 --- 4 files changed, 23 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index adb6dc48c9c..dca7f774331 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import entity_registry as er from homeassistant.util import Throttle from .const import ( @@ -30,9 +29,7 @@ from .const import ( API_RESOURCE_TYPE, API_V3_ACCOUNT_ID, API_V3_TYPE_VAULT, - CONF_CURRENCIES, CONF_EXCHANGE_BASE, - CONF_EXCHANGE_RATES, ) _LOGGER = logging.getLogger(__name__) @@ -47,9 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> """Set up Coinbase from a config entry.""" instance = await hass.async_add_executor_job(create_and_update_instance, entry) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = instance await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -83,29 +77,6 @@ def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: return instance -async def update_listener( - hass: HomeAssistant, config_entry: CoinbaseConfigEntry -) -> None: - """Handle options update.""" - - await hass.config_entries.async_reload(config_entry.entry_id) - - registry = er.async_get(hass) - entities = er.async_entries_for_config_entry(registry, config_entry.entry_id) - - # Remove orphaned entities - for entity in entities: - currency = entity.unique_id.split("-")[-1] - if ( - "xe" in entity.unique_id - and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) - ) or ( - "wallet" in entity.unique_id - and currency not in config_entry.options.get(CONF_CURRENCIES, []) - ): - registry.async_remove(entity.entity_id) - - def get_accounts(client): """Handle paginated accounts.""" response = client.get_accounts() diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index e1dad899d2b..6aad3a81d17 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -10,7 +10,11 @@ from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -204,7 +208,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Coinbase.""" async def async_step_init( diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index f69aed8c386..4dfc744b7fa 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -6,6 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -68,6 +69,22 @@ async def async_setup_entry( CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION_DEFAULT ) + # Remove orphaned entities + registry = er.async_get(hass) + existing_entities = er.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + for entity in existing_entities: + currency = entity.unique_id.split("-")[-1] + if ( + "xe" in entity.unique_id + and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) + ) or ( + "wallet" in entity.unique_id + and currency not in config_entry.options.get(CONF_CURRENCIES, []) + ): + registry.async_remove(entity.entity_id) + for currency in desired_currencies: _LOGGER.debug( "Attempting to set up %s account sensor", diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index 0dc7fa95ffb..3858df83269 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -186,9 +186,6 @@ async def test_option_form(hass: HomeAssistant) -> None: "coinbase.rest.RESTClient.get", return_value={"data": mock_get_exchange_rates()}, ), - patch( - "homeassistant.components.coinbase.update_listener" - ) as mock_update_listener, ): config_entry = await init_mock_coinbase(hass) await hass.async_block_till_done() @@ -204,7 +201,6 @@ async def test_option_form(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - assert len(mock_update_listener.mock_calls) == 1 async def test_form_bad_account_currency(hass: HomeAssistant) -> None: From ed39b18d94429cdf43ba03256b21dac2a2931d2a Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Thu, 14 Aug 2025 06:10:19 +0800 Subject: [PATCH 16/73] Add cover platform for switchbot cloud (#148993) --- .../components/switchbot_cloud/__init__.py | 23 + .../switchbot_cloud/binary_sensor.py | 5 + .../components/switchbot_cloud/const.py | 2 + .../components/switchbot_cloud/cover.py | 233 +++++++++ .../components/switchbot_cloud/fan.py | 10 +- .../components/switchbot_cloud/sensor.py | 4 + tests/components/switchbot_cloud/conftest.py | 10 + .../components/switchbot_cloud/test_cover.py | 457 ++++++++++++++++++ 8 files changed, 739 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/switchbot_cloud/cover.py create mode 100644 tests/components/switchbot_cloud/test_cover.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index ae3a32997ae..44fbfe0fcf4 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.LOCK, @@ -47,6 +48,7 @@ class SwitchbotDevices: ) buttons: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) climates: list[tuple[Remote, SwitchBotCoordinator]] = field(default_factory=list) + covers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field( default_factory=list ) @@ -192,6 +194,27 @@ async def make_device_data( ) devices_data.fans.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Curtain", + "Curtain3", + "Roller Shade", + "Blind Tilt", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.covers.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Garage Door Opener", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.covers.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ "Strip Light", diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index cd0e6e8968c..a1ad6d6887d 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -60,6 +60,11 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Curtain": (CALIBRATION_DESCRIPTION,), + "Curtain3": (CALIBRATION_DESCRIPTION,), + "Roller Shade": (CALIBRATION_DESCRIPTION,), + "Blind Tilt": (CALIBRATION_DESCRIPTION,), + "Garage Door Opener": (DOOR_OPEN_DESCRIPTION,), } diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index dcca5119a74..a9b3d0df412 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -17,3 +17,5 @@ VACUUM_FAN_SPEED_STRONG = "strong" VACUUM_FAN_SPEED_MAX = "max" AFTER_COMMAND_REFRESH = 5 + +COVER_ENTITY_AFTER_COMMAND_REFRESH = 10 diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py new file mode 100644 index 00000000000..77f0b960d25 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -0,0 +1,233 @@ +"""Support for the Switchbot BlindTilt, Curtain, Curtain3, RollerShade as Cover.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + BlindTiltCommands, + CommonCommands, + CurtainCommands, + Device, + Remote, + RollerShadeCommands, + SwitchBotAPI, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData, SwitchBotCoordinator +from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH, DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.covers + ) + + +class SwitchBotCloudCover(SwitchBotCloudEntity, CoverEntity): + """Representation of a SwitchBot Cover.""" + + _attr_name = None + _attr_is_closed: bool | None = None + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + position: int | None = self.coordinator.data.get("slidePosition") + if position is None: + return + self._attr_current_cover_position = 100 - position + self._attr_current_cover_tilt_position = 100 - position + self._attr_is_closed = position == 100 + + +class SwitchBotCloudCoverCurtain(SwitchBotCloudCover): + """Representation of a SwitchBot Curtain & Curtain3.""" + + _attr_device_class = CoverDeviceClass.CURTAIN + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position: int | None = kwargs.get("position") + if position is not None: + await self.send_api_command( + CurtainCommands.SET_POSITION, + parameters=f"{0},ff,{100 - position}", + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.send_api_command(CurtainCommands.PAUSE) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover): + """Representation of a SwitchBot RollerShade.""" + + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0)) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command( + RollerShadeCommands.SET_POSITION, parameters=str(100) + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position: int | None = kwargs.get("position") + if position is not None: + await self.send_api_command( + RollerShadeCommands.SET_POSITION, parameters=str(100 - position) + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverBlindTilt(SwitchBotCloudCover): + """Representation of a SwitchBot Blind Tilt.""" + + _attr_direction: str | None = None + _attr_device_class = CoverDeviceClass.BLIND + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + ) + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + position: int | None = self.coordinator.data.get("slidePosition") + if position is None: + return + self._attr_is_closed = position in [0, 100] + if position > 50: + percent = 100 - ((position - 50) * 2) + else: + percent = 100 - (50 - position) * 2 + self._attr_current_cover_position = percent + self._attr_current_cover_tilt_position = percent + direction = self.coordinator.data.get("direction") + self._attr_direction = direction.lower() if direction else None + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + percent: int | None = kwargs.get("tilt_position") + if percent is not None: + await self.send_api_command( + BlindTiltCommands.SET_POSITION, + parameters=f"{self._attr_direction};{percent}", + ) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(BlindTiltCommands.FULLY_OPEN) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover.""" + if self._attr_direction is not None: + if "up" in self._attr_direction: + await self.send_api_command(BlindTiltCommands.CLOSE_UP) + else: + await self.send_api_command(BlindTiltCommands.CLOSE_DOWN) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudCoverGarageDoorOpener(SwitchBotCloudCover): + """Representation of a SwitchBot Garage Door Opener.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + + def _set_attributes(self) -> None: + if self.coordinator.data is None: + return + door_status: int | None = self.coordinator.data.get("doorStatus") + self._attr_is_closed = None if door_status is None else door_status == 1 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> ( + SwitchBotCloudCoverBlindTilt + | SwitchBotCloudCoverRollerShade + | SwitchBotCloudCoverCurtain + | SwitchBotCloudCoverGarageDoorOpener +): + """Make a SwitchBotCloudCover device.""" + if device.device_type == "Blind Tilt": + return SwitchBotCloudCoverBlindTilt(api, device, coordinator) + if device.device_type == "Roller Shade": + return SwitchBotCloudCoverRollerShade(api, device, coordinator) + if device.device_type == "Garage Door Opener": + return SwitchBotCloudCoverGarageDoorOpener(api, device, coordinator) + return SwitchBotCloudCoverCurtain(api, device, coordinator) diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py index d7cf82520ec..418296ffb55 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData -from .const import DOMAIN +from .const import AFTER_COMMAND_REFRESH, DOMAIN from .entity import SwitchBotCloudEntity @@ -88,13 +88,13 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): command=BatteryCirculatorFanCommands.SET_WIND_SPEED, parameters=str(self.percentage), ) - await asyncio.sleep(5) + await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" await self.send_api_command(CommonCommands.OFF) - await asyncio.sleep(5) + await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() async def async_set_percentage(self, percentage: int) -> None: @@ -107,7 +107,7 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): command=BatteryCirculatorFanCommands.SET_WIND_SPEED, parameters=str(percentage), ) - await asyncio.sleep(5) + await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -116,5 +116,5 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): command=BatteryCirculatorFanCommands.SET_WIND_MODE, parameters=preset_mode, ) - await asyncio.sleep(5) + await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 75e994b484e..163b1653686 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -139,6 +139,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Smart Lock Lite": (BATTERY_DESCRIPTION,), "Smart Lock Pro": (BATTERY_DESCRIPTION,), "Smart Lock Ultra": (BATTERY_DESCRIPTION,), + "Curtain": (BATTERY_DESCRIPTION,), + "Curtain3": (BATTERY_DESCRIPTION,), + "Roller Shade": (BATTERY_DESCRIPTION,), + "Blind Tilt": (BATTERY_DESCRIPTION,), } diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py index 27214fde28d..c38e3e1264e 100644 --- a/tests/components/switchbot_cloud/conftest.py +++ b/tests/components/switchbot_cloud/conftest.py @@ -39,3 +39,13 @@ def mock_after_command_refresh(): "homeassistant.components.switchbot_cloud.const.AFTER_COMMAND_REFRESH", 0 ): yield + + +@pytest.fixture(scope="package", autouse=True) +def mock_after_command_refresh_for_cover(): + """Mock after command refresh.""" + with patch( + "homeassistant.components.switchbot_cloud.const.COVER_ENTITY_AFTER_COMMAND_REFRESH", + 0, + ): + yield diff --git a/tests/components/switchbot_cloud/test_cover.py b/tests/components/switchbot_cloud/test_cover.py new file mode 100644 index 00000000000..0d0daf1bd7b --- /dev/null +++ b/tests/components/switchbot_cloud/test_cover.py @@ -0,0 +1,457 @@ +"""Test for the switchbot_cloud Cover.""" + +from unittest.mock import patch + +import pytest +from switchbot_api import ( + BlindTiltCommands, + CommonCommands, + CurtainCommands, + Device, + RollerShadeCommands, +) + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_cover_set_attributes_normal( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test cover set_attributes normal.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Roller Shade", + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.return_value = {"slidePosition": 100, "direction": "up"} + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_CLOSED + + +@pytest.mark.parametrize( + "device_model", + [ + "Roller Shade", + "Blind Tilt", + ], +) +async def test_cover_set_attributes_position_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_model +) -> None: + """Test cover_set_attributes position is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.side_effect = [{"direction": "up"}, {"direction": "up"}] + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "device_model", + [ + "Roller Shade", + "Blind Tilt", + ], +) +async def test_cover_set_attributes_coordinator_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_model +) -> None: + """Test cover set_attributes coordinator is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType=device_model, + hubDeviceId="test-hub-id", + ), + ] + + cover_id = "cover.cover_1" + mock_get_status.return_value = None + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +async def test_curtain_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test curtain features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Curtain", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.ON, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.OFF, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CurtainCommands.PAUSE, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"position": 50, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CurtainCommands.SET_POSITION, "command", "0,ff,50" + ) + + +async def test_blind_tilt_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test blind_tilt features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Blind Tilt", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + {"slidePosition": 95, "direction": "up"}, + {"slidePosition": 95, "direction": "up"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.FULLY_OPEN, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.CLOSE_UP, "command", "default" + ) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"tilt_position": 25, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.SET_POSITION, "command", "up;25" + ) + + +async def test_blind_tilt_features_close_down( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test blind tilt features close_down.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Blind Tilt", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + {"slidePosition": 25, "direction": "down"}, + {"slidePosition": 25, "direction": "down"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", BlindTiltCommands.CLOSE_DOWN, "command", "default" + ) + + +async def test_roller_shade_features( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test roller shade features.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Roller Shade", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "slidePosition": 95, + }, + { + "slidePosition": 95, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "0" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "100" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"position": 50, ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", RollerShadeCommands.SET_POSITION, "command", "50" + ) + + +async def test_cover_set_attributes_coordinator_is_none_for_garage_door( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test cover set_attributes coordinator is none for garage_door.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + cover_id = "cover.cover_1" + mock_get_status.return_value = None + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_UNKNOWN + + +async def test_garage_door_features_close( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test garage door features close.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "doorStatus": 1, + }, + { + "doorStatus": 1, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.OFF, "command", "default" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_CLOSED + + +async def test_garage_door_features_open( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test garage_door features open cover.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="cover-id-1", + deviceName="cover-1", + deviceType="Garage Door Opener", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "doorStatus": 0, + }, + { + "doorStatus": 0, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + cover_id = "cover.cover_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: cover_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "cover-id-1", CommonCommands.ON, "command", "default" + ) + + await configure_integration(hass) + state = hass.states.get(cover_id) + assert state.state == STATE_OPEN From 12706178c2e324a8f4a1649047e07b507f3129f9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 13 Aug 2025 18:11:52 -0400 Subject: [PATCH 17/73] Change Snoo to use MQTT instead of PubNub (#150570) --- homeassistant/components/snoo/coordinator.py | 2 +- tests/components/snoo/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py index 8ce0db34621..43e717c2bc7 100644 --- a/homeassistant/components/snoo/coordinator.py +++ b/homeassistant/components/snoo/coordinator.py @@ -40,7 +40,7 @@ class SnooCoordinator(DataUpdateCoordinator[SnooData]): async def setup(self) -> None: """Perform setup needed on every coordintaor creation.""" - await self.snoo.subscribe(self.device, self.async_set_updated_data) + self.snoo.start_subscribe(self.device, self.async_set_updated_data) # After we subscribe - get the status so that we have something to start with. # We only need to do this once. The device will auto update otherwise. await self.snoo.get_status(self.device) diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py index b4692e6f08b..417eb438143 100644 --- a/tests/components/snoo/__init__.py +++ b/tests/components/snoo/__init__.py @@ -48,7 +48,7 @@ def find_update_callback( mock: AsyncMock, serial_number: str ) -> Callable[[SnooData], Awaitable[None]]: """Find the update callback for a specific identifier.""" - for call in mock.subscribe.call_args_list: + for call in mock.start_subscribe.call_args_list: if call[0][0].serialNumber == serial_number: return call[0][1] pytest.fail(f"Callback for identifier {serial_number} not found") From 6a4bf4ec72a52f965e9febcd7589a56548b1faa1 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 13 Aug 2025 18:12:18 -0400 Subject: [PATCH 18/73] Bump python-snoo to 0.8.2 (#150569) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 0db11c5b086..0a2301c6fd8 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.8.1"] + "requirements": ["python-snoo==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 97baa819716..e727c52bbfb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2506,7 +2506,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.8.1 +python-snoo==0.8.2 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 961211a8662..bb37d13404e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.8.1 +python-snoo==0.8.2 # homeassistant.components.songpal python-songpal==0.16.2 From f28e9f60ee8ceb2b238a374f3f42f2ab9fe46b2f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 14 Aug 2025 01:03:04 +0200 Subject: [PATCH 19/73] Use runtime_data in pvpc_hourly_pricing (#150565) --- .../components/pvpc_hourly_pricing/__init__.py | 18 +++++++----------- .../pvpc_hourly_pricing/coordinator.py | 6 ++++-- .../components/pvpc_hourly_pricing/sensor.py | 7 +++---- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 4d120e9fae7..ad35e409627 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -1,18 +1,17 @@ """The pvpc_hourly_pricing integration to collect Spain official electric prices.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN -from .coordinator import ElecPricesDataUpdateCoordinator +from .const import ATTR_POWER, ATTR_POWER_P3 +from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry from .helpers import get_enabled_sensor_keys PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool: """Set up pvpc hourly pricing from a config entry.""" entity_registry = er.async_get(hass) sensor_keys = get_enabled_sensor_keys( @@ -22,13 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = ElecPricesDataUpdateCoordinator(hass, entry, sensor_keys) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: PVPCConfigEntry) -> None: """Handle options update.""" if any( entry.data.get(attrib) != entry.options.get(attrib) @@ -41,9 +40,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py index 28e676d37ed..bc9d6a21557 100644 --- a/homeassistant/components/pvpc_hourly_pricing/coordinator.py +++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py @@ -17,14 +17,16 @@ from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN _LOGGER = logging.getLogger(__name__) +type PVPCConfigEntry = ConfigEntry[ElecPricesDataUpdateCoordinator] + class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" - config_entry: ConfigEntry + config_entry: PVPCConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] + self, hass: HomeAssistant, entry: PVPCConfigEntry, sensor_keys: set[str] ) -> None: """Initialize.""" self.api = PVPCData( diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 1b92cfc533d..c49756290ab 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,7 +23,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ElecPricesDataUpdateCoordinator +from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry from .helpers import make_sensor_unique_id _LOGGER = logging.getLogger(__name__) @@ -149,11 +148,11 @@ _PRICE_SENSOR_ATTRIBUTES_MAP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PVPCConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the electricity price sensor from config_entry.""" - coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors = [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)] if coordinator.api.using_private_api: sensors.extend( From 4954c2a84be8b015d3f61c3183f287b44528db16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:27:18 +0200 Subject: [PATCH 20/73] Bump actions/ai-inference from 1.2.8 to 2.0.0 (#150619) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 17777f576de..5f9522e0593 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.2.8 + uses: actions/ai-inference@v2.0.0 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 1aa51492c74..bcad5726968 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.2.8 + uses: actions/ai-inference@v2.0.0 with: model: openai/gpt-4o-mini system-prompt: | From 5a789cbbc8710df8b29bbef3372e4543550c6f98 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 14 Aug 2025 09:30:02 +0200 Subject: [PATCH 21/73] Bump togrill to 0.7.0 in preperation for number (#150611) --- homeassistant/components/togrill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json index 7d777b8ae67..9f9ad8c3782 100644 --- a/homeassistant/components/togrill/manifest.json +++ b/homeassistant/components/togrill/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/togrill", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["togrill-bluetooth==0.4.0"] + "requirements": ["togrill-bluetooth==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e727c52bbfb..0083e03c4ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2953,7 +2953,7 @@ tmb==0.0.4 todoist-api-python==2.1.7 # homeassistant.components.togrill -togrill-bluetooth==0.4.0 +togrill-bluetooth==0.7.0 # homeassistant.components.tolo tololib==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb37d13404e..a5b5379d964 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2430,7 +2430,7 @@ tilt-pi==0.2.1 todoist-api-python==2.1.7 # homeassistant.components.togrill -togrill-bluetooth==0.4.0 +togrill-bluetooth==0.7.0 # homeassistant.components.tolo tololib==1.2.2 From bb3d5718872ef18f62987e8a9ed2fd844ce8dbe2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 14 Aug 2025 09:30:47 +0200 Subject: [PATCH 22/73] Make sure we update the api version in philips_js discovery (#150604) --- homeassistant/components/philips_js/config_flow.py | 2 +- tests/components/philips_js/test_config_flow.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index a568d51e5ea..779452b284b 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -82,7 +82,7 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): ) await hub.getSystem() - await hub.setTransport(hub.secured_transport) + await hub.setTransport(hub.secured_transport, hub.api_version_detected) if not hub.system or not hub.name: raise ConnectionFailure("System data or name is empty") diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index c4dcc44e619..77227fd0f63 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -125,7 +125,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv.setTransport.assert_called_with(True) + mock_tv.setTransport.assert_called_with(True, ANY) mock_tv.pairRequest.assert_called() result = await hass.config_entries.flow.async_configure( @@ -204,7 +204,7 @@ async def test_pair_grant_failed( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv.setTransport.assert_called_with(True) + mock_tv.setTransport.assert_called_with(True, ANY) mock_tv.pairRequest.assert_called() # Test with invalid pin @@ -266,6 +266,7 @@ async def test_zeroconf_discovery( """Test we can setup from zeroconf discovery.""" mock_tv_pairable.secured_transport = secured_transport + mock_tv_pairable.api_version_detected = 6 result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -291,7 +292,7 @@ async def test_zeroconf_discovery( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tv_pairable.setTransport.assert_called_with(secured_transport) + mock_tv_pairable.setTransport.assert_called_with(secured_transport, 6) mock_tv_pairable.pairRequest.assert_called() From 7e28e3dcd303ba711d50815d118f9647e9272245 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Aug 2025 09:31:43 +0200 Subject: [PATCH 23/73] Add sw_version to JustNimbus device (#150592) --- homeassistant/components/justnimbus/entity.py | 1 + tests/components/justnimbus/test_config_flow.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/justnimbus/entity.py b/homeassistant/components/justnimbus/entity.py index f85c3f33f93..1d0e6a4c1bc 100644 --- a/homeassistant/components/justnimbus/entity.py +++ b/homeassistant/components/justnimbus/entity.py @@ -28,6 +28,7 @@ class JustNimbusEntity( identifiers={(DOMAIN, device_id)}, name="JustNimbus Sensor", manufacturer="JustNimbus", + sw_version=coordinator.data.api_version, ) @property diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py index cc3a7a88285..d581f230dde 100644 --- a/tests/components/justnimbus/test_config_flow.py +++ b/tests/components/justnimbus/test_config_flow.py @@ -132,7 +132,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with patch( "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", - return_value=MagicMock(), + return_value=MagicMock(api_version="1.0.0"), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From cc4b9e0eca2436be9ae989e87edb19bbb327651a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 14 Aug 2025 11:46:06 +0200 Subject: [PATCH 24/73] Extend UnitOfReactivePower with 'mvar' (#150415) --- homeassistant/components/number/const.py | 2 +- .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 4 +- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 17 ++++++ tests/components/sensor/test_init.py | 1 - tests/util/test_unit_conversion.py | 60 +++++++++++++++---- 8 files changed, 73 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 373814cae9a..02e11d1530a 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -338,7 +338,7 @@ class NumberDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var`, `kvar` + Unit of measurement: `mvar`, `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 20fd1a3ce28..2321da45bb9 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -60,6 +60,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -216,6 +217,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter), **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter), **dict.fromkeys(ReactiveEnergyConverter.VALID_UNITS, ReactiveEnergyConverter), + **dict.fromkeys(ReactivePowerConverter.VALID_UNITS, ReactivePowerConverter), **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter), **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter), **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter), diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 310e2fc85c5..4f798fb86d0 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -33,6 +33,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -81,6 +82,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS), + vol.Optional("reactive_power"): vol.In(ReactivePowerConverter.VALID_UNITS), vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 251a233e1fa..92607ba07eb 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -64,6 +64,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -370,7 +371,7 @@ class SensorDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var`, `kvar` + Unit of measurement: `mvar`, `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -550,6 +551,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRECIPITATION_INTENSITY: SpeedConverter, SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter, + SensorDeviceClass.REACTIVE_POWER: ReactivePowerConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: MassVolumeConcentrationConverter, diff --git a/homeassistant/const.py b/homeassistant/const.py index 8e340d8468b..b74fa64d5c7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -609,6 +609,7 @@ class UnitOfPower(StrEnum): class UnitOfReactivePower(StrEnum): """Reactive power units.""" + MILLIVOLT_AMPERE_REACTIVE = "mvar" VOLT_AMPERE_REACTIVE = "var" KILO_VOLT_AMPERE_REACTIVE = "kvar" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 610cf5db7a9..ad459e55d15 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfReactiveEnergy, + UnitOfReactivePower, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -460,6 +461,22 @@ class ReactiveEnergyConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfReactiveEnergy) +class ReactivePowerConverter(BaseUnitConverter): + """Utility to convert reactive power values.""" + + UNIT_CLASS = "reactive_power" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE: 1 * 1000, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE: 1, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE: 1 / 1000, + } + VALID_UNITS = { + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + } + + class SpeedConverter(BaseUnitConverter): """Utility to convert speed values.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index ce21f6ea8ab..62141186b55 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2978,7 +2978,6 @@ def test_device_class_converters_are_complete() -> None: SensorDeviceClass.PM1, SensorDeviceClass.PM10, SensorDeviceClass.PM25, - SensorDeviceClass.REACTIVE_POWER, SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, SensorDeviceClass.SULPHUR_DIOXIDE, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 1ef66584952..08fb7cce067 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfReactiveEnergy, + UnitOfReactivePower, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -57,6 +58,7 @@ from homeassistant.util.unit_conversion import ( PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -89,6 +91,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { PowerConverter, PressureConverter, ReactiveEnergyConverter, + ReactivePowerConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -100,6 +103,11 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + ApparentPowerConverter: ( + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + 1000, + ), AreaConverter: (UnitOfArea.SQUARE_KILOMETERS, UnitOfArea.SQUARE_METERS, 0.000001), BloodGlucoseConcentrationConverter: ( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, @@ -141,11 +149,6 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, 1000, ), - ApparentPowerConverter: ( - UnitOfApparentPower.MILLIVOLT_AMPERE, - UnitOfApparentPower.VOLT_AMPERE, - 1000, - ), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), PressureConverter: (UnitOfPressure.HPA, UnitOfPressure.INHG, 33.86389), ReactiveEnergyConverter: ( @@ -153,6 +156,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, 1000, ), + ReactivePowerConverter: ( + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + 1000, + ), SpeedConverter: ( UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, @@ -176,6 +184,14 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + ApparentPowerConverter: [ + ( + 10, + UnitOfApparentPower.MILLIVOLT_AMPERE, + 0.01, + UnitOfApparentPower.VOLT_AMPERE, + ), + ], AreaConverter: [ # Square Meters to other units (5, UnitOfArea.SQUARE_METERS, 50000, UnitOfArea.SQUARE_CENTIMETERS), @@ -623,14 +639,6 @@ _CONVERTED_VALUE: dict[ (1, UnitOfMass.STONES, 14, UnitOfMass.POUNDS), (1, UnitOfMass.STONES, 224, UnitOfMass.OUNCES), ], - ApparentPowerConverter: [ - ( - 10, - UnitOfApparentPower.MILLIVOLT_AMPERE, - 0.01, - UnitOfApparentPower.VOLT_AMPERE, - ), - ], PowerConverter: [ (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), (10, UnitOfPower.MEGA_WATT, 10e6, UnitOfPower.WATT), @@ -682,6 +690,32 @@ _CONVERTED_VALUE: dict[ UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, ), ], + ReactivePowerConverter: [ + ( + 10, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + 10000, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + 0.01, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + 0.01, + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + ), + ( + 10, + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + 0.00001, + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + ), + ], SpeedConverter: [ # 5 km/h / 1.609 km/mi = 3.10686 mi/h (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.106856, UnitOfSpeed.MILES_PER_HOUR), From 02dca5f0ad298a52818b6559ed17ce3c0ed075b6 Mon Sep 17 00:00:00 2001 From: Martin Dybal Date: Thu, 14 Aug 2025 12:55:54 +0200 Subject: [PATCH 25/73] Fix type annotation for climate `_attr_current_humidity` (#150615) --- homeassistant/components/climate/__init__.py | 2 +- homeassistant/components/lookin/climate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 790579d6a73..4a244ce3530 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -255,7 +255,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ) entity_description: ClimateEntityDescription - _attr_current_humidity: int | None = None + _attr_current_humidity: float | None = None _attr_current_temperature: float | None = None _attr_fan_mode: str | None _attr_fan_modes: list[str] | None diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 6b92032e4ab..cc9634ac1b6 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -91,7 +91,7 @@ async def async_setup_entry( class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): """An aircon or heat pump.""" - _attr_current_humidity: float | None = None # type: ignore[assignment] + _attr_current_humidity: float | None = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE From e6103fdcf4b5634a899daef9cb4652b5e4a99208 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 14 Aug 2025 13:43:32 +0200 Subject: [PATCH 26/73] Bump airOS to 0.2.11 (#150627) --- homeassistant/components/airos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index 58f76abe577..16855d805c0 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.2.8"] + "requirements": ["airos==0.2.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0083e03c4ca..2b4c6c916bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.8 +airos==0.2.11 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5b5379d964..7e11345951b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -435,7 +435,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.8 +airos==0.2.11 # homeassistant.components.airthings_ble airthings-ble==0.9.2 From 5358c89bfdac9a1644b298e83accd21e578033c1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Aug 2025 14:51:20 +0200 Subject: [PATCH 27/73] Add fixtures for one door refrigerator in SmartThings (#150632) --- tests/components/smartthings/conftest.py | 1 + .../da_ref_normal_01011_onedoor.json | 1380 +++++++++++++++++ .../devices/da_ref_normal_01011_onedoor.json | 588 +++++++ .../snapshots/test_binary_sensor.ambr | 49 + .../smartthings/snapshots/test_init.ambr | 31 + .../smartthings/snapshots/test_sensor.ambr | 282 ++++ .../smartthings/snapshots/test_switch.ambr | 48 + 7 files changed, 2379 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 93f505872f4..9a633a38f1a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -112,6 +112,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "centralite", "da_ref_normal_000001", "da_ref_normal_01011", + "da_ref_normal_01011_onedoor", "da_ref_normal_01001", "vd_network_audio_002s", "vd_network_audio_003s", diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json new file mode 100644 index 00000000000..5cb33eb9535 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011_onedoor.json @@ -0,0 +1,1380 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-08-14T05:31:51.945Z" + } + }, + "samsungce.fridgeWelcomeLighting": { + "detectionProximity": { + "value": null + }, + "supportedDetectionProximities": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": null + }, + "contents": { + "value": null + }, + "lastUpdatedTime": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "00130445", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "00090026001610304100000021010000", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "description": { + "value": "TP1X_REF_21K", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_REF_21K", + "timestamp": "2025-08-14T03:17:25.761Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-08-12T13:08:24.409Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP1-24-T4-COM_20250706", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "di": { + "value": "271d82e9-5b0c-e4b8-058e-cdf23a188610", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "n": { + "value": "Samsung-Refrigerator", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnmo": { + "value": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01011", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnpv": { + "value": "SYSTEM 2.0", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "pi": { + "value": "271d82e9-5b0c-e4b8-058e-cdf23a188610", + "timestamp": "2025-08-12T13:21:14.953Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-08-12T13:21:14.953Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": "off", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.driverState": { + "driverState": { + "value": { + "device/0": [ + { + "rt": ["x.com.samsung.devcol", "oic.wk.col"], + "if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"] + }, + { + "href": "/alarms/vs/0", + "rep": { + "href": "/alarms/vs/0" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On", + "href": "/bespoke/vs/0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "", + "href": "/configuration/vs/0" + } + }, + { + "href": "/door/onedoorfreezer/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close", + "href": "/door/onedoorfreezer/vs/0" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Door" + } + ], + "href": "/doors/vs/0" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "1129", + "x.com.samsung.da.start": "2025-08-13T12:34:56Z", + "x.com.samsung.da.realSaving": "On", + "href": "/drlc/vs/0" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "263", + "x.com.samsung.da.instantaneousPower": "1", + "x.com.samsung.da.cumulativePower": "801", + "x.com.samsung.da.cumulativeSavedPower": "30", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "href": "/energy/consumption/vs/0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00", + "x.com.samsung.supprtedtype": 1, + "href": "/file/information/vs/0" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "href": "/refrigeration/vs/0" + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": ["1"], + "href": "/energy/ailevel/vs/0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "08174EAY700355P", + "x.com.samsung.da.otnDUID": "EXCCN6NY7KZ4W", + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RO0", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "250706", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2408090D, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "href": "/information/vs/0" + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.childlock": "Locked", + "href": "/status/lock/vs/0" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": ["RVACATION_OFF"], + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ], + "href": "/mode/vs/0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true", + "href": "/realtimenotiforclient/vs/0" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0, + "href": "/runningmode/vs/0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": ["Start"], + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": ["ErrorCode_None"], + "href": "/selfcheck/vs/0" + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "temperature": 3.0, + "range": [1.0, 7.0], + "units": "C", + "href": "/temperature/desired/cooler/0" + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "temperature": 3.0, + "range": [1.0, 7.0], + "units": "C", + "href": "/temperature/current/cooler/0" + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "3", + "x.com.samsung.da.current": "3", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ], + "href": "/temperatures/vs/0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Refrigerator", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-24-T4-COM", + "versions": ["20250706"], + "visVersion": "250706" + }, + { + "type": "Micom", + "modelId": "028100130445FFFFFFFF", + "versions": ["2408090D", "FFFFFFFF"], + "visVersion": "240809" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Warsaw", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": ["helper_hotspot", "ble_ocf"], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": ["2.4G"], + "calmConnectionCare": { + "version": "1.0", + "role": ["things"] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "34:FC:99:0A:67:55", + "macaddressBLE": "34:FC:99:0A:67:56" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] + }, + "timestamp": "2025-08-14T03:17:25.764Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "custom.waterFilter", + "custom.dustFilter", + "samsungce.viewInside", + "samsungce.fridgeWelcomeLighting", + "sec.smartthingsHub", + "samsungce.powerFreeze", + "samsungce.sabbathMode" + ], + "timestamp": "2025-08-13T17:29:03.375Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25060101, + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "RO0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker", + "icemaker-02", + "icemaker-03", + "pantry-01", + "pantry-02", + "specialzone-01", + "scale-10", + "scale-11", + "cooler", + "freezer", + "cvroom" + ], + "timestamp": "2025-08-12T12:36:05.791Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 2, + "start": "2025-08-14T07:24:20Z", + "duration": 1441, + "override": false + }, + "timestamp": "2025-08-14T07:24:23.569Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 861, + "deltaEnergy": 0, + "power": 1, + "powerEnergy": 0.27936416665712993, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 35, + "start": "2025-08-14T07:04:50Z", + "end": "2025-08-14T07:21:35Z" + }, + "timestamp": "2025-08-14T07:21:35.717Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": "passed", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "250706", + "description": "WiFi Module" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "2408090D, FFFFFFFF", + "description": "Micom" + } + ], + "timestamp": "2025-08-12T13:21:17.127Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null + }, + "dustFilterUsage": { + "value": null + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": null + }, + "dustFilterCapacity": { + "value": null + }, + "dustFilterResetType": { + "value": null + } + }, + "refrigeration": { + "defrost": { + "value": null + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-08-12T14:27:24.223Z" + }, + "rapidFreezing": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-08-12T14:27:24.223Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-08-13T12:50:26.756Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-08-12T12:36:06.104Z" + }, + "energySavingLevel": { + "value": 1, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": [1], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "energySavingOperation": { + "value": true, + "timestamp": "2025-08-14T07:24:28.808Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-08-12T13:14:52.642Z" + }, + "otnDUID": { + "value": "EXCCN6NY7KZ4W", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-08-12T13:14:52.642Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-08-12T13:08:24.243Z" + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": null + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "specialzone-01": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + } + }, + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-08-14T05:31:51.945Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2025-08-12T12:36:06.177Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-13T19:45:46.907Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-08-12T12:36:06.031Z" + }, + "coolingSetpoint": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-13T19:43:49.744Z" + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.temperatureSetting", "custom.fridgeMode"], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 21, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + }, + "coolingSetpoint": { + "value": 3, + "unit": "C", + "timestamp": "2025-08-12T12:36:03.771Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFullFridgeModes": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2025-08-12T12:36:03.771Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.freezerConvertMode"], + "timestamp": "2025-08-12T12:42:30.879Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "samsungce.fridgeIcemakerInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json new file mode 100644 index 00000000000..2669768b719 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011_onedoor.json @@ -0,0 +1,588 @@ +{ + "items": [ + { + "deviceId": "271d82e0-5b0c-e4b8-058e-cdf23a188610", + "name": "Samsung-Refrigerator", + "label": "Lod\u00f3wka", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "5274d210-9bd8-4a14-ae55-52a9ffeedfb7", + "ownerId": "d40034d0-c87b-3fa6-da98-108c42c36a6b", + "roomId": "b19fa610-62f8-4109-b9cc-47f85fcefd29", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.driverState", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.fridgeWelcomeLighting", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.fridgeIcemakerInfo", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "specialzone-01", + "label": "specialzone-01", + "capabilities": [ + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-08-12T12:35:56.924Z", + "profile": { + "id": "840ff773-857b-324b-a54e-ba31a8155c4d" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Samsung-Refrigerator", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_REF_21K|00130445|00090026001610304100000021010000", + "platformVersion": "SYSTEM 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "A-RFWW-TP1-24-T4-COM_20250706", + "vendorId": "DA-REF-NORMAL-01011", + "vendorResourceClientServerVersion": "MediaTek Release 250706", + "lastSignupTime": "2025-08-12T12:35:56.864318132Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "RR39C7EC5B1/EF" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 7be4d3af55b..4637de49efb 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -1169,6 +1169,55 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][binary_sensor.lodowka_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.lodowka_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][binary_sensor.lodowka_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Lodówka Door', + }), + 'context': , + 'entity_id': 'binary_sensor.lodowka_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 17a9d6691cc..5fd7040f61b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -684,6 +684,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ref_normal_01011_onedoor] + 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': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '271d82e0-5b0c-e4b8-058e-cdf23a188610', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_REF_21K', + 'model_id': None, + 'name': 'Lodówka', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': 'A-RFWW-TP1-24-T4-COM_20250706', + 'via_device_id': None, + }) +# --- # name: test_devices[da_rvc_map_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 169359118da..8771ed505cf 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6066,6 +6066,288 @@ 'state': '97', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.lodowka_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.861', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.lodowka_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.lodowka_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.lodowka_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Lodówka Power', + 'power_consumption_end': '2025-08-14T07:21:35Z', + 'power_consumption_start': '2025-08-14T07:04:50Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.lodowka_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][sensor.lodowka_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Lodówka Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lodowka_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.00027936416665713', + }) +# --- # name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 1aaeb35205f..6512e88998b 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -623,6 +623,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011_onedoor][switch.lodowka_power_cool-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lodowka_power_cool', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power cool', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_cool', + 'unique_id': '271d82e0-5b0c-e4b8-058e-cdf23a188610_main_samsungce.powerCool_activated_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011_onedoor][switch.lodowka_power_cool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lodówka Power cool', + }), + 'context': , + 'entity_id': 'switch.lodowka_power_cool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_rvc_map_01011][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 382e7dfd39f4c8f5656892499f798eb5823b8570 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:51:43 +0200 Subject: [PATCH 28/73] Add Tuya test fixtures (#150622) --- tests/components/tuya/__init__.py | 4 + .../tuya/fixtures/cz_fencxse0bnut96ig.json | 100 ++++ .../tuya/fixtures/cz_ipabufmlmodje1ws.json | 165 ++++++ .../tuya/fixtures/cz_z6pht25s3p0gs26q.json | 207 +++++++ .../tuya/fixtures/wk_ccpwojhalfxryigz.json | 121 ++++ .../tuya/snapshots/test_climate.ambr | 65 +++ .../components/tuya/snapshots/test_init.ambr | 124 +++++ .../tuya/snapshots/test_number.ambr | 58 ++ .../tuya/snapshots/test_select.ambr | 59 ++ .../tuya/snapshots/test_sensor.ambr | 522 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 244 ++++++++ 11 files changed, 1669 insertions(+) create mode 100644 tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json create mode 100644 tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json create mode 100644 tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json create mode 100644 tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index c48c99da9fa..537fc98854a 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -41,11 +41,13 @@ DEVICE_MOCKS = [ "cz_anwgf2xugjxpkfxb", # https://github.com/orgs/home-assistant/discussions/539 "cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704 "cz_dntgh2ngvshfxpsz", # https://github.com/home-assistant/core/issues/149704 + "cz_fencxse0bnut96ig", # https://github.com/home-assistant/core/issues/63978 "cz_gbtxrqfy9xcsakyp", # https://github.com/home-assistant/core/issues/141278 "cz_gjnozsaz", # https://github.com/orgs/home-assistant/discussions/482 "cz_hA2GsgMfTQFTz9JL", # https://github.com/home-assistant/core/issues/148347 "cz_hj0a5c7ckzzexu8l", # https://github.com/home-assistant/core/issues/149704 "cz_ik9sbig3mthx9hjz", # https://github.com/home-assistant/core/issues/141278 + "cz_ipabufmlmodje1ws", # https://github.com/home-assistant/core/issues/63978 "cz_jnbbxsb84gvvyfg5", # https://github.com/tuya/tuya-home-assistant/issues/754 "cz_n8iVBAPLFKAAAszH", # https://github.com/home-assistant/core/issues/146164 "cz_nkb0fmtlfyqosnvk", # https://github.com/orgs/home-assistant/discussions/482 @@ -61,6 +63,7 @@ DEVICE_MOCKS = [ "cz_wifvoilfrqeo6hvu", # https://github.com/home-assistant/core/issues/146164 "cz_wrz6vzch8htux2zp", # https://github.com/home-assistant/core/issues/141278 "cz_y4jnobxh", # https://github.com/orgs/home-assistant/discussions/482 + "cz_z6pht25s3p0gs26q", # https://github.com/home-assistant/core/issues/63978 "dc_l3bpgg8ibsagon4x", # https://github.com/home-assistant/core/issues/149704 "dj_0gyaslysqfp4gfis", # https://github.com/home-assistant/core/issues/149895 "dj_8szt7whdvwpmxglk", # https://github.com/home-assistant/core/issues/149704 @@ -167,6 +170,7 @@ DEVICE_MOCKS = [ "wg2_v7owd9tzcaninc36", # https://github.com/orgs/home-assistant/discussions/539 "wk_6kijc7nd", # https://github.com/home-assistant/core/issues/136513 "wk_aqoouq7x", # https://github.com/home-assistant/core/issues/146263 + "wk_ccpwojhalfxryigz", # https://github.com/home-assistant/core/issues/145551 "wk_fi6dne5tu4t1nm6j", # https://github.com/orgs/home-assistant/discussions/243 "wk_gogb05wrtredz3bs", # https://github.com/home-assistant/core/issues/136337 "wk_y5obtqhuztqsf2mj", # https://github.com/home-assistant/core/issues/139735 diff --git a/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json b/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json new file mode 100644 index 00000000000..a244a6c8bcb --- /dev/null +++ b/tests/components/tuya/fixtures/cz_fencxse0bnut96ig.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "46", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Spa", + "model": "JLCZ8266", + "category": "cz", + "product_id": "fencxse0bnut96ig", + "product_name": "smart plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-28T07:32:38+00:00", + "create_time": "2022-01-28T07:32:38+00:00", + "update_time": "2022-01-28T07:32:52+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 203, + "cur_current": 5404, + "cur_power": 12018, + "cur_voltage": 2381 + } +} diff --git a/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json b/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json new file mode 100644 index 00000000000..5edf6500132 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_ipabufmlmodje1ws.json @@ -0,0 +1,165 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "46", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "V\u00e4rmelampa", + "model": "FK-PW802EC-F", + "category": "cz", + "product_id": "ipabufmlmodje1ws", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-22T12:55:50+00:00", + "create_time": "2022-01-21T20:32:42+00:00", + "update_time": "2022-01-22T12:55:53+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 82, + "cur_current": 435, + "cur_power": 1642, + "cur_voltage": 2246, + "test_bit": 1, + "voltage_coe": 632, + "electric_coe": 10795, + "power_coe": 10197, + "electricity_coe": 4090 + } +} diff --git a/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json b/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json new file mode 100644 index 00000000000..01caa14439a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_z6pht25s3p0gs26q.json @@ -0,0 +1,207 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "tuyaSmart", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "6294HA", + "model": "", + "category": "cz", + "product_id": "z6pht25s3p0gs26q", + "product_name": "6294HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-20T11:03:22+00:00", + "create_time": "2022-01-10T01:30:10+00:00", + "update_time": "2022-01-20T11:03:22+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 0, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "switch_2": false, + "countdown_1": 0, + "countdown_2": 0, + "add_ele": 201, + "cur_current": 5466, + "cur_power": 11374, + "cur_voltage": 2396, + "test_bit": 2, + "voltage_coe": 0, + "electric_coe": 0, + "power_coe": 0, + "electricity_coe": 0, + "relay_status": "power_on", + "child_lock": false + } +} diff --git a/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json b/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json new file mode 100644 index 00000000000..ed489927c1e --- /dev/null +++ b/tests/components/tuya/fixtures/wk_ccpwojhalfxryigz.json @@ -0,0 +1,121 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Boiler Temperature Controller", + "category": "wk", + "product_id": "ccpwojhalfxryigz", + "product_name": "Intelligent temperature controller", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-24T19:20:54+00:00", + "create_time": "2024-01-24T19:20:54+00:00", + "update_time": "2024-01-24T19:20:54+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["e1", "e2", "e3"] + } + } + }, + "status": { + "switch": true, + "work_state": "hot", + "upper_temp": 585, + "temp_current": 575, + "lower_temp": 600, + "temp_correction": -8, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 270eeef1577..445fb3f8cc6 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,71 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.boiler_temperature_controller-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.boiler_temperature_controller', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.zgiyrxflahjowpcckw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.boiler_temperature_controller-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 57.5, + 'friendly_name': 'Boiler Temperature Controller', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'context': , + 'entity_id': 'climate.boiler_temperature_controller', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_platform_setup_and_discovery[climate.clima_cucina-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 52ff63dac36..4491ce180ac 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -2324,6 +2324,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[gi69tunb0esxcnefzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'gi69tunb0esxcnefzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'smart plug', + 'model_id': 'fencxse0bnut96ig', + 'name': 'Spa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gjnpc0eojd] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4122,6 +4153,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[q62sg0p3s52thp6zzc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'q62sg0p3s52thp6zzc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '6294HA', + 'model_id': 'z6pht25s3p0gs26q', + 'name': '6294HA', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[q8dncqpgin4yympisc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4649,6 +4711,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[sw1ejdomlmfubapizc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'sw1ejdomlmfubapizc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '', + 'model_id': 'ipabufmlmodje1ws', + 'name': 'Värmelampa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[swhtzki3qrz5ydchjboc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5610,6 +5703,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[zgiyrxflahjowpcckw] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'zgiyrxflahjowpcckw', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Intelligent temperature controller', + 'model_id': 'ccpwojhalfxryigz', + 'name': 'Boiler Temperature Controller', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[zjh9xhtm3gibs9kizc] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index b1007431046..1aa8c3dcca9 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -58,6 +58,64 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[number.boiler_temperature_controller_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.boiler_temperature_controller_temperature_correction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.zgiyrxflahjowpcckwtemp_correction', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.boiler_temperature_controller_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Boiler Temperature Controller Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.boiler_temperature_controller_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.8', + }) +# --- # name: test_platform_setup_and_discovery[number.c9_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 4e701b39566..08928a6440c 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -176,6 +176,65 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[select.6294ha_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.6294ha_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.6294ha_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '6294HA Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.6294ha_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- # name: test_platform_setup_and_discovery[select.aqi_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 0d113454d4d..b8e84328e1f 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -173,6 +173,180 @@ 'state': '231.9', }) # --- +# name: test_platform_setup_and_discovery[sensor.6294ha_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.6294ha_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.q62sg0p3s52thp6zzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '6294HA Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.6294ha_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.466', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.6294ha_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.q62sg0p3s52thp6zzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '6294HA Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.6294ha_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11374.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.6294ha_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.q62sg0p3s52thp6zzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.6294ha_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '6294HA Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.6294ha_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aqi_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11549,6 +11723,180 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.spa_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.spa_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.gi69tunb0esxcnefzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Spa Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spa_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.404', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.spa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.gi69tunb0esxcnefzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Spa Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.spa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1201.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.spa_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.gi69tunb0esxcnefzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.spa_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Spa Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spa_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '238.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.steel_cage_door_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -12275,6 +12623,180 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.varmelampa_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.sw1ejdomlmfubapizccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Värmelampa Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.varmelampa_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.435', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.varmelampa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.sw1ejdomlmfubapizccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Värmelampa Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.varmelampa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1642.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.varmelampa_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.sw1ejdomlmfubapizccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.varmelampa_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Värmelampa Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.varmelampa_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '224.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 96d88a967ff..2e5d1066fef 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -292,6 +292,152 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.6294ha_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.6294ha_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '6294HA Child lock', + }), + 'context': , + 'entity_id': 'switch.6294ha_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.6294ha_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '6294HA Socket 1', + }), + 'context': , + 'entity_id': 'switch.6294ha_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.6294ha_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.q62sg0p3s52thp6zzcswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.6294ha_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '6294HA Socket 2', + }), + 'context': , + 'entity_id': 'switch.6294ha_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.ac_charging_control_box_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6872,6 +7018,55 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.spa_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.spa_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.gi69tunb0esxcnefzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spa_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Spa Socket 1', + }), + 'context': , + 'entity_id': 'switch.spa_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.spot_1_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7705,6 +7900,55 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.varmelampa_socket_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.varmelampa_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.sw1ejdomlmfubapizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.varmelampa_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Värmelampa Socket 1', + }), + 'context': , + 'entity_id': 'switch.varmelampa_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.wallwasher_front_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3eecfa8e57c29241827e6fda21af208ca180b0a9 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:36:04 +0200 Subject: [PATCH 29/73] Set PARALLEL_UPDATES in NINA (#150635) --- homeassistant/components/nina/binary_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index be37a802d47..cfbdd87a0e2 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -52,6 +52,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 0 + + class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): """Representation of an NINA warning.""" From d9b6f826391d95dfa772dfb71bc5405f2992fd6d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 14 Aug 2025 16:37:44 +0200 Subject: [PATCH 30/73] Add Z-Wave Fortrezz SSA2 discovery (#150629) --- .../components/zwave_js/discovery.py | 2 +- tests/components/zwave_js/conftest.py | 14 + .../fixtures/fortrezz_ssa2_siren_state.json | 447 ++++++++++++++++++ tests/components/zwave_js/test_discovery.py | 10 + 4 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 25c342cf87d..7030009f5ad 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -760,7 +760,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SELECT, hint="multilevel_switch", manufacturer_id={0x0084}, - product_id={0x0107, 0x0108, 0x010B, 0x0205}, + product_id={0x0107, 0x0108, 0x0109, 0x010B, 0x0205}, product_type={0x0311, 0x0313, 0x0331, 0x0341, 0x0343}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=BaseDiscoverySchemaDataTemplate( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index eef92a7eb0a..f60c0169055 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -427,6 +427,12 @@ def fortrezz_ssa1_siren_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fortrezz_ssa1_siren_state.json", DOMAIN) +@pytest.fixture(name="fortrezz_ssa2_siren_state", scope="package") +def fortrezz_ssa2_siren_state_fixture() -> dict[str, Any]: + """Load the fortrezz ssa2 siren node state fixture data.""" + return load_json_object_fixture("fortrezz_ssa2_siren_state.json", DOMAIN) + + @pytest.fixture(name="fortrezz_ssa3_siren_state", scope="package") def fortrezz_ssa3_siren_state_fixture() -> dict[str, Any]: """Load the fortrezz ssa3 siren node state fixture data.""" @@ -1218,6 +1224,14 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state) -> Node: return node +@pytest.fixture(name="fortrezz_ssa2_siren") +def fortrezz_ssa2_siren_fixture(client, fortrezz_ssa2_siren_state) -> Node: + """Mock a fortrezz ssa2 siren node.""" + node = Node(client, copy.deepcopy(fortrezz_ssa2_siren_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="fortrezz_ssa3_siren") def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state) -> Node: """Mock a fortrezz ssa3 siren node.""" diff --git a/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json b/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json new file mode 100644 index 00000000000..6fc7a89046e --- /dev/null +++ b/tests/components/zwave_js/fixtures/fortrezz_ssa2_siren_state.json @@ -0,0 +1,447 @@ +{ + "nodeId": 30, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 132, + "productId": 265, + "productType": 785, + "firmwareVersion": "1.9", + "deviceConfig": { + "filename": "/data/db/devices/0x0084/ssa1_ssa2.json", + "isEmbedded": true, + "manufacturer": "FortrezZ LLC", + "manufacturerId": 132, + "label": "SSA1/SSA2", + "description": "Siren and Strobe Alarm", + "devices": [ + { + "productType": 785, + "productId": 267 + }, + { + "productType": 787, + "productId": 264 + }, + { + "productType": 787, + "productId": 267 + }, + { + "productType": 785, + "productId": 265 + }, + { + "productType": 787, + "productId": 265 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + } + }, + "label": "SSA1/SSA2", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0084:0x0311:0x0109:1.9", + "statistics": { + "commandsTX": 23, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 22, + "rtt": 50.7, + "lastSeen": "2025-06-10T03:23:40.329Z" + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-06-04T08:00:19.437Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Delay Before Accept of Basic Set Off", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay, from the time the siren-strobe turns on", + "label": "Delay Before Accept of Basic Set Off", + "default": 0, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 265 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 785 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 132 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.9"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "2.97" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + } + ], + "endpoints": [ + { + "nodeId": 30, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 6a4752d536b..299c003aefe 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -157,6 +157,16 @@ async def test_lock_popp_electric_strike_lock_control( assert hass.states.get("select.node_62_current_lock_mode") is not None +async def test_fortrez_ssa2_siren( + hass: HomeAssistant, + client: MagicMock, + fortrezz_ssa2_siren: Node, + integration: MockConfigEntry, +) -> None: + """Test Fortrezz SSA2 siren gets discovered correctly.""" + assert hass.states.get("select.siren_and_strobe_alarm") is not None + + async def test_fortrez_ssa3_siren( hass: HomeAssistant, client, fortrezz_ssa3_siren, integration ) -> None: From 2248584a0fadfa2a55c14450d34e2bd6bd6bbc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Thu, 14 Aug 2025 17:07:18 +0200 Subject: [PATCH 31/73] Add Matter Electrical measurements additional attributes (#150188) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/sensor.py | 104 ++++ homeassistant/components/matter/strings.json | 18 + .../fixtures/nodes/silabs_dishwasher.json | 22 - .../fixtures/nodes/silabs_laundrywasher.json | 22 - .../matter/snapshots/test_sensor.ambr | 554 +++++++++--------- 5 files changed, 399 insertions(+), 321 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 9e2ef33167b..af7dd385fd0 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -32,11 +32,13 @@ from homeassistant.const import ( REVOLUTIONS_PER_MINUTE, EntityCategory, Platform, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfPressure, + UnitOfReactivePower, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -782,10 +784,43 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalPowerMeasurement.Attributes.ActivePower, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementApparentPower", + device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfApparentPower.MILLIVOLT_AMPERE, + suggested_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ApparentPower, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementReactivePower", + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + suggested_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ReactivePower, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementVoltage", + translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, @@ -796,10 +831,45 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementRMSVoltage", + translation_key="rms_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.RMSVoltage, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementApparentCurrent", + translation_key="apparent_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ApparentCurrent, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="ElectricalPowerMeasurementActiveCurrent", + translation_key="active_current", device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, @@ -812,6 +882,40 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementReactiveCurrent", + translation_key="reactive_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ReactiveCurrent, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementRMSCurrent", + translation_key="rms_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.RMSCurrent, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 6355ebfbee6..8cd2fdf6adf 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -451,6 +451,24 @@ }, "window_covering_target_position": { "name": "Target opening position" + }, + "active_current": { + "name": "Active current" + }, + "apparent_current": { + "name": "Apparent current" + }, + "reactive_current": { + "name": "Reactive current" + }, + "rms_current": { + "name": "Effective current" + }, + "rms_voltage": { + "name": "Effective voltage" + }, + "voltage": { + "name": "Voltage" } }, "switch": { diff --git a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json index d0efcc7e004..fa66f4dfeef 100644 --- a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json @@ -588,31 +588,9 @@ "10": 101 } ], - "2/144/4": 120000, - "2/144/5": 0, - "2/144/6": 0, - "2/144/7": 0, "2/144/8": 0, - "2/144/9": 0, - "2/144/10": 0, "2/144/11": 120000, "2/144/12": 0, - "2/144/13": 0, - "2/144/14": 60, - "2/144/15": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/16": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/17": 9800, - "2/144/18": 0, "2/144/65532": 31, "2/144/65533": 1, "2/144/65528": [], diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index 3b1ed0043de..93ba7e2e026 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -832,31 +832,9 @@ "10": 129 } ], - "2/144/4": 120000, - "2/144/5": 0, - "2/144/6": 0, - "2/144/7": 0, "2/144/8": 0, - "2/144/9": 0, - "2/144/10": 0, "2/144/11": 120000, "2/144/12": 0, - "2/144/13": 0, - "2/144/14": 60, - "2/144/15": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/16": [ - { - "0": 1, - "1": 100000 - } - ], - "2/144/17": 9800, - "2/144/18": 0, "2/144/65532": 31, "2/144/65533": 1, "2/144/65528": [], diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index bff4ad7909d..b7aff460d77 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1251,6 +1251,65 @@ 'state': '189.0', }) # --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_active_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.mock_battery_storage_active_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_current', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_active_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Mock Battery Storage Active current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_active_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1427,65 +1486,6 @@ 'state': '48.0', }) # --- -# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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': , - 'entity_id': 'sensor.mock_battery_storage_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Mock Battery Storage Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.mock_battery_storage_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1763,7 +1763,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -2176,7 +2176,7 @@ 'state': '238.800003051758', }) # --- -# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_current-entry] +# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2191,7 +2191,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'entity_id': 'sensor.eve_energy_plug_patched_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2209,26 +2209,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', 'unit_of_measurement': , }) # --- -# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_current-state] +# name: test_sensors[eve_energy_plug_patched][sensor.eve_energy_plug_patched_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Eve Energy Plug Patched Current', + 'friendly_name': 'Eve Energy Plug Patched Active current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'entity_id': 'sensor.eve_energy_plug_patched_active_current', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2391,7 +2391,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -4333,7 +4333,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_current-entry] +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4348,7 +4348,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.dishwasher_current', + 'entity_id': 'sensor.dishwasher_effective_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4366,32 +4366,91 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Effective current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'translation_key': 'rms_current', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementRMSCurrent-144-12', 'unit_of_measurement': , }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_current-state] +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Dishwasher Current', + 'friendly_name': 'Dishwasher Effective current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_current', + 'entity_id': 'sensor.dishwasher_effective_current', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.dishwasher_effective_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Effective voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_voltage', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementRMSVoltage-144-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_dishwasher][sensor.dishwasher_effective_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Dishwasher Effective voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_effective_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[silabs_dishwasher][sensor.dishwasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4574,65 +4633,6 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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': , - 'entity_id': 'sensor.dishwasher_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_dishwasher][sensor.dishwasher_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Dishwasher Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dishwasher_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '120.0', - }) -# --- # name: test_sensors[silabs_evse_charging][sensor.evse_appliance_energy_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5134,65 +5134,6 @@ 'state': '32.0', }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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': , - 'entity_id': 'sensor.laundrywasher_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'LaundryWasher Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.laundrywasher_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5253,6 +5194,124 @@ 'state': 'pre-soak', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.laundrywasher_effective_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Effective current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_current', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementRMSCurrent-144-12', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LaundryWasher Effective current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_effective_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.laundrywasher_effective_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Effective voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rms_voltage', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementRMSVoltage-144-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_effective_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LaundryWasher Effective voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_effective_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5433,7 +5492,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-entry] +# name: test_sensors[silabs_water_heater][sensor.water_heater_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5448,7 +5507,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.laundrywasher_voltage', + 'entity_id': 'sensor.water_heater_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5458,38 +5517,38 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', - 'unit_of_measurement': , + 'translation_key': 'active_current', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , }) # --- -# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-state] +# name: test_sensors[silabs_water_heater][sensor.water_heater_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'LaundryWasher Voltage', + 'device_class': 'current', + 'friendly_name': 'Water Heater Active current', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.laundrywasher_voltage', + 'entity_id': 'sensor.water_heater_active_current', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '120.0', + 'state': '0.1', }) # --- # name: test_sensors[silabs_water_heater][sensor.water_heater_appliance_energy_state-entry] @@ -5556,65 +5615,6 @@ 'state': 'online', }) # --- -# name: test_sensors[silabs_water_heater][sensor.water_heater_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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': , - 'entity_id': 'sensor.water_heater_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[silabs_water_heater][sensor.water_heater_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Water Heater Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.water_heater_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }) -# --- # name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5941,7 +5941,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) @@ -6122,7 +6122,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[solar_power][sensor.solarpower_current-entry] +# name: test_sensors[solar_power][sensor.solarpower_active_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6137,7 +6137,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.solarpower_current', + 'entity_id': 'sensor.solarpower_active_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6155,26 +6155,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current', + 'original_name': 'Active current', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', 'unit_of_measurement': , }) # --- -# name: test_sensors[solar_power][sensor.solarpower_current-state] +# name: test_sensors[solar_power][sensor.solarpower_active_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'SolarPower Current', + 'friendly_name': 'SolarPower Active current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.solarpower_current', + 'entity_id': 'sensor.solarpower_active_current', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6337,7 +6337,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', 'unit_of_measurement': , }) From 6e98446523e4ccdadd9ab6bebfb8e84610c2b3c8 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 14 Aug 2025 12:24:43 -0400 Subject: [PATCH 32/73] Media player API enumeration alignment and feature flags (#149597) Co-authored-by: J. Nick Koston --- .../components/esphome/media_player.py | 40 ++++++-- tests/components/esphome/test_media_player.py | 91 +++++++++++++++++++ 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 2d43d40bfb3..e7fcd84d299 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -10,6 +10,7 @@ from urllib.parse import urlparse from aioesphomeapi import ( EntityInfo, MediaPlayerCommand, + MediaPlayerEntityFeature as EspMediaPlayerEntityFeature, MediaPlayerEntityState, MediaPlayerFormatPurpose, MediaPlayerInfo, @@ -53,6 +54,31 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM } ) +_FEATURES = { + EspMediaPlayerEntityFeature.PAUSE: MediaPlayerEntityFeature.PAUSE, + EspMediaPlayerEntityFeature.SEEK: MediaPlayerEntityFeature.SEEK, + EspMediaPlayerEntityFeature.VOLUME_SET: MediaPlayerEntityFeature.VOLUME_SET, + EspMediaPlayerEntityFeature.VOLUME_MUTE: MediaPlayerEntityFeature.VOLUME_MUTE, + EspMediaPlayerEntityFeature.PREVIOUS_TRACK: MediaPlayerEntityFeature.PREVIOUS_TRACK, + EspMediaPlayerEntityFeature.NEXT_TRACK: MediaPlayerEntityFeature.NEXT_TRACK, + EspMediaPlayerEntityFeature.TURN_ON: MediaPlayerEntityFeature.TURN_ON, + EspMediaPlayerEntityFeature.TURN_OFF: MediaPlayerEntityFeature.TURN_OFF, + EspMediaPlayerEntityFeature.PLAY_MEDIA: MediaPlayerEntityFeature.PLAY_MEDIA, + EspMediaPlayerEntityFeature.VOLUME_STEP: MediaPlayerEntityFeature.VOLUME_STEP, + EspMediaPlayerEntityFeature.SELECT_SOURCE: MediaPlayerEntityFeature.SELECT_SOURCE, + EspMediaPlayerEntityFeature.STOP: MediaPlayerEntityFeature.STOP, + EspMediaPlayerEntityFeature.CLEAR_PLAYLIST: MediaPlayerEntityFeature.CLEAR_PLAYLIST, + EspMediaPlayerEntityFeature.PLAY: MediaPlayerEntityFeature.PLAY, + EspMediaPlayerEntityFeature.SHUFFLE_SET: MediaPlayerEntityFeature.SHUFFLE_SET, + EspMediaPlayerEntityFeature.SELECT_SOUND_MODE: MediaPlayerEntityFeature.SELECT_SOUND_MODE, + EspMediaPlayerEntityFeature.BROWSE_MEDIA: MediaPlayerEntityFeature.BROWSE_MEDIA, + EspMediaPlayerEntityFeature.REPEAT_SET: MediaPlayerEntityFeature.REPEAT_SET, + EspMediaPlayerEntityFeature.GROUPING: MediaPlayerEntityFeature.GROUPING, + EspMediaPlayerEntityFeature.MEDIA_ANNOUNCE: MediaPlayerEntityFeature.MEDIA_ANNOUNCE, + EspMediaPlayerEntityFeature.MEDIA_ENQUEUE: MediaPlayerEntityFeature.MEDIA_ENQUEUE, + EspMediaPlayerEntityFeature.SEARCH_MEDIA: MediaPlayerEntityFeature.SEARCH_MEDIA, +} + ATTR_BYPASS_PROXY = "bypass_proxy" @@ -67,16 +93,12 @@ class EsphomeMediaPlayer( def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" super()._on_static_info_update(static_info) - flags = ( - MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.BROWSE_MEDIA - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + esp_flags = EspMediaPlayerEntityFeature( + self._static_info.feature_flags_compat(self._api_version) ) - if self._static_info.supports_pause: - flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY + flags = MediaPlayerEntityFeature(0) + for espflag in esp_flags: + flags |= _FEATURES[espflag] self._attr_supported_features = flags self._entry_data.media_player_formats[self.unique_id] = cast( MediaPlayerInfo, static_info diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 232f7e1f06e..efc060bb136 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -29,6 +29,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, + STATE_PLAYING, BrowseMedia, MediaClass, MediaType, @@ -56,6 +57,8 @@ async def test_media_player_entity( key=1, name="my media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, ) ] states = [ @@ -156,6 +159,88 @@ async def test_media_player_entity( mock_client.media_player_command.reset_mock() +async def test_media_player_entity_with_undefined_flags( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that media_player handles undefined feature flags gracefully.""" + # Include existing flags (PAUSE=1, PLAY=16384, VOLUME_SET=4) + # plus undefined bits (bit 6=64, bit 23=8388608) + # Total: 1 + 16384 + 4 + 64 + 8388608 = 8405061 + entity_info = [ + MediaPlayerInfo( + object_id="mymedia_player_undefined", + key=1, + name="my media_player undefined", + supports_pause=True, + # PAUSE,PLAY,VOLUME_SET + undefined bits 6 and 23 + feature_flags=8405061, + ) + ] + states = [ + MediaPlayerEntityState( + key=1, volume=50, muted=False, state=MediaPlayerState.PLAYING + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + # Verify entity is created successfully despite undefined flags + state = hass.states.get("media_player.test_my_media_player_undefined") + assert state is not None + assert state.state == STATE_PLAYING + + # Verify supported features only include known flags + # Should have PAUSE, PLAY, and VOLUME_SET + supported_features = state.attributes.get("supported_features", 0) + # PAUSE=1, VOLUME_SET=4, PLAY=16384 = 16389 + assert supported_features == 16389 + + # Verify entity works correctly with known features + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PLAY, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.PAUSE, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player_undefined", + ATTR_MEDIA_VOLUME_LEVEL: 0.7, + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, volume=0.7, device_id=0)] + ) + + async def test_media_player_entity_with_source( hass: HomeAssistant, mock_client: APIClient, @@ -202,6 +287,8 @@ async def test_media_player_entity_with_source( key=1, name="my media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, ) ] states = [ @@ -317,6 +404,8 @@ async def test_media_player_proxy( key=1, name="my media_player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, supported_formats=[ MediaPlayerSupportedFormat( format="flac", @@ -475,6 +564,8 @@ async def test_media_player_formats_reload_preserves_data( key=1, name="Test Media Player", supports_pause=True, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY + feature_flags=1200653, supported_formats=supported_formats, ) ], From 1ea740d81cf974be3a5b3421d84751b9b2d4fbd9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 14 Aug 2025 13:07:01 -0400 Subject: [PATCH 33/73] Add media_player add off on capability to esphome (#147990) --- .../components/esphome/media_player.py | 20 ++++++++++++ tests/components/esphome/test_media_player.py | 31 +++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index e7fcd84d299..a35d93c9fe1 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -51,6 +51,8 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM EspMediaPlayerState.IDLE: MediaPlayerState.IDLE, EspMediaPlayerState.PLAYING: MediaPlayerState.PLAYING, EspMediaPlayerState.PAUSED: MediaPlayerState.PAUSED, + EspMediaPlayerState.OFF: MediaPlayerState.OFF, + EspMediaPlayerState.ON: MediaPlayerState.ON, } ) @@ -279,6 +281,24 @@ class EsphomeMediaPlayer( device_id=self._static_info.device_id, ) + @convert_api_error_ha_error + async def async_turn_on(self) -> None: + """Send turn on command.""" + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.TURN_ON, + device_id=self._static_info.device_id, + ) + + @convert_api_error_ha_error + async def async_turn_off(self) -> None: + """Send turn off command.""" + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.TURN_OFF, + device_id=self._static_info.device_id, + ) + def _is_url(url: str) -> bool: """Validate the URL can be parsed and at least has scheme + netloc.""" diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index efc060bb136..b5805298b97 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -27,6 +27,8 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, STATE_PLAYING, @@ -57,8 +59,8 @@ async def test_media_player_entity( key=1, name="my media_player", supports_pause=True, - # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY - feature_flags=1200653, + # PLAY_MEDIA,BROWSE_MEDIA,STOP,VOLUME_SET,VOLUME_MUTE,MEDIA_ANNOUNCE,PAUSE,PLAY,TURN_OFF,TURN_ON + feature_flags=1201037, ) ] states = [ @@ -158,6 +160,31 @@ async def test_media_player_entity( ) mock_client.media_player_command.reset_mock() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.TURN_OFF, device_id=0)] + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "media_player.test_my_media_player", + }, + blocking=True, + ) + mock_client.media_player_command.assert_has_calls( + [call(1, command=MediaPlayerCommand.TURN_ON, device_id=0)] + ) + mock_client.media_player_command.reset_mock() + async def test_media_player_entity_with_undefined_flags( hass: HomeAssistant, From 9c21965a3430d7141b02c2f9eba5373d97b5c2ea Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 14 Aug 2025 19:57:33 +0200 Subject: [PATCH 34/73] Add diagnostics to NINA (#150638) --- homeassistant/components/nina/diagnostics.py | 24 ++++++++++ .../nina/snapshots/test_diagnostics.ambr | 45 +++++++++++++++++++ tests/components/nina/test_diagnostics.py | 45 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 homeassistant/components/nina/diagnostics.py create mode 100644 tests/components/nina/snapshots/test_diagnostics.ambr create mode 100644 tests/components/nina/test_diagnostics.py diff --git a/homeassistant/components/nina/diagnostics.py b/homeassistant/components/nina/diagnostics.py new file mode 100644 index 00000000000..f62b7b6bcec --- /dev/null +++ b/homeassistant/components/nina/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics for the Nina integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import NinaConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NinaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + runtime_data_dict = { + region_key: [asdict(warning) for warning in region_data] + for region_key, region_data in entry.runtime_data.data.items() + } + + return { + "entry_data": dict(entry.data), + "data": runtime_data_dict, + } diff --git a/tests/components/nina/snapshots/test_diagnostics.ambr b/tests/components/nina/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..aaf42471912 --- /dev/null +++ b/tests/components/nina/snapshots/test_diagnostics.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + '083350000000': list([ + dict({ + 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', + 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', + 'expires': '3021-11-22T05:19:00+01:00', + 'headline': 'Ausfall Notruf 112', + 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', + 'is_valid': True, + 'recommended_actions': '', + 'sender': 'Deutscher Wetterdienst', + 'sent': '2021-10-11T05:20:00+01:00', + 'severity': 'Minor', + 'start': '2021-11-01T05:20:00+01:00', + 'web': 'https://www.wettergefahren.de', + }), + dict({ + 'affected_areas': 'Axstedt, Gnarrenburg, Grasberg, Hagen im Bremischen, Hambergen, Hepstedt, Holste, Lilienthal, Lübberstedt, Osterholz-Scharmbeck, Ritterhude, Schwanewede, Vollersode, Worpswede', + 'description': 'In Beverstedt im Landkreis Cuxhaven ist am 20. Juli 2022 in einer Geflügelhaltung der Ausbruch der Geflügelpest (Vogelgrippe, Aviäre Influenza) amtlich festgestellt worden. Durch die geografische Nähe des Ausbruchsbetriebes zum Gebiet des Landkreises Osterholz musste das Veterinäramt des Landkreises zum Schutz vor einer Ausbreitung der Geflügelpest auch für sein Gebiet ein Restriktionsgebiet festlegen. Rund um den Ausbruchsort wurde eine Überwachungszone ausgewiesen. Eine entsprechende Tierseuchenbehördliche Allgemeinverfügung wurde vom Landkreis Osterholz erlassen und tritt am 23.07.2022 in Kraft.
\xa0
Die Überwachungszone mit einem Radius von mindestens zehn Kilometern um den Ausbruchsbetrieb erstreckt sich im Landkreis Osterholz innerhalb der Samtgemeinde Hambergen auf die Mitgliedsgemeinden Axstedt, Holste und Lübberstedt. Die vorgenannten Gemeinden sind vollständig zur Überwachungszone erklärt worden. Der genaue Grenzverlauf des Gebietes kann auch der interaktiven Karte im Internet entnommen werden.
\xa0
In der Überwachungszone liegen im Landkreis Osterholz rund 70 Geflügelhaltungen mit einem Gesamtbestand von rund 1.800 Tieren. Sie alle unterliegen mit der Allgemeinverfügung der sogenannten amtlichen Beobachtung. Für die Betriebe sind die Biosicherheitsmaßnahmen einzuhalten. Dazu zählen insbesondere Hygienemaßnahmen im laufenden Betrieb und eine ordnungsgemäße Schadnagerbekämpfung.
\xa0
Das Verbringen von Vögeln, Fleisch von Geflügel, Eiern und sonstige Nebenprodukte von Geflügel in und aus Betrieben in der Überwachungszone ist verboten. Auch Geflügeltransporte sind in der Überwachungszone verboten. Jeder Verdacht der Erkrankung auf Geflügelpest ist zudem dem Veterinäramt des Landkreises Osterholz unter der E-Mail-Adresse veterinaeramt@landkreis-osterholz.de sofort zu melden. Alle Hinweise, die innerhalb der Überwachungszone zu beachten sind, sind unter www.landkreis-osterholz.de/gefluegelpest zusammengefasst dargestellt.
\xa0
Die Veterinärbehörde weist zudem darauf hin, dass sämtliche Geflügelhaltungen – Hühner, Enten, Gänse, Fasane, Perlhühner, Rebhühner, Truthühner, Wachteln oder Laufvögel – der zuständigen Behörde angezeigt werden müssen. Wer dies bisher noch nicht gemacht hat und über keine Registriernummer für seinen Geflügelbestand verfügt, sollte die Meldung über das Veterinäramt umgehend nachholen.
\xa0
Das Beobachtungsgebiet kann frühestens 30 Tage nach der Grobreinigung des Ausbruchsbetriebes wieder aufgehoben werden. Hierüber wird der Landkreis Osterholz informieren.
\xa0
Die Allgemeinverfügung, eine Übersicht zur Überwachungszone und weitere Hinweise sind auf der Internetseite unter www.landkreis-osterholz.de/gefluegelpest zu finden.', + 'expires': '2002-08-07T10:59:00+02:00', + 'headline': 'Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt', + 'id': 'biw.BIWAPP-69634', + 'is_valid': False, + 'recommended_actions': '', + 'sender': '', + 'sent': '1999-08-07T10:59:00+02:00', + 'severity': 'Minor', + 'start': '', + 'web': '', + }), + ]), + }), + 'entry_data': dict({ + 'area_filter': '.*', + 'headline_filter': '.*corona.*', + 'regions': dict({ + '083350000000': 'Aach, Stadt', + }), + 'slots': 5, + }), + }) +# --- diff --git a/tests/components/nina/test_diagnostics.py b/tests/components/nina/test_diagnostics.py new file mode 100644 index 00000000000..c0646b8d68c --- /dev/null +++ b/tests/components/nina/test_diagnostics.py @@ -0,0 +1,45 @@ +"""Test the Nina diagnostics.""" + +from typing import Any +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nina.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import mocked_request_function + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +ENTRY_DATA: dict[str, Any] = { + "slots": 5, + "corona_filter": True, + "regions": {"083350000000": "Aach, Stadt"}, +} + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + config_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 7e6ceee9d14ed64355a8713029f08a7ba584ae80 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:34:37 +0200 Subject: [PATCH 35/73] Add IQ Meter Collar and C6 Combiner to enphase_envoy integration (#150649) --- .../components/enphase_envoy/binary_sensor.py | 116 +++++++- .../components/enphase_envoy/sensor.py | 130 +++++++++ .../components/enphase_envoy/strings.json | 6 + tests/components/enphase_envoy/conftest.py | 6 + .../fixtures/envoy_metered_batt_relay.json | 29 ++ .../snapshots/test_binary_sensor.ambr | 98 +++++++ .../enphase_envoy/snapshots/test_sensor.ambr | 247 ++++++++++++++++++ 7 files changed, 631 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 2628406f56f..5dcc2f28c7f 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter -from pyenphase import EnvoyEncharge, EnvoyEnpower +from pyenphase import EnvoyC6CC, EnvoyCollar, EnvoyEncharge, EnvoyEnpower from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -72,6 +72,42 @@ ENPOWER_SENSORS = ( ) +@dataclass(frozen=True, kw_only=True) +class EnvoyCollarBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an Envoy IQ Meter Collar binary sensor entity.""" + + value_fn: Callable[[EnvoyCollar], bool] + + +COLLAR_SENSORS = ( + EnvoyCollarBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("communicating"), + ), +) + + +@dataclass(frozen=True, kw_only=True) +class EnvoyC6CCBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an C6 Combiner controller binary sensor entity.""" + + value_fn: Callable[[EnvoyC6CC], bool] + + +C6CC_SENSORS = ( + EnvoyC6CCBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("communicating"), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, @@ -95,6 +131,18 @@ async def async_setup_entry( for description in ENPOWER_SENSORS ) + if envoy_data.collar: + entities.extend( + EnvoyCollarBinarySensorEntity(coordinator, description) + for description in COLLAR_SENSORS + ) + + if envoy_data.c6cc: + entities.extend( + EnvoyC6CCBinarySensorEntity(coordinator, description) + for description in C6CC_SENSORS + ) + async_add_entities(entities) @@ -168,3 +216,69 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): enpower = self.data.enpower assert enpower is not None return self.entity_description.value_fn(enpower) + + +class EnvoyCollarBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an IQ Meter Collar binary_sensor entity.""" + + entity_description: EnvoyCollarBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyCollarBinarySensorEntityDescription, + ) -> None: + """Init the Collar base entity.""" + super().__init__(coordinator, description) + collar_data = self.data.collar + assert collar_data is not None + self._attr_unique_id = f"{collar_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, collar_data.serial_number)}, + manufacturer="Enphase", + model="IQ Meter Collar", + name=f"Collar {collar_data.serial_number}", + sw_version=str(collar_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=collar_data.serial_number, + ) + + @property + def is_on(self) -> bool: + """Return the state of the Collar binary_sensor.""" + collar_data = self.data.collar + assert collar_data is not None + return self.entity_description.value_fn(collar_data) + + +class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an C6 Combiner binary_sensor entity.""" + + entity_description: EnvoyC6CCBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyC6CCBinarySensorEntityDescription, + ) -> None: + """Init the C6 Combiner base entity.""" + super().__init__(coordinator, description) + c6cc_data = self.data.c6cc + assert c6cc_data is not None + self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, c6cc_data.serial_number)}, + manufacturer="Enphase", + model="C6 COMBINER CONTROLLER", + name=f"C6 Combiner {c6cc_data.serial_number}", + sw_version=str(c6cc_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=c6cc_data.serial_number, + ) + + @property + def is_on(self) -> bool: + """Return the state of the C6 Combiner binary_sensor.""" + c6cc_data = self.data.c6cc + assert c6cc_data is not None + return self.entity_description.value_fn(c6cc_data) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 63a2a09a6f5..e771233b069 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING from pyenphase import ( EnvoyACBPower, EnvoyBatteryAggregate, + EnvoyC6CC, + EnvoyCollar, EnvoyEncharge, EnvoyEnchargeAggregate, EnvoyEnchargePower, @@ -790,6 +792,58 @@ ENPOWER_SENSORS = ( ) +@dataclass(frozen=True, kw_only=True) +class EnvoyCollarSensorEntityDescription(SensorEntityDescription): + """Describes an Envoy Collar sensor entity.""" + + value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str] + + +COLLAR_SENSORS = ( + EnvoyCollarSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=attrgetter("temperature"), + ), + EnvoyCollarSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date), + ), + EnvoyCollarSensorEntityDescription( + key="grid_state", + translation_key="grid_status", + value_fn=lambda collar: collar.grid_state, + ), + EnvoyCollarSensorEntityDescription( + key="mid_state", + translation_key="mid_state", + value_fn=lambda collar: collar.mid_state, + ), +) + + +@dataclass(frozen=True, kw_only=True) +class EnvoyC6CCSensorEntityDescription(SensorEntityDescription): + """Describes an Envoy C6 Combiner controller sensor entity.""" + + value_fn: Callable[[EnvoyC6CC], datetime.datetime] + + +C6CC_SENSORS = ( + EnvoyC6CCSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda c6cc: dt_util.utc_from_timestamp(c6cc.last_report_date), + ), +) + + @dataclass(frozen=True) class EnvoyEnchargeAggregateRequiredKeysMixin: """Mixin for required keys.""" @@ -1050,6 +1104,15 @@ async def async_setup_entry( AggregateBatteryEntity(coordinator, description) for description in AGGREGATE_BATTERY_SENSORS ) + if envoy_data.collar: + entities.extend( + EnvoyCollarEntity(coordinator, description) + for description in COLLAR_SENSORS + ) + if envoy_data.c6cc: + entities.extend( + EnvoyC6CCEntity(coordinator, description) for description in C6CC_SENSORS + ) async_add_entities(entities) @@ -1488,3 +1551,70 @@ class AggregateBatteryEntity(EnvoySystemSensorEntity): battery_aggregate = self.data.battery_aggregate assert battery_aggregate is not None return self.entity_description.value_fn(battery_aggregate) + + +class EnvoyCollarEntity(EnvoySensorBaseEntity): + """Envoy Collar sensor entity.""" + + entity_description: EnvoyCollarSensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyCollarSensorEntityDescription, + ) -> None: + """Initialize Collar entity.""" + super().__init__(coordinator, description) + collar_data = self.data.collar + assert collar_data is not None + self._serial_number = collar_data.serial_number + self._attr_unique_id = f"{collar_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, collar_data.serial_number)}, + manufacturer="Enphase", + model="IQ Meter Collar", + name=f"Collar {collar_data.serial_number}", + sw_version=str(collar_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=collar_data.serial_number, + ) + + @property + def native_value(self) -> datetime.datetime | int | float | str: + """Return the state of the collar sensors.""" + collar_data = self.data.collar + assert collar_data is not None + return self.entity_description.value_fn(collar_data) + + +class EnvoyC6CCEntity(EnvoySensorBaseEntity): + """Envoy C6CC sensor entity.""" + + entity_description: EnvoyC6CCSensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyC6CCSensorEntityDescription, + ) -> None: + """Initialize Encharge entity.""" + super().__init__(coordinator, description) + c6cc_data = self.data.c6cc + assert c6cc_data is not None + self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, c6cc_data.serial_number)}, + manufacturer="Enphase", + model="C6 COMBINER CONTROLLER", + name=f"C6 Combiner {c6cc_data.serial_number}", + sw_version=str(c6cc_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + serial_number=c6cc_data.serial_number, + ) + + @property + def native_value(self) -> datetime.datetime: + """Return the state of the c6cc inventory sensors.""" + c6cc_data = self.data.c6cc + assert c6cc_data is not None + return self.entity_description.value_fn(c6cc_data) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index ffe0ccb1271..17ed8eff67e 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -407,6 +407,12 @@ }, "last_report_duration": { "name": "Last report duration" + }, + "grid_status": { + "name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]" + }, + "mid_state": { + "name": "MID state" } }, "switch": { diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 7ad15f85ac2..9e94dab5a4c 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -9,6 +9,8 @@ import multidict from pyenphase import ( EnvoyACBPower, EnvoyBatteryAggregate, + EnvoyC6CC, + EnvoyCollar, EnvoyData, EnvoyEncharge, EnvoyEnchargeAggregate, @@ -260,6 +262,10 @@ def _load_json_2_encharge_enpower_data( ) if item := json_fixture["data"].get("battery_aggregate"): mocked_data.battery_aggregate = EnvoyBatteryAggregate(**item) + if item := json_fixture["data"].get("collar"): + mocked_data.collar = EnvoyCollar(**item) + if item := json_fixture["data"].get("c6cc"): + mocked_data.c6cc = EnvoyC6CC(**item) def _load_json_2_raw_data(mocked_data: EnvoyData, json_fixture: dict[str, Any]) -> None: diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 73af5af0e5d..e8e0fd8ac85 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -407,6 +407,35 @@ "type": "NONE" } }, + "collar": { + "admin_state": 88, + "admin_state_str": "ENCMN_MDE_ON_GRID", + "firmware_loaded_date": 1752939759, + "firmware_version": "3.0.6-D0", + "installed_date": 1752939759, + "last_report_date": 1752939759, + "communicating": true, + "mid_state": "close", + "grid_state": "on_grid", + "part_number": "865-00400-r22", + "serial_number": "482520020939", + "temperature": 42, + "temperature_unit": "C", + "control_error": 0, + "collar_state": "Installed" + }, + "c6cc": { + "admin_state": 82, + "admin_state_str": "ENCMN_C6_CC_READY", + "firmware_loaded_date": 1752945451, + "firmware_version": "0.1.20-D1", + "installed_date": 1752945451, + "last_report_date": 1752945451, + "communicating": true, + "part_number": "800-02403-r08", + "serial_number": "482523040549", + "dmir_version": "0.1.20-D1" + }, "inverters": { "1": { "serial_number": "1", diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index bbf35621c6c..18e7a9c9008 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -96,6 +96,104 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.c6_combiner_482523040549_communicating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communicating', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'communicating', + 'unique_id': '482523040549_communicating', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'C6 Combiner 482523040549 Communicating', + }), + 'context': , + 'entity_id': 'binary_sensor.c6_combiner_482523040549_communicating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.collar_482520020939_communicating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communicating', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'communicating', + 'unique_id': '482520020939_communicating', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Collar 482520020939 Communicating', + }), + 'context': , + 'entity_id': 'binary_sensor.collar_482520020939_communicating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.encharge_123456_communicating-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 4a9563ce906..00cb30fce09 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -13947,6 +13947,253 @@ 'state': 'unknown', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.c6_combiner_482523040549_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '482523040549_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'C6 Combiner 482523040549 Last reported', + }), + 'context': , + 'entity_id': 'sensor.c6_combiner_482523040549_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-07-19T17:17:31+00:00', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_grid_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid status', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_status', + 'unique_id': '482520020939_grid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 Grid status', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_grid_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on_grid', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '482520020939_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Collar 482520020939 Last reported', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-07-19T15:42:39+00:00', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_mid_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MID state', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mid_state', + 'unique_id': '482520020939_mid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Collar 482520020939 MID state', + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_mid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'close', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.collar_482520020939_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '482520020939_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Collar 482520020939 Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.collar_482520020939_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f5fe53a67f2c0105ede5110591daa58294ecf361 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 16:16:04 -0500 Subject: [PATCH 36/73] Bump uiprotect to 7.21.1 (#150657) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8eee080abb4..50bdeec8572 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.20.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.21.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 2b4c6c916bb..2ce816f94c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.20.0 +uiprotect==7.21.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e11345951b..bdc736ec63a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.20.0 +uiprotect==7.21.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 57265ac6485ad400ed6f1dc128bf4db36b75002a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 14 Aug 2025 16:28:42 -0500 Subject: [PATCH 37/73] Add fuzzy matching to default agent (#150595) --- .../components/conversation/__init__.py | 5 +- .../components/conversation/default_agent.py | 378 ++++++++++++++---- homeassistant/components/conversation/http.py | 29 +- tests/components/assist_pipeline/conftest.py | 7 +- tests/components/conversation/conftest.py | 6 +- .../conversation/snapshots/test_http.ambr | 13 +- .../conversation/test_default_agent.py | 105 ++++- 7 files changed, 435 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 3435a7d2ed4..4fd3a57034f 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -117,7 +117,7 @@ CONFIG_SCHEMA = vol.Schema( {cv.string: vol.All(cv.ensure_list, [cv.string])} ) } - ) + ), }, extra=vol.ALLOW_EXTRA, ) @@ -268,8 +268,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) hass.data[DATA_COMPONENT] = entity_component + agent_config = config.get(DOMAIN, {}) await async_setup_default_agent( - hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) + hass, entity_component, config_intents=agent_config.get("intents", {}) ) async def handle_process(service: ServiceCall) -> ServiceResponse: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3fb305098e7..4b056ead2c2 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -15,13 +15,18 @@ import time from typing import IO, Any, cast from hassil.expression import Expression, Group, ListReference, TextChunk +from hassil.fuzzy import FuzzyNgramMatcher, SlotCombinationInfo from hassil.intents import ( + Intent, + IntentData, Intents, SlotList, TextSlotList, TextSlotValue, WildcardSlotList, ) +from hassil.models import MatchEntity +from hassil.ngram import Sqlite3NgramModel from hassil.recognize import ( MISSING_ENTITY, RecognizeResult, @@ -31,7 +36,15 @@ from hassil.recognize import ( from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity from hassil.trie import Trie from hassil.util import merge_dict -from home_assistant_intents import ErrorKey, get_intents, get_languages +from home_assistant_intents import ( + ErrorKey, + FuzzyConfig, + FuzzyLanguageResponses, + get_fuzzy_config, + get_fuzzy_language, + get_intents, + get_languages, +) import yaml from homeassistant import core @@ -76,6 +89,7 @@ TRIGGER_CALLBACK_TYPE = Callable[ ] METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" +METADATA_FUZZY_MATCH = "hass_fuzzy_match" ERROR_SENTINEL = object() @@ -94,6 +108,8 @@ class LanguageIntents: intent_responses: dict[str, Any] error_responses: dict[str, Any] language_variant: str | None + fuzzy_matcher: FuzzyNgramMatcher | None = None + fuzzy_responses: FuzzyLanguageResponses | None = None @dataclass(slots=True) @@ -119,10 +135,13 @@ class IntentMatchingStage(Enum): EXPOSED_ENTITIES_ONLY = auto() """Match against exposed entities only.""" + FUZZY = auto() + """Use fuzzy matching to guess intent.""" + UNEXPOSED_ENTITIES = auto() """Match against unexposed entities in Home Assistant.""" - FUZZY = auto() + UNKNOWN_NAMES = auto() """Capture names that are not known to Home Assistant.""" @@ -241,6 +260,10 @@ class DefaultAgent(ConversationEntity): # LRU cache to avoid unnecessary intent matching self._intent_cache = IntentCache(capacity=128) + # Shared configuration for fuzzy matching + self.fuzzy_matching = True + self._fuzzy_config: FuzzyConfig | None = None + @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" @@ -299,7 +322,7 @@ class DefaultAgent(ConversationEntity): _LOGGER.warning("No intents were loaded for language: %s", language) return None - slot_lists = self._make_slot_lists() + slot_lists = await self._make_slot_lists() intent_context = self._make_intent_context(user_input) if self._exposed_names_trie is not None: @@ -556,6 +579,36 @@ class DefaultAgent(ConversationEntity): # Don't try matching against all entities or doing a fuzzy match return None + # Use fuzzy matching + skip_fuzzy_match = False + if cache_value is not None: + if (cache_value.result is not None) and ( + cache_value.stage == IntentMatchingStage.FUZZY + ): + _LOGGER.debug("Got cached result for fuzzy match") + return cache_value.result + + # Continue with matching, but we know we won't succeed for fuzzy + # match. + skip_fuzzy_match = True + + if (not skip_fuzzy_match) and self.fuzzy_matching: + start_time = time.monotonic() + fuzzy_result = self._recognize_fuzzy(lang_intents, user_input) + + # Update cache + self._intent_cache.put( + cache_key, + IntentCacheValue(result=fuzzy_result, stage=IntentMatchingStage.FUZZY), + ) + + _LOGGER.debug( + "Did fuzzy match in %s second(s)", time.monotonic() - start_time + ) + + if fuzzy_result is not None: + return fuzzy_result + # Try again with all entities (including unexposed) skip_unexposed_entities_match = False if cache_value is not None: @@ -601,102 +654,160 @@ class DefaultAgent(ConversationEntity): # This should fail the intent handling phase (async_match_targets). return strict_result - # Try again with missing entities enabled - skip_fuzzy_match = False + # Check unknown names + skip_unknown_names = False if cache_value is not None: if (cache_value.result is not None) and ( - cache_value.stage == IntentMatchingStage.FUZZY + cache_value.stage == IntentMatchingStage.UNKNOWN_NAMES ): - _LOGGER.debug("Got cached result for fuzzy match") + _LOGGER.debug("Got cached result for unknown names") return cache_value.result - # We know we won't succeed for fuzzy matching. - skip_fuzzy_match = True + skip_unknown_names = True maybe_result: RecognizeResult | None = None - if not skip_fuzzy_match: + if not skip_unknown_names: start_time = time.monotonic() - best_num_matched_entities = 0 - best_num_unmatched_entities = 0 - best_num_unmatched_ranges = 0 - for result in recognize_all( - user_input.text, - lang_intents.intents, - slot_lists=slot_lists, - intent_context=intent_context, - allow_unmatched_entities=True, - ): - if result.text_chunks_matched < 1: - # Skip results that don't match any literal text - continue - - # Don't count missing entities that couldn't be filled from context - num_matched_entities = 0 - for matched_entity in result.entities_list: - if matched_entity.name not in result.unmatched_entities: - num_matched_entities += 1 - - num_unmatched_entities = 0 - num_unmatched_ranges = 0 - for unmatched_entity in result.unmatched_entities_list: - if isinstance(unmatched_entity, UnmatchedTextEntity): - if unmatched_entity.text != MISSING_ENTITY: - num_unmatched_entities += 1 - elif isinstance(unmatched_entity, UnmatchedRangeEntity): - num_unmatched_ranges += 1 - num_unmatched_entities += 1 - else: - num_unmatched_entities += 1 - - if ( - (maybe_result is None) # first result - or ( - # More literal text matched - result.text_chunks_matched > maybe_result.text_chunks_matched - ) - or ( - # More entities matched - num_matched_entities > best_num_matched_entities - ) - or ( - # Fewer unmatched entities - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities < best_num_unmatched_entities) - ) - or ( - # Prefer unmatched ranges - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges > best_num_unmatched_ranges) - ) - or ( - # Prefer match failures with entities - (result.text_chunks_matched == maybe_result.text_chunks_matched) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges == best_num_unmatched_ranges) - and ( - ("name" in result.entities) - or ("name" in result.unmatched_entities) - ) - ) - ): - maybe_result = result - best_num_matched_entities = num_matched_entities - best_num_unmatched_entities = num_unmatched_entities - best_num_unmatched_ranges = num_unmatched_ranges + maybe_result = self._recognize_unknown_names( + lang_intents, user_input, slot_lists, intent_context + ) # Update cache self._intent_cache.put( cache_key, - IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY), + IntentCacheValue( + result=maybe_result, stage=IntentMatchingStage.UNKNOWN_NAMES + ), ) _LOGGER.debug( - "Did fuzzy match in %s second(s)", time.monotonic() - start_time + "Did unknown names match in %s second(s)", time.monotonic() - start_time ) return maybe_result + def _recognize_fuzzy( + self, lang_intents: LanguageIntents, user_input: ConversationInput + ) -> RecognizeResult | None: + """Return fuzzy recognition from hassil.""" + if lang_intents.fuzzy_matcher is None: + return None + + fuzzy_result = lang_intents.fuzzy_matcher.match(user_input.text) + if fuzzy_result is None: + return None + + response = "default" + if lang_intents.fuzzy_responses: + domain = "" # no domain + if "name" in fuzzy_result.slots: + domain = fuzzy_result.name_domain + elif "domain" in fuzzy_result.slots: + domain = fuzzy_result.slots["domain"].value + + slot_combo = tuple(sorted(fuzzy_result.slots)) + if ( + intent_responses := lang_intents.fuzzy_responses.get( + fuzzy_result.intent_name + ) + ) and (combo_responses := intent_responses.get(slot_combo)): + response = combo_responses.get(domain, response) + + entities = [ + MatchEntity(name=slot_name, value=slot_value.value, text=slot_value.text) + for slot_name, slot_value in fuzzy_result.slots.items() + ] + + return RecognizeResult( + intent=Intent(name=fuzzy_result.intent_name), + intent_data=IntentData(sentence_texts=[]), + intent_metadata={METADATA_FUZZY_MATCH: True}, + entities={entity.name: entity for entity in entities}, + entities_list=entities, + response=response, + ) + + def _recognize_unknown_names( + self, + lang_intents: LanguageIntents, + user_input: ConversationInput, + slot_lists: dict[str, SlotList], + intent_context: dict[str, Any] | None, + ) -> RecognizeResult | None: + """Return result with unknown names for an error message.""" + maybe_result: RecognizeResult | None = None + + best_num_matched_entities = 0 + best_num_unmatched_entities = 0 + best_num_unmatched_ranges = 0 + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if result.text_chunks_matched < 1: + # Skip results that don't match any literal text + continue + + # Don't count missing entities that couldn't be filled from context + num_matched_entities = 0 + for matched_entity in result.entities_list: + if matched_entity.name not in result.unmatched_entities: + num_matched_entities += 1 + + num_unmatched_entities = 0 + num_unmatched_ranges = 0 + for unmatched_entity in result.unmatched_entities_list: + if isinstance(unmatched_entity, UnmatchedTextEntity): + if unmatched_entity.text != MISSING_ENTITY: + num_unmatched_entities += 1 + elif isinstance(unmatched_entity, UnmatchedRangeEntity): + num_unmatched_ranges += 1 + num_unmatched_entities += 1 + else: + num_unmatched_entities += 1 + + if ( + (maybe_result is None) # first result + or ( + # More literal text matched + result.text_chunks_matched > maybe_result.text_chunks_matched + ) + or ( + # More entities matched + num_matched_entities > best_num_matched_entities + ) + or ( + # Fewer unmatched entities + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities < best_num_unmatched_entities) + ) + or ( + # Prefer unmatched ranges + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges > best_num_unmatched_ranges) + ) + or ( + # Prefer match failures with entities + (result.text_chunks_matched == maybe_result.text_chunks_matched) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) + and ( + ("name" in result.entities) + or ("name" in result.unmatched_entities) + ) + ) + ): + maybe_result = result + best_num_matched_entities = num_matched_entities + best_num_unmatched_entities = num_unmatched_entities + best_num_unmatched_ranges = num_unmatched_ranges + + return maybe_result + def _get_unexposed_entity_names(self, text: str) -> TextSlotList: """Get filtered slot list with unexposed entity names in Home Assistant.""" if self._unexposed_names_trie is None: @@ -851,7 +962,7 @@ class DefaultAgent(ConversationEntity): if lang_intents is None: return - self._make_slot_lists() + await self._make_slot_lists() async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None: """Load all intents of a language with lock.""" @@ -1002,12 +1113,85 @@ class DefaultAgent(ConversationEntity): intent_responses = responses_dict.get("intents", {}) error_responses = responses_dict.get("errors", {}) + if not self.fuzzy_matching: + _LOGGER.debug("Fuzzy matching is disabled") + return LanguageIntents( + intents, + intents_dict, + intent_responses, + error_responses, + language_variant, + ) + + # Load fuzzy + fuzzy_info = get_fuzzy_language(language_variant, json_load=json_load) + if fuzzy_info is None: + _LOGGER.debug( + "Fuzzy matching not available for language: %s", language_variant + ) + return LanguageIntents( + intents, + intents_dict, + intent_responses, + error_responses, + language_variant, + ) + + if self._fuzzy_config is None: + # Load shared config + self._fuzzy_config = get_fuzzy_config(json_load=json_load) + _LOGGER.debug("Loaded shared fuzzy matching config") + + assert self._fuzzy_config is not None + + fuzzy_matcher: FuzzyNgramMatcher | None = None + fuzzy_responses: FuzzyLanguageResponses | None = None + + start_time = time.monotonic() + fuzzy_responses = fuzzy_info.responses + fuzzy_matcher = FuzzyNgramMatcher( + intents=intents, + intent_models={ + intent_name: Sqlite3NgramModel( + order=fuzzy_model.order, + words={ + word: str(word_id) + for word, word_id in fuzzy_model.words.items() + }, + database_path=fuzzy_model.database_path, + ) + for intent_name, fuzzy_model in fuzzy_info.ngram_models.items() + }, + intent_slot_list_names=self._fuzzy_config.slot_list_names, + slot_combinations={ + intent_name: { + combo_key: [ + SlotCombinationInfo( + name_domains=(set(name_domains) if name_domains else None) + ) + ] + for combo_key, name_domains in intent_combos.items() + } + for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items() + }, + domain_keywords=fuzzy_info.domain_keywords, + stop_words=fuzzy_info.stop_words, + ) + _LOGGER.debug( + "Loaded fuzzy matcher in %s second(s): language=%s, intents=%s", + time.monotonic() - start_time, + language_variant, + sorted(fuzzy_matcher.intent_models.keys()), + ) + return LanguageIntents( intents, intents_dict, intent_responses, error_responses, language_variant, + fuzzy_matcher=fuzzy_matcher, + fuzzy_responses=fuzzy_responses, ) @core.callback @@ -1027,8 +1211,7 @@ class DefaultAgent(ConversationEntity): # Slot lists have changed, so we must clear the cache self._intent_cache.clear() - @core.callback - def _make_slot_lists(self) -> dict[str, SlotList]: + async def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists @@ -1089,6 +1272,10 @@ class DefaultAgent(ConversationEntity): "floor": TextSlotList.from_tuples(floor_names, allow_template=False), } + # Reload fuzzy matchers with new slot lists + if self.fuzzy_matching: + await self.hass.async_add_executor_job(self._load_fuzzy_matchers) + self._listen_clear_slot_list() _LOGGER.debug( @@ -1098,6 +1285,25 @@ class DefaultAgent(ConversationEntity): return self._slot_lists + def _load_fuzzy_matchers(self) -> None: + """Reload fuzzy matchers for all loaded languages.""" + for lang_intents in self._lang_intents.values(): + if (not isinstance(lang_intents, LanguageIntents)) or ( + lang_intents.fuzzy_matcher is None + ): + continue + + lang_matcher = lang_intents.fuzzy_matcher + lang_intents.fuzzy_matcher = FuzzyNgramMatcher( + intents=lang_matcher.intents, + intent_models=lang_matcher.intent_models, + intent_slot_list_names=lang_matcher.intent_slot_list_names, + slot_combinations=lang_matcher.slot_combinations, + domain_keywords=lang_matcher.domain_keywords, + stop_words=lang_matcher.stop_words, + slot_lists=self._slot_lists, + ) + def _make_intent_context( self, user_input: ConversationInput ) -> dict[str, Any] | None: @@ -1521,10 +1727,8 @@ def _get_match_error_response( def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Group): - grp: Group = expression - for item in grp.items: + for item in expression.items: _collect_list_references(item, list_names) elif isinstance(expression, ListReference): # {list} - list_ref: ListReference = expression - list_names.add(list_ref.slot_name) + list_names.add(expression.slot_name) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index efcdcb8d69b..290e3aab955 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -26,7 +26,11 @@ from .agent_manager import ( get_agent_manager, ) from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY -from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE +from .default_agent import ( + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, + METADATA_FUZZY_MATCH, +) from .entity import ConversationEntity from .models import ConversationInput @@ -240,6 +244,8 @@ async def websocket_hass_agent_debug( "sentence_template": "", # When match is incomplete, this will contain the best slot guesses "unmatched_slots": _get_unmatched_slots(intent_result), + # True if match was not exact + "fuzzy_match": False, } if successful_match: @@ -251,16 +257,19 @@ async def websocket_hass_agent_debug( if intent_result.intent_sentence is not None: result_dict["sentence_template"] = intent_result.intent_sentence.text - # Inspect metadata to determine if this matched a custom sentence - if intent_result.intent_metadata and intent_result.intent_metadata.get( - METADATA_CUSTOM_SENTENCE - ): - result_dict["source"] = "custom" - result_dict["file"] = intent_result.intent_metadata.get( - METADATA_CUSTOM_FILE + if intent_result.intent_metadata: + # Inspect metadata to determine if this matched a custom sentence + if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE): + result_dict["source"] = "custom" + result_dict["file"] = intent_result.intent_metadata.get( + METADATA_CUSTOM_FILE + ) + else: + result_dict["source"] = "builtin" + + result_dict["fuzzy_match"] = intent_result.intent_metadata.get( + METADATA_FUZZY_MATCH, False ) - else: - result_dict["source"] = "builtin" result_dicts.append(result_dict) diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index e20452a1f93..681f6e7759d 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components import stt, tts, wake_word +from homeassistant.components import conversation, stt, tts, wake_word from homeassistant.components.assist_pipeline import DOMAIN, select as assist_select from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, @@ -295,6 +295,11 @@ async def init_supporting_components( assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component(hass, "conversation", {"conversation": {}}) + + # Disable fuzzy matching by default for tests + agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 8dfe879ee2b..19d8434fc5a 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -73,4 +73,8 @@ async def sl_setup(hass: HomeAssistant): async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "conversation", {conversation.DOMAIN: {}}) + + # Disable fuzzy matching by default for tests + agent = hass.data[conversation.DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 8f68274d37f..8b8ed6fa71c 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -464,6 +464,7 @@ 'value': 'my cool light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOn', }), @@ -472,7 +473,6 @@ 'slots': dict({ 'name': 'my cool light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -489,6 +489,7 @@ 'value': 'my cool light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOff', }), @@ -497,7 +498,6 @@ 'slots': dict({ 'name': 'my cool light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -519,6 +519,7 @@ 'value': 'light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassTurnOn', }), @@ -528,7 +529,6 @@ 'area': 'kitchen', 'domain': 'light', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -555,6 +555,7 @@ 'value': 'on', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassGetState', }), @@ -565,7 +566,6 @@ 'domain': 'lights', 'state': 'on', }), - 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': False, @@ -590,6 +590,7 @@ }), }), 'file': 'en/beer.yaml', + 'fuzzy_match': False, 'intent': dict({ 'name': 'OrderBeer', }), @@ -630,6 +631,7 @@ 'value': 'test light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassLightSet', }), @@ -639,7 +641,6 @@ 'brightness': '100', 'name': 'test light', }), - 'source': 'builtin', 'targets': dict({ 'light.demo_1234': dict({ 'matched': True, @@ -662,6 +663,7 @@ 'value': 'test light', }), }), + 'fuzzy_match': False, 'intent': dict({ 'name': 'HassLightSet', }), @@ -670,7 +672,6 @@ 'slots': dict({ 'name': 'test light', }), - 'source': 'builtin', 'targets': dict({ }), 'unmatched_slots': dict({ diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index f075f267111..7c5e897d86c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -25,7 +25,12 @@ from homeassistant.components.intent import ( TimerInfo, async_register_timer_handler, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, + ColorMode, + intent as light_intent, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -81,6 +86,10 @@ async def init_components(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "conversation", {}) assert await async_setup_component(hass, "intent", {}) + # Disable fuzzy matching by default for tests + agent = hass.data[DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = False + @pytest.mark.parametrize( "er_kwargs", @@ -3287,3 +3296,97 @@ async def test_language_with_alternative_code( assert call.domain == LIGHT_DOMAIN assert call.service == "turn_on" assert call.data == {"entity_id": [entity_id]} + + +@pytest.mark.parametrize("fuzzy_matching", [True, False]) +@pytest.mark.parametrize( + ("sentence", "intent_type", "slots"), + [ + ("time", "HassGetCurrentTime", {}), + ("how about my timers", "HassTimerStatus", {}), + ( + "the office needs more blue", + "HassLightSet", + {"area": "office", "color": "blue"}, + ), + ( + "50% office light", + "HassLightSet", + {"name": "office light", "brightness": "50%"}, + ), + ], +) +async def test_fuzzy_matching( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fuzzy_matching: bool, + sentence: str, + intent_type: str, + slots: dict[str, Any], +) -> None: + """Test fuzzy vs. non-fuzzy matching on some English sentences.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) + await light_intent.async_setup_intents(hass) + + agent = hass.data[DATA_DEFAULT_ENTITY] + agent.fuzzy_matching = fuzzy_matching + + area_office = area_registry.async_get_or_create("office_id") + area_office = area_registry.async_update(area_office.id, name="office") + + entry = MockConfigEntry() + entry.add_to_hass(hass) + office_satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, + ) + device_registry.async_update_device(office_satellite.id, area_id=area_office.id) + + office_light = entity_registry.async_get_or_create("light", "demo", "1234") + office_light = entity_registry.async_update_entity( + office_light.entity_id, area_id=area_office.id + ) + hass.states.async_set( + office_light.entity_id, + "on", + attributes={ + ATTR_FRIENDLY_NAME: "office light", + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.BRIGHTNESS, ColorMode.RGB], + }, + ) + _on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + result = await conversation.async_converse( + hass, + sentence, + None, + Context(), + language="en", + device_id=office_satellite.id, + ) + response = result.response + + if not fuzzy_matching: + # Should not match + assert response.response_type == intent.IntentResponseType.ERROR + return + + assert response.response_type in ( + intent.IntentResponseType.ACTION_DONE, + intent.IntentResponseType.QUERY_ANSWER, + ) + assert response.intent is not None + assert response.intent.intent_type == intent_type + + # Verify slot texts match + actual_slots = { + slot_name: slot_value["text"] + for slot_name, slot_value in response.intent.slots.items() + if slot_name != "preferred_area_id" # context area + } + assert actual_slots == slots From 9f36b2dcde37027cccf55d91f11f2b6851a186da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 19:31:10 -0500 Subject: [PATCH 38/73] Bump protobuf to 6.32.0 (#150667) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5fa00656e5a..f9d9e1f3d4b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -144,7 +144,7 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.31.1 +protobuf==6.32.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b710fbc31ed..2546c871707 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -170,7 +170,7 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.31.1 +protobuf==6.32.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From a785f3d509f6ced0e2c0d55bc88b6028ebf7a03b Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 15 Aug 2025 05:45:42 +0200 Subject: [PATCH 39/73] Increase test coverage of Habitica (#150671) --- .../components/habitica/fixtures/party_2.json | 74 ++++++++++++++++ tests/components/habitica/test_image.py | 87 ++++++++++++++++++- 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/components/habitica/fixtures/party_2.json diff --git a/tests/components/habitica/fixtures/party_2.json b/tests/components/habitica/fixtures/party_2.json new file mode 100644 index 00000000000..2c0ff528e32 --- /dev/null +++ b/tests/components/habitica/fixtures/party_2.json @@ -0,0 +1,74 @@ +{ + "success": true, + "data": { + "leaderOnly": { + "challenges": false, + "getGems": false + }, + "quest": { + "progress": { + "collect": {}, + "hp": 100 + }, + "key": "dustbunnies", + "active": true, + "leader": "d69833ef-4542-4259-ba50-9b4a1a841bcf", + "members": { + "d69833ef-4542-4259-ba50-9b4a1a841bcf": true + }, + "extra": {} + }, + "tasksOrder": { + "habits": [], + "dailys": [], + "todos": [], + "rewards": [] + }, + "purchased": { + "plan": { + "consecutive": { + "count": 0, + "offset": 0, + "gemCapExtra": 0, + "trinkets": 0 + }, + "quantity": 1, + "extraMonths": 0, + "gemsBought": 0, + "cumulativeCount": 0, + "mysteryItems": [] + } + }, + "cron": {}, + "_id": "1e87097c-4c03-4f8c-a475-67cc7da7f409", + "name": "test-user's Party", + "type": "party", + "privacy": "private", + "chat": [], + "memberCount": 2, + "challengeCount": 0, + "balance": 0, + "managers": {}, + "categories": [], + "leader": { + "auth": { + "local": { + "username": "test-username" + } + }, + "flags": { + "verifiedUsername": true + }, + "profile": { + "name": "test-user" + }, + "_id": "af36e2a8-7927-4dec-a258-400ade7f0ae3", + "id": "af36e2a8-7927-4dec-a258-400ade7f0ae3" + }, + "summary": "test-user's Party", + "id": "1e87097c-4c03-4f8c-a475-67cc7da7f409" + }, + "notifications": [], + "userV": 0, + "appVersion": "5.38.0" +} diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py index 42a87d21a8a..b0810d8e76f 100644 --- a/tests/components/habitica/test_image.py +++ b/tests/components/habitica/test_image.py @@ -8,12 +8,13 @@ import sys from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from habiticalib import HabiticaUserResponse +from habiticalib import HabiticaGroupsResponse, HabiticaUserResponse import pytest +import respx from syrupy.assertion import SnapshotAssertion from syrupy.extensions.image import PNGImageSnapshotExtension -from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -97,3 +98,85 @@ async def test_image_platform( assert (await resp.read()) == snapshot( extension_class=PNGImageSnapshotExtension ) + + +@pytest.mark.usefixtures("habitica") +@respx.mock +async def test_load_image_from_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test loading of image from URL.""" + freezer.move_to("2024-09-20T22:00:00.000") + + call1 = respx.get(f"{ASSETS_URL}quest_atom1.png").respond(content=b"\x89PNG") + call2 = respx.get(f"{ASSETS_URL}quest_dustbunnies.png").respond(content=b"\x89PNG") + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:00:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == b"\x89PNG" + + assert call1.call_count == 1 + + habitica.get_group.return_value = HabiticaGroupsResponse.from_json( + await async_load_fixture(hass, "party_2.json", DOMAIN) + ) + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("image.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:15:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == b"\x89PNG" + assert call2.call_count == 1 + + +@pytest.mark.usefixtures("habitica") +@respx.mock +async def test_load_image_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test NotFound error.""" + freezer.move_to("2024-09-20T22:00:00.000") + + call1 = respx.get(f"{ASSETS_URL}quest_atom1.png").respond(status_code=404) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_s_party_quest")) + assert state.state == "2024-09-20T22:00:00+00:00" + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + assert call1.call_count == 1 From 25f7c0249896cd98d3e88ee953dd10f977be4b2f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 14 Aug 2025 23:46:59 -0400 Subject: [PATCH 40/73] Bump python-snoo to 0.8.3 (#150670) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 0a2301c6fd8..5a162a9e9d3 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.8.2"] + "requirements": ["python-snoo==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2ce816f94c8..5072eff94c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2506,7 +2506,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.8.2 +python-snoo==0.8.3 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bdc736ec63a..b974f1faa4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.8.2 +python-snoo==0.8.3 # homeassistant.components.songpal python-songpal==0.16.2 From 3e9e9b04894d4c2b86318c4b6f32acee1ca90db4 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:47:55 -0700 Subject: [PATCH 41/73] Fix demo media_player.browse browsing (#150669) --- homeassistant/components/demo/media_player.py | 10 ++++++ tests/components/demo/test_media_player.py | 31 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index ad7ddcba285..0c001921c7a 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any +from homeassistant.components import media_source from homeassistant.components.media_player import ( BrowseMedia, MediaClass, @@ -396,6 +397,15 @@ class DemoBrowsePlayer(AbstractDemoPlayer): _attr_supported_features = BROWSE_PLAYER_SUPPORT + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the websocket media browsing helper.""" + + return await media_source.async_browse_media(self.hass, media_content_id) + class DemoGroupPlayer(AbstractDemoPlayer): """A Demo media player that supports grouping.""" diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 7487a4c13e3..c22b28ae799 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -55,7 +55,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION, _make_key from homeassistant.setup import async_setup_component -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_ENTITY_ID = "media_player.walkman" @@ -563,3 +563,32 @@ async def test_grouping(hass: HomeAssistant) -> None: ) state = hass.states.get(walkman) assert state.attributes.get(ATTR_GROUP_MEMBERS) == [] + + +async def test_browse( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the media player browse.""" + entity = "media_player.browse" + + await async_setup_component(hass, "media_source", {"media_source": {}}) + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": entity, + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert msg["result"]["title"] == "media" + assert msg["result"]["media_class"] == "directory" + assert len(msg["result"]["children"]) From 00b765893dd4e0c822d789405e9fe71174b82d09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 22:49:31 -0500 Subject: [PATCH 42/73] Bump onvif-zeep-async to 4.0.3 (#150663) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index fbb1454ec2a..787040d5691 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==4.0.2", "WSDiscovery==2.1.2"] + "requirements": ["onvif-zeep-async==4.0.3", "WSDiscovery==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5072eff94c2..dcd5fac6143 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1591,7 +1591,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.2 +onvif-zeep-async==4.0.3 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b974f1faa4e..5529e2d848e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1359,7 +1359,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.2 +onvif-zeep-async==4.0.3 # homeassistant.components.opengarage open-garage==0.2.0 From 2a6d1180f44fd9ddd0eba49e8445e78ae619fdec Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 15 Aug 2025 08:13:22 +0200 Subject: [PATCH 43/73] Update py-madvr2 to 1.6.40 (#150647) --- homeassistant/components/madvr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/madvr/manifest.json b/homeassistant/components/madvr/manifest.json index 0ac906fdbef..e45a4c60f30 100644 --- a/homeassistant/components/madvr/manifest.json +++ b/homeassistant/components/madvr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/madvr", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-madvr2==1.6.32"] + "requirements": ["py-madvr2==1.6.40"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcd5fac6143..a86f5c0b32c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1764,7 +1764,7 @@ py-dormakaba-dkey==1.0.6 py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.32 +py-madvr2==1.6.40 # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5529e2d848e..08571928eac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1490,7 +1490,7 @@ py-dormakaba-dkey==1.0.6 py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.32 +py-madvr2==1.6.40 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 58f8b3c4014cd72743972600ba5d1e9729262092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 15 Aug 2025 11:29:49 +0200 Subject: [PATCH 44/73] Bump Python Matter server to 8.1.0 (#150631) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 9db0dfc9881..b79113d422e 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,6 +7,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==8.0.0"], + "requirements": ["python-matter-server==8.1.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a86f5c0b32c..ac41bfa82d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2460,7 +2460,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==8.0.0 +python-matter-server==8.1.0 # homeassistant.components.melcloud python-melcloud==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08571928eac..041555bdb6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2033,7 +2033,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==8.0.0 +python-matter-server==8.1.0 # homeassistant.components.melcloud python-melcloud==0.1.0 From 83ee380b17f18837c8eedacd902eb2efd4df66ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 15 Aug 2025 11:35:52 +0200 Subject: [PATCH 45/73] Bump hass-nabucasa from 0.111.2 to 1.0.0 and refactor related code (#150566) --- .../components/cloud/google_config.py | 4 +-- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/conftest.py | 5 ++- tests/components/cloud/test_google_config.py | 18 +++++------ tests/components/cloud/test_http_api.py | 31 ++++++++++--------- 10 files changed, 37 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 2b6f45ec474..62496906c9d 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -7,7 +7,7 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import Cloud from hass_nabucasa.google_report_state import ErrorResponse from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -377,7 +377,7 @@ class CloudGoogleConfig(AbstractConfig): return HTTPStatus.OK async with self._sync_entities_lock: - resp = await cloud_api.async_google_actions_request_sync(self._cloud) + resp = await self._cloud.google_report_state.request_sync() return resp.status async def async_connect_agent_user(self, agent_user_id: str) -> None: diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index cb3537a59e5..a0f88b3a558 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.111.2"], + "requirements": ["hass-nabucasa==1.0.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f9d9e1f3d4b..cfec93ae4b0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.0.1 -hass-nabucasa==0.111.2 +hass-nabucasa==1.0.0 hassil==3.1.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250811.0 diff --git a/pyproject.toml b/pyproject.toml index 1f74056ac91..3e24035f271 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.111.2", + "hass-nabucasa==1.0.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 502e1e225cf..f053bc0d541 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.111.2 +hass-nabucasa==1.0.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ac41bfa82d8..6112d8d49b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ habiticalib==0.4.2 habluetooth==5.0.1 # homeassistant.components.cloud -hass-nabucasa==0.111.2 +hass-nabucasa==1.0.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 041555bdb6b..b1dce41c9c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ habiticalib==0.4.2 habluetooth==5.0.1 # homeassistant.components.cloud -hass-nabucasa==0.111.2 +hass-nabucasa==1.0.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index a4625fcce92..10d38c227f1 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -55,7 +55,10 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: # Attributes set in the constructor without parameters. # We spec the mocks with the real classes # and set constructor attributes or mock properties as needed. - mock_cloud.google_report_state = MagicMock(spec=GoogleReportState) + mock_cloud.google_report_state = MagicMock( + spec=GoogleReportState, + request_sync=AsyncMock(), + ) mock_cloud.cloudhooks = MagicMock(spec=Cloudhooks) mock_cloud.remote = MagicMock( spec=RemoteUI, diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index cb456be5036..e6fb289d09e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -1,7 +1,7 @@ """Test the Cloud Google Config.""" from http import HTTPStatus -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch from freezegun import freeze_time import pytest @@ -119,15 +119,13 @@ async def test_sync_entities( assert len(mock_conf.async_get_agent_users()) == 1 - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=HTTPStatus.NOT_FOUND), - ) as mock_request_sync: - assert ( - await mock_conf.async_sync_entities("mock-user-id") == HTTPStatus.NOT_FOUND - ) - assert len(mock_conf.async_get_agent_users()) == 0 - assert len(mock_request_sync.mock_calls) == 1 + mock_conf._cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.NOT_FOUND) + ) + + assert await mock_conf.async_sync_entities("mock-user-id") == HTTPStatus.NOT_FOUND + assert len(mock_conf.async_get_agent_users()) == 0 + assert len(mock_conf._cloud.google_report_state.request_sync.mock_calls) == 1 async def test_google_update_expose_trigger_sync( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index f125a5cbdae..96927477b0a 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -139,31 +139,34 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: async def test_google_actions_sync( setup_cloud: None, hass_client: ClientSessionGenerator, + cloud: MagicMock, ) -> None: """Test syncing Google Actions.""" cloud_client = await hass_client() - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=200), - ) as mock_request_sync: - req = await cloud_client.post("/api/cloud/google_actions/sync") - assert req.status == HTTPStatus.OK - assert mock_request_sync.call_count == 1 + + cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.OK) + ) + + req = await cloud_client.post("/api/cloud/google_actions/sync") + assert req.status == HTTPStatus.OK + assert len(cloud.google_report_state.request_sync.mock_calls) == 1 async def test_google_actions_sync_fails( setup_cloud: None, hass_client: ClientSessionGenerator, + cloud: MagicMock, ) -> None: """Test syncing Google Actions gone bad.""" cloud_client = await hass_client() - with patch( - "hass_nabucasa.cloud_api.async_google_actions_request_sync", - return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR), - ) as mock_request_sync: - req = await cloud_client.post("/api/cloud/google_actions/sync") - assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert mock_request_sync.call_count == 1 + cloud.google_report_state.request_sync = AsyncMock( + return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR) + ) + + req = await cloud_client.post("/api/cloud/google_actions/sync") + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert len(cloud.google_report_state.request_sync.mock_calls) == 1 @pytest.mark.parametrize( From 7bd126dc8ea0bba4fa77a50128017aae1b246b3e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 15 Aug 2025 13:04:12 +0200 Subject: [PATCH 46/73] Assert the MQTT config entry is reloaded on subentry creation and mutation (#150636) --- tests/components/mqtt/test_config_flow.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ff1f954bace..3b4f090aef3 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3367,6 +3367,7 @@ async def test_migrate_of_incompatible_config_entry( async def test_subentry_configflow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + mock_reload_after_entry_update: MagicMock, config_subentries_data: dict[str, Any], mock_device_user_input: dict[str, Any], mock_entity_user_input: dict[str, Any], @@ -3501,6 +3502,10 @@ async def test_subentry_configflow( assert subentry_device_data[option] == value await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + # Assert the entry is reloaded to set up the entity + assert len(mock_reload_after_entry_update.mock_calls) == 1 @pytest.mark.parametrize( @@ -3641,6 +3646,7 @@ async def test_subentry_reconfigure_remove_entity( async def test_subentry_reconfigure_edit_entity_multi_entitites( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + mock_reload_after_entry_update: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, user_input_mqtt: dict[str, Any], @@ -3758,6 +3764,10 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( for key, value in user_input_mqtt.items(): assert new_components[object_list[1]][key] == value + # Assert the entry is reloaded to set up the entity + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_reload_after_entry_update.mock_calls) == 1 + @pytest.mark.parametrize( ( From 792bb5781d0d6186091c740cd722432ac3589835 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 15 Aug 2025 07:53:48 -0400 Subject: [PATCH 47/73] Fix optimistic set to false for template entities (#150421) --- homeassistant/components/template/entity.py | 10 ++-- .../components/template/template_entity.py | 2 +- .../template/test_alarm_control_panel.py | 32 +++++++++++++ tests/components/template/test_cover.py | 29 ++++++++++- tests/components/template/test_fan.py | 33 +++++++++++++ tests/components/template/test_light.py | 36 ++++++++++++++ tests/components/template/test_lock.py | 33 +++++++++++++ tests/components/template/test_number.py | 31 ++++++++++++ tests/components/template/test_select.py | 36 ++++++++++++++ tests/components/template/test_switch.py | 37 ++++++++++++++ tests/components/template/test_vacuum.py | 48 +++++++++++++++++++ 11 files changed, 322 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 03a93f50ec3..4901a7a7be8 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -34,16 +34,20 @@ class AbstractTemplateEntity(Entity): self._action_scripts: dict[str, Script] = {} if self._optimistic_entity: + optimistic = config.get(CONF_OPTIMISTIC) + self._template = config.get(CONF_STATE) - optimistic = self._template is None + assumed_optimistic = self._template is None if self._extra_optimistic_options: - optimistic = optimistic and all( + assumed_optimistic = assumed_optimistic and all( config.get(option) is None for option in self._extra_optimistic_options ) - self._attr_assumed_state = optimistic or config.get(CONF_OPTIMISTIC, False) + self._attr_assumed_state = optimistic or ( + optimistic is None and assumed_optimistic + ) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 1bc49bceafd..3ba89cae1f4 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -102,7 +102,7 @@ TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = { - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, } diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index c1df654e328..319d02a1056 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -973,3 +973,35 @@ async def test_optimistic(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == AlarmControlPanelState.ARMED_HOME + + +@pytest.mark.parametrize( + ("count", "panel_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('alarm_control_panel.test') }}", + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_panel") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + ALARM_DOMAIN, + "alarm_arm_away", + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 692567c7aa8..2a83967b048 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -628,11 +628,38 @@ async def test_template_position( ], ) @pytest.mark.usefixtures("setup_cover") -async def test_template_not_optimistic(hass: HomeAssistant) -> None: +async def test_template_not_optimistic( + hass: HomeAssistant, + calls: list[ServiceCall], +) -> None: """Test the is_closed attribute.""" state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index b9161edf61a..81486d75137 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1885,6 +1885,39 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_sensor', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_fan") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + fan.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 0549f9981e7..e5d05cfa08f 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -2795,6 +2795,42 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("count", "light_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('light.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "expected"), + [ + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_light") +async def test_not_optimistic(hass: HomeAssistant, expected: str) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + light.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 823306015bf..6a4164fb802 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -1190,6 +1190,39 @@ async def test_optimistic(hass: HomeAssistant) -> None: assert state.state == LockState.UNLOCKED +@pytest.mark.parametrize( + ("count", "lock_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_state', 'on') }}", + "lock": [], + "unlock": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_lock") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 0ae98a23ae4..f10664e0d5f 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -605,6 +605,37 @@ async def test_optimistic(hass: HomeAssistant) -> None: assert float(state.state) == 2 +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "state": "{{ states('sensor.test_state') }}", + "optimistic": False, + "set_value": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_number") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("count", "number_config"), [ diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index f613fa865a6..eda27f18100 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -601,6 +601,42 @@ async def test_optimistic(hass: HomeAssistant) -> None: assert state.state == "yes" +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "state": "{{ states('select.test_state') }}", + "optimistic": False, + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_select") +async def test_not_optimistic(hass: HomeAssistant) -> None: + """Test optimistic yaml option set to false.""" + # Ensure Trigger template entities update the options list + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("count", "select_config"), [ diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index a32f1df4c76..5a884160fe8 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -1267,3 +1268,39 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "switch_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('switch.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "expected"), + [ + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_switch") +async def test_not_optimistic(hass: HomeAssistant, expected: str) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + switch.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 8c2773956b2..21592718551 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1299,6 +1299,54 @@ async def test_optimistic_option( assert state.state == VacuumActivity.DOCKED +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('sensor.test_state') }}", + "start": [], + **TEMPLATE_VACUUM_ACTIONS, + "optimistic": False, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "service", + [ + vacuum.SERVICE_START, + vacuum.SERVICE_PAUSE, + vacuum.SERVICE_STOP, + vacuum.SERVICE_RETURN_TO_BASE, + vacuum.SERVICE_CLEAN_SPOT, + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_not_optimistic( + hass: HomeAssistant, + service: str, + calls: list[ServiceCall], +) -> None: + """Test optimistic yaml option set to false.""" + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, From 64768b10366275c053077760b474361cf2097dd8 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 15 Aug 2025 13:58:03 +0200 Subject: [PATCH 48/73] Fix re-auth flow for Volvo integration (#150478) --- homeassistant/components/volvo/config_flow.py | 6 +-- tests/components/volvo/test_config_flow.py | 49 ++++++++++++++++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index f187d751a2d..0ae0e54077e 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -69,7 +69,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for the flow.""" - self._config_data |= data + self._config_data |= (self.init_data or {}) | data return await self.async_step_api_key() async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: @@ -77,7 +77,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): return await self.async_step_reauth_confirm() async def async_step_reconfigure( - self, _: dict[str, Any] | None = None + self, data: dict[str, Any] | None = None ) -> ConfigFlowResult: """Reconfigure the entry.""" return await self.async_step_api_key() @@ -121,7 +121,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): if user_input is None: if self.source == SOURCE_REAUTH: - user_input = self._config_data = dict(self._get_reauth_entry().data) + user_input = self._config_data api = _create_volvo_cars_api( self.hass, self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py index 91a7803dce5..3129b1383fe 100644 --- a/tests/components/volvo/test_config_flow.py +++ b/tests/components/volvo/test_config_flow.py @@ -13,7 +13,7 @@ from yarl import URL from homeassistant import config_entries from homeassistant.components.volvo.const import CONF_VIN, DOMAIN from homeassistant.config_entries import ConfigFlowResult -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -117,6 +117,53 @@ async def test_reauth_flow( assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_no_stale_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test if reauthentication flow does not use stale data.""" + old_access_token = mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + + with patch( + "homeassistant.components.volvo.config_flow._create_volvo_cars_api", + return_value=mock_config_flow_api, + ) as mock_create_volvo_cars_api: + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await _async_run_flow_to_completion( + hass, + result, + mock_config_flow_api, + has_vin_step=False, + is_reauth=True, + ) + + assert mock_create_volvo_cars_api.called + call = mock_create_volvo_cars_api.call_args_list[0] + access_token_arg = call.args[1] + assert old_access_token != access_token_arg + + async def test_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From a742125f13ea1e2d60f8d705e0c94cee1baeab58 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 13:58:23 +0200 Subject: [PATCH 49/73] Add serial number to Emonitor device (#150692) --- homeassistant/components/emonitor/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index be9e2ecb4cc..3e2f6dcbc8f 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -93,6 +93,7 @@ class EmonitorPowerSensor(CoordinatorEntity[EmonitorStatus], SensorEntity): manufacturer="Powerhouse Dynamics, Inc.", name=device_name, sw_version=emonitor_status.hardware.firmware_version, + serial_number=emonitor_status.hardware.serial_number, ) self._attr_extra_state_attributes = {"channel": channel_number} self._attr_native_value = self._paired_attr(self.entity_description.key) From b300654e15a656a9816f0dbbac8efc92257ae75b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 13:58:44 +0200 Subject: [PATCH 50/73] Add serial number to Dremel device (#150691) --- homeassistant/components/dremel_3d_printer/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/dremel_3d_printer/entity.py b/homeassistant/components/dremel_3d_printer/entity.py index 46686e47e1f..c2823e594a8 100644 --- a/homeassistant/components/dremel_3d_printer/entity.py +++ b/homeassistant/components/dremel_3d_printer/entity.py @@ -30,6 +30,7 @@ class Dremel3DPrinterEntity(CoordinatorEntity[Dremel3DPrinterDataUpdateCoordinat """Return device information about this Dremel printer.""" return DeviceInfo( identifiers={(DOMAIN, self._api.get_serial_number())}, + serial_number=self._api.get_serial_number(), manufacturer=self._api.get_manufacturer(), model=self._api.get_model(), name=self._api.get_title(), From facf217b995982ff5c504bd59d419a59c31ee068 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 15 Aug 2025 13:59:35 +0200 Subject: [PATCH 51/73] Fix missing labels for subdiv in workday (#150684) --- homeassistant/components/workday/config_flow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 1d91e1d5ae3..20d9040e527 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -86,6 +86,9 @@ def add_province_and_language_to_schema( SelectOptionDict(value=k, label=", ".join(v)) for k, v in subdiv_aliases.items() ] + for option in province_options: + if option["label"] == "": + option["label"] = option["value"] else: province_options = provinces province_schema = { From 602497904be5244307438c10c80209f0ca67a6fa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:01:42 +0200 Subject: [PATCH 52/73] Set firmware version to the right field in Guardian (#150697) --- homeassistant/components/guardian/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py index c48c87afa01..760b9423afd 100644 --- a/homeassistant/components/guardian/entity.py +++ b/homeassistant/components/guardian/entity.py @@ -74,7 +74,7 @@ class ValveControllerEntity(GuardianEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.data[CONF_UID])}, manufacturer="Elexa", - model=self._diagnostics_coordinator.data["firmware"], + sw_version=self._diagnostics_coordinator.data["firmware"], name=f"Guardian valve controller {entry.data[CONF_UID]}", ) self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" From bc89e8fd3c6bdd003bf2915e142287f92cf7e1f1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:03:30 +0200 Subject: [PATCH 53/73] Move Notion hardware revision to hw_version (#150701) --- homeassistant/components/notion/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py index 11e470f1d26..387eaf2e423 100644 --- a/homeassistant/components/notion/entity.py +++ b/homeassistant/components/notion/entity.py @@ -45,9 +45,9 @@ class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sensor.hardware_id)}, manufacturer="Silicon Labs", - model=str(sensor.hardware_revision), name=str(sensor.name).capitalize(), sw_version=sensor.firmware_version, + hw_version=str(sensor.hardware_revision), ) if bridge := self._async_get_bridge(bridge_id): From 8da75490c098b36a76eff45695a1cf156c0563fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:04:59 +0200 Subject: [PATCH 54/73] Add hw_version to RainMachine device (#150705) --- homeassistant/components/rainmachine/entity.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rainmachine/entity.py b/homeassistant/components/rainmachine/entity.py index 1289d3e808e..441cf8237b6 100644 --- a/homeassistant/components/rainmachine/entity.py +++ b/homeassistant/components/rainmachine/entity.py @@ -56,11 +56,9 @@ class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)}, name=self._data.controller.name.capitalize(), manufacturer="RainMachine", - model=( - f"Version {self._version_coordinator.data['hwVer']} " - f"(API: {self._version_coordinator.data['apiVer']})" - ), - sw_version=self._version_coordinator.data["swVer"], + hw_version=self._version_coordinator.data["hwVer"], + sw_version=f"{self._version_coordinator.data['swVer']} " + f"(API: {self._version_coordinator.data['apiVer']})", ) @callback From ebbeef80218773655c531aff76eb52e3c7f539d4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:15:22 +0200 Subject: [PATCH 55/73] Add mac to Ambient station device (#150689) --- homeassistant/components/ambient_station/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ambient_station/entity.py b/homeassistant/components/ambient_station/entity.py index 24dfab438d8..9dec905b157 100644 --- a/homeassistant/components/ambient_station/entity.py +++ b/homeassistant/components/ambient_station/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from aioambient.util import get_public_device_id from homeassistant.core import callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription @@ -37,6 +37,7 @@ class AmbientWeatherEntity(Entity): identifiers={(DOMAIN, mac_address)}, manufacturer="Ambient Weather", name=station_name.capitalize(), + connections={(CONNECTION_NETWORK_MAC, mac_address)}, ) self._attr_unique_id = f"{mac_address}_{description.key}" From b7ba99ed176844b893992e497170fe194ede11cd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 15 Aug 2025 15:24:05 +0200 Subject: [PATCH 56/73] Bump `nextdns` to version 4.1.0 (#150706) --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nextdns/__init__.py | 1 + tests/components/nextdns/snapshots/test_diagnostics.ambr | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index d10a1728a94..4fdbcdb7175 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], - "requirements": ["nextdns==4.0.0"] + "requirements": ["nextdns==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6112d8d49b5..9ccdcadf116 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1515,7 +1515,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.0.0 +nextdns==4.1.0 # homeassistant.components.niko_home_control nhc==0.4.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1dce41c9c4..9cbec152176 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1298,7 +1298,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.0.0 +nextdns==4.1.0 # homeassistant.components.niko_home_control nhc==0.4.12 diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 4cf74d72e63..1fa0d234196 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -39,6 +39,7 @@ SETTINGS = Settings( ai_threat_detection=True, allow_affiliate=True, anonymized_ecs=True, + bav=True, block_bypass_methods=True, block_csam=True, block_ddns=True, diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 23f42fee077..f55c381af4e 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -56,6 +56,7 @@ 'ai_threat_detection': True, 'allow_affiliate': True, 'anonymized_ecs': True, + 'bav': True, 'block_9gag': True, 'block_amazon': True, 'block_bereal': True, From 94e9f32da58a49c37bd9ceb7ce122001834235a8 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 15 Aug 2025 15:24:23 +0200 Subject: [PATCH 57/73] Bump airOS to 0.3.0 (#150693) --- homeassistant/components/airos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/airos/fixtures/airos_loco5ac_ap-ptp.json | 9 ++++++++- tests/components/airos/snapshots/test_diagnostics.ambr | 2 ++ 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index 16855d805c0..5699d082956 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.2.11"] + "requirements": ["airos==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ccdcadf116..5a8912fc083 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.11 +airos==0.3.0 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cbec152176..83152691f58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -435,7 +435,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.11 +airos==0.3.0 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json index a033a82411c..06feb3d0a55 100644 --- a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json +++ b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json @@ -13,8 +13,10 @@ "access_point": true, "mac": "01:23:45:67:89:AB", "mac_interface": "br0", + "mode": "point_to_point", "ptmp": false, "ptp": true, + "role": "access_point", "station": false }, "firewall": { @@ -25,9 +27,14 @@ }, "genuine": "/images/genuine.png", "gps": { + "alt": null, + "dim": null, + "dop": null, "fix": 0, "lat": 52.379894, - "lon": 4.901608 + "lon": 4.901608, + "sats": null, + "time_synced": null }, "host": { "cpuload": 10.10101, diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index e3c4d74a5fd..f4561ec6d99 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -16,8 +16,10 @@ 'access_point': True, 'mac': '**REDACTED**', 'mac_interface': 'br0', + 'mode': 'point_to_point', 'ptmp': False, 'ptp': True, + 'role': 'access_point', 'station': False, }), 'firewall': dict({ From 1e2f7cadc7b5c508cc8dcfd548e6452c554bd89e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:27:49 +0200 Subject: [PATCH 58/73] Add unregister hook to Vera (#150708) --- homeassistant/components/vera/entity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py index b3013c288c1..985761f2e63 100644 --- a/homeassistant/components/vera/entity.py +++ b/homeassistant/components/vera/entity.py @@ -48,6 +48,10 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): """Subscribe to updates.""" self.controller.register(self.vera_device, self._update_callback) + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from updates.""" + self.controller.unregister(self.vera_device, self._update_callback) + def _update_callback(self, _device: _DeviceTypeT) -> None: """Update the state.""" self.schedule_update_ha_state(True) From 635cfe7d17cf214e6c99ed01c8bf24013672d2ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:30:01 +0200 Subject: [PATCH 59/73] Remove hass assignment in Openhome (#150703) --- homeassistant/components/openhome/media_player.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 9f8840b8487..8251a06bd00 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -48,7 +48,7 @@ async def async_setup_entry( device = hass.data[DOMAIN][config_entry.entry_id] - entity = OpenhomeDevice(hass, device) + entity = OpenhomeDevice(device) async_add_entities([entity]) @@ -100,9 +100,8 @@ class OpenhomeDevice(MediaPlayerEntity): _attr_state = MediaPlayerState.PLAYING _attr_available = True - def __init__(self, hass, device): + def __init__(self, device): """Initialise the Openhome device.""" - self.hass = hass self._device = device self._attr_unique_id = device.uuid() self._source_index = {} From 9646aa232abfd5d5ae36ccef9a33827ad30f7502 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:31:29 +0200 Subject: [PATCH 60/73] Add serial number to Zeversolar device (#150710) --- homeassistant/components/zeversolar/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zeversolar/entity.py b/homeassistant/components/zeversolar/entity.py index 18ac4dcde32..3e085d952ca 100644 --- a/homeassistant/components/zeversolar/entity.py +++ b/homeassistant/components/zeversolar/entity.py @@ -27,4 +27,5 @@ class ZeversolarEntity( identifiers={(DOMAIN, coordinator.data.serial_number)}, name="Zeversolar Sensor", manufacturer="Zeversolar", + serial_number=coordinator.data.serial_number, ) From abdb48e7cecdeded31a0595b921e3a6e68a9f349 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:32:43 +0200 Subject: [PATCH 61/73] Add serial number to Nobo hub devices (#150700) --- homeassistant/components/nobo_hub/select.py | 4 +++- homeassistant/components/nobo_hub/sensor.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index c24dbe3d21d..566ff88abac 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -69,10 +69,12 @@ class NoboGlobalSelector(SelectEntity): self._override_type = override_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, hub.hub_serial)}, + serial_number=hub.hub_serial, name=hub.hub_info[ATTR_NAME], manufacturer=NOBO_MANUFACTURER, - model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})", + model="Nobø Ecohub", sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION], + hw_version=hub.hub_info[ATTR_HARDWARE_VERSION], ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 382fd1b0bf4..6a394f23f4c 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -58,6 +58,7 @@ class NoboTemperatureSensor(SensorEntity): suggested_area = hub.zones[zone_id][ATTR_NAME] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, component[ATTR_SERIAL])}, + serial_number=component[ATTR_SERIAL], name=component[ATTR_NAME], manufacturer=NOBO_MANUFACTURER, model=component[ATTR_MODEL].name, From ef7ed026dbe6f7790f6ec8e30d7f9f55ee0e7928 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:33:13 +0200 Subject: [PATCH 62/73] Add serial number to Ondilo ICO (#150702) --- homeassistant/components/ondilo_ico/sensor.py | 6 ++++-- tests/components/ondilo_ico/snapshots/test_init.ambr | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index da5ccae11a5..42e65bd0db2 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -137,9 +137,11 @@ class OndiloICO(CoordinatorEntity[OndiloIcoMeasuresCoordinator], SensorEntity): super().__init__(coordinator) self.entity_description = description self._pool_id = pool_id - self._attr_unique_id = f"{pool_data.ico['serial_number']}-{description.key}" + serial_number = pool_data.ico["serial_number"] + self._attr_unique_id = f"{serial_number}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, pool_data.ico["serial_number"])}, + identifiers={(DOMAIN, serial_number)}, + serial_number=serial_number, ) @property diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index 6ea2ad11103..c3d8d92a9d2 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -25,7 +25,7 @@ 'name': 'Pool 1', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': 'W1122333044455', 'sw_version': '1.7.1-stable', 'via_device_id': None, }) @@ -56,7 +56,7 @@ 'name': 'Pool 2', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': 'W2233304445566', 'sw_version': '1.7.1-stable', 'via_device_id': None, }) From 61de50dfc002e413df846b47bcdf8923f166927a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:34:10 +0200 Subject: [PATCH 63/73] Add hw_version to Point device (#150704) --- homeassistant/components/point/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 39af7867e97..b6718d7fd2d 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -28,8 +28,9 @@ class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, identifiers={(DOMAIN, device["device_id"])}, manufacturer="Minut", - model=f"Point v{device['hardware_version']}", + model="Point", name=device["description"], + hw_version=device["hardware_version"], sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) From f72f2a326a5250382de12d8afb88f7964638f59f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:34:31 +0200 Subject: [PATCH 64/73] Add MAC address to Modern forms devices (#150698) --- homeassistant/components/modern_forms/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modern_forms/entity.py b/homeassistant/components/modern_forms/entity.py index c8419295c1f..0fab00f8f22 100644 --- a/homeassistant/components/modern_forms/entity.py +++ b/homeassistant/components/modern_forms/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -31,6 +31,9 @@ class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator """Return device information about this Modern Forms device.""" return DeviceInfo( identifiers={(DOMAIN, self.coordinator.data.info.mac_address)}, + connections={ + (CONNECTION_NETWORK_MAC, self.coordinator.data.info.mac_address) + }, name=self.coordinator.data.info.device_name, manufacturer="Modern Forms", model=self.coordinator.data.info.fan_type, From 2a62e033ddfd440d5fd38ef2bf77c7d9e0453955 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:35:51 +0200 Subject: [PATCH 65/73] Add binary sensor platform to qbus integration (#149975) --- .../components/qbus/binary_sensor.py | 144 +++++++++++++++++ homeassistant/components/qbus/const.py | 1 + homeassistant/components/qbus/coordinator.py | 34 ++-- homeassistant/components/qbus/entity.py | 39 +++-- homeassistant/components/qbus/icons.json | 12 ++ homeassistant/components/qbus/strings.json | 8 + .../qbus/snapshots/test_binary_sensor.ambr | 146 ++++++++++++++++++ tests/components/qbus/test_binary_sensor.py | 27 ++++ 8 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/qbus/binary_sensor.py create mode 100644 homeassistant/components/qbus/icons.json create mode 100644 tests/components/qbus/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/qbus/test_binary_sensor.py diff --git a/homeassistant/components/qbus/binary_sensor.py b/homeassistant/components/qbus/binary_sensor.py new file mode 100644 index 00000000000..d91b6c9cbe6 --- /dev/null +++ b/homeassistant/components/qbus/binary_sensor.py @@ -0,0 +1,144 @@ +"""Support for Qbus binary sensor.""" + +from dataclasses import dataclass +from typing import cast + +from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.factory import QbusMqttTopicFactory +from qbusmqttapi.state import QbusMqttDeviceState, QbusMqttWeatherState + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import ( + QbusEntity, + create_device_identifier, + create_unique_id, + determine_new_outputs, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class QbusWeatherDescription(BinarySensorEntityDescription): + """Description for Qbus weather entities.""" + + property: str + + +_WEATHER_DESCRIPTIONS = ( + QbusWeatherDescription( + key="raining", + property="raining", + translation_key="raining", + ), + QbusWeatherDescription( + key="twilight", + property="twilight", + translation_key="twilight", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + added_controllers: list[str] = [] + + def _create_weather_entities() -> list[BinarySensorEntity]: + new_outputs = determine_new_outputs( + coordinator, added_outputs, lambda output: output.type == "weatherstation" + ) + + return [ + QbusWeatherBinarySensor(output, description) + for output in new_outputs + for description in _WEATHER_DESCRIPTIONS + ] + + def _create_controller_entities() -> list[BinarySensorEntity]: + if coordinator.data and coordinator.data.id not in added_controllers: + added_controllers.extend(coordinator.data.id) + return [QbusControllerConnectedBinarySensor(coordinator.data)] + + return [] + + def _check_outputs() -> None: + entities = [*_create_weather_entities(), *_create_controller_entities()] + async_add_entities(entities) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity): + """Representation of a Qbus weather binary sensor.""" + + _state_cls = QbusMqttWeatherState + + entity_description: QbusWeatherDescription + + def __init__( + self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription + ) -> None: + """Initialize binary sensor entity.""" + + super().__init__(mqtt_output, id_suffix=description.key) + + self.entity_description = description + + async def _handle_state_received(self, state: QbusMqttWeatherState) -> None: + if value := state.read_property(self.entity_description.property, None): + self._attr_is_on = ( + None if value is None else cast(str, value).lower() == "true" + ) + + +class QbusControllerConnectedBinarySensor(BinarySensorEntity): + """Representation of the Qbus controller connected sensor.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + def __init__(self, controller: QbusMqttDevice) -> None: + """Initialize binary sensor entity.""" + self._controller = controller + + self._attr_unique_id = create_unique_id(controller.serial_number, "connected") + self._attr_device_info = DeviceInfo( + identifiers={create_device_identifier(controller)} + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + topic = QbusMqttTopicFactory().get_device_state_topic(self._controller.id) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{topic}", + self._state_received, + ) + ) + + @callback + def _state_received(self, state: QbusMqttDeviceState) -> None: + self._attr_is_on = state.properties.connected if state.properties else None + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index 133a3b8fea9..3ecab64059a 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -6,6 +6,7 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, Platform.LIGHT, diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py index 42e226c8e6a..c3fbf4b60bb 100644 --- a/homeassistant/components/qbus/coordinator.py +++ b/homeassistant/components/qbus/coordinator.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import cast -from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from homeassistant.components.mqtt import ( @@ -19,6 +19,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.hass_dict import HassKey @@ -32,7 +33,7 @@ type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator] QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) -class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): +class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]): """Qbus data coordinator.""" _STATE_REQUEST_DELAY = 3 @@ -63,8 +64,8 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) ) - async def _async_update_data(self) -> list[QbusMqttOutput]: - return self._controller.outputs if self._controller else [] + async def _async_update_data(self) -> QbusMqttDevice | None: + return self._controller def shutdown(self, event: Event | None = None) -> None: """Shutdown Qbus coordinator.""" @@ -140,20 +141,25 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic ) - if self._controller is None or self._controller_activated: + if self._controller is None: return state = self._message_factory.parse_device_state(msg.payload) - if state and state.properties and state.properties.connectable is False: - _LOGGER.debug( - "%s - Activating controller %s", self.config_entry.unique_id, state.id - ) - self._controller_activated = True - request = self._message_factory.create_device_activate_request( - self._controller - ) - await mqtt.async_publish(self.hass, request.topic, request.payload) + if state and state.properties: + async_dispatcher_send(self.hass, f"{DOMAIN}_{msg.topic}", state) + + if not self._controller_activated and state.properties.connectable is False: + _LOGGER.debug( + "%s - Activating controller %s", + self.config_entry.unique_id, + state.id, + ) + self._controller_activated = True + request = self._message_factory.create_device_activate_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) def _request_entity_states(self) -> None: async def request_state(_: datetime) -> None: diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 9fb481d4515..f7205a85c00 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -7,7 +7,7 @@ from collections.abc import Callable import re from typing import Generic, TypeVar, cast -from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory from qbusmqttapi.state import QbusMqttState @@ -44,11 +44,15 @@ def determine_new_outputs( added_ref_ids = {k.ref_id for k in added_outputs} - new_outputs = [ - output - for output in coordinator.data - if filter_fn(output) and output.ref_id not in added_ref_ids - ] + new_outputs = ( + [ + output + for output in coordinator.data.outputs + if filter_fn(output) and output.ref_id not in added_ref_ids + ] + if coordinator.data + else [] + ) if new_outputs: added_outputs.extend(new_outputs) @@ -64,9 +68,14 @@ def format_ref_id(ref_id: str) -> str | None: return None -def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str]: - """Create the identifier referring to the main device this output belongs to.""" - return (DOMAIN, format_mac(mqtt_output.device.mac)) +def create_device_identifier(mqtt_device: QbusMqttDevice) -> tuple[str, str]: + """Create the device identifier.""" + return (DOMAIN, format_mac(mqtt_device.mac)) + + +def create_unique_id(serial_number: str, suffix: str) -> str: + """Create the unique id.""" + return f"ctd_{serial_number}_{suffix}" class QbusEntity(Entity, Generic[StateT], ABC): @@ -95,16 +104,18 @@ class QbusEntity(Entity, Generic[StateT], ABC): ) ref_id = format_ref_id(mqtt_output.ref_id) - unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + suffix = ref_id or "" if id_suffix: - unique_id += f"_{id_suffix}" + suffix += f"_{id_suffix}" - self._attr_unique_id = unique_id + self._attr_unique_id = create_unique_id( + mqtt_output.device.serial_number, suffix + ) if link_to_main_device: self._attr_device_info = DeviceInfo( - identifiers={create_main_device_identifier(mqtt_output)} + identifiers={create_device_identifier(mqtt_output.device)} ) else: self._attr_device_info = DeviceInfo( @@ -112,7 +123,7 @@ class QbusEntity(Entity, Generic[StateT], ABC): manufacturer=MANUFACTURER, identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, suggested_area=mqtt_output.location.title(), - via_device=create_main_device_identifier(mqtt_output), + via_device=create_device_identifier(mqtt_output.device), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/qbus/icons.json b/homeassistant/components/qbus/icons.json new file mode 100644 index 00000000000..400a2bba935 --- /dev/null +++ b/homeassistant/components/qbus/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "raining": { + "default": "mdi:weather-pouring" + }, + "twilight": { + "default": "mdi:weather-sunset" + } + } + } +} diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index f3a0d108476..87788787baa 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -17,6 +17,14 @@ } }, "entity": { + "binary_sensor": { + "raining": { + "name": "Raining" + }, + "twilight": { + "name": "Twilight" + } + }, "sensor": { "daylight": { "name": "Daylight" diff --git a/tests/components/qbus/snapshots/test_binary_sensor.ambr b/tests/components/qbus/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..79b36db6639 --- /dev/null +++ b/tests/components/qbus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,146 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.ctd_000001-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ctd_000001', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.ctd_000001-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'CTD 000001', + }), + 'context': , + 'entity_id': 'binary_sensor.ctd_000001', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_raining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.weersensor_raining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Raining', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'raining', + 'unique_id': 'ctd_000001_21007_raining', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_raining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weersensor Raining', + }), + 'context': , + 'entity_id': 'binary_sensor.weersensor_raining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_twilight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.weersensor_twilight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Twilight', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'twilight', + 'unique_id': 'ctd_000001_21007_twilight', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.weersensor_twilight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weersensor Twilight', + }), + 'context': , + 'entity_id': 'binary_sensor.weersensor_twilight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/qbus/test_binary_sensor.py b/tests/components/qbus/test_binary_sensor.py new file mode 100644 index 00000000000..9160bdb916e --- /dev/null +++ b/tests/components/qbus/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Test Qbus binary sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_binary_sensor( + hass: HomeAssistant, + setup_integration_deferred: Callable[[], Awaitable], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test binary sensor.""" + + with patch("homeassistant.components.qbus.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration_deferred() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 90157434839522560f6f4b9f53291b255beebaaa Mon Sep 17 00:00:00 2001 From: Alex Thompson Date: Fri, 15 Aug 2025 09:37:08 -0400 Subject: [PATCH 66/73] Bump tilt-ble to 0.3.1 (#150711) --- homeassistant/components/tilt_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tilt_ble/manifest.json b/homeassistant/components/tilt_ble/manifest.json index e22c9d5a1d5..1b178cdb2a6 100644 --- a/homeassistant/components/tilt_ble/manifest.json +++ b/homeassistant/components/tilt_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/tilt_ble", "iot_class": "local_push", - "requirements": ["tilt-ble==0.2.3"] + "requirements": ["tilt-ble==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a8912fc083..46480c71c47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2941,7 +2941,7 @@ thinqconnect==1.0.7 tikteck==0.4 # homeassistant.components.tilt_ble -tilt-ble==0.2.3 +tilt-ble==0.3.1 # homeassistant.components.tilt_pi tilt-pi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83152691f58..c3456a3276f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2421,7 +2421,7 @@ thermopro-ble==0.13.1 thinqconnect==1.0.7 # homeassistant.components.tilt_ble -tilt-ble==0.2.3 +tilt-ble==0.3.1 # homeassistant.components.tilt_pi tilt-pi==0.2.1 From 6c21a14be4927d0269253fccd055e5a9b9b94434 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:37:34 +0200 Subject: [PATCH 67/73] Add binary sensor to 1-Wire DS2405 (#150679) --- .../components/onewire/binary_sensor.py | 7 +++ homeassistant/components/onewire/strings.json | 3 ++ tests/components/onewire/const.py | 1 + .../onewire/snapshots/test_binary_sensor.ambr | 49 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 7d6b3e2c019..c1d34bad60e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -41,6 +41,13 @@ class OneWireBinarySensorEntityDescription( DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = { + "05": ( + OneWireBinarySensorEntityDescription( + key="sensed", + entity_registry_enabled_default=False, + translation_key="sensed", + ), + ), "12": tuple( OneWireBinarySensorEntityDescription( key=f"sensed.{device_key}", diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 5e7719673b1..c77f2933fe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -37,6 +37,9 @@ }, "entity": { "binary_sensor": { + "sensed": { + "name": "Sensed" + }, "sensed_id": { "name": "Sensed {id}" }, diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 370bcc871c6..32804bca28e 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -15,6 +15,7 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: { "/type": [b"DS2405"], "/PIO": [b" 1"], + "/sensed": [b" 1"], }, }, "10.111111111111": { diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index bce1251904a..521e5c50925 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_binary_sensors[binary_sensor.05_111111111111_sensed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.05_111111111111_sensed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed', + 'platform': 'onewire', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensed', + 'unique_id': '/05.111111111111/sensed', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.05_111111111111_sensed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/sensed', + 'friendly_name': '05.111111111111 Sensed', + }), + 'context': , + 'entity_id': 'binary_sensor.05_111111111111_sensed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4f20776e0e130f865fdd5ac351fb40275103b173 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:44:47 +0200 Subject: [PATCH 68/73] Add check for dependency package names in hassfest (#150630) --- script/hassfest/requirements.py | 128 +++++++++++++++++++++++++++- tests/hassfest/test_requirements.py | 118 +++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 9b5334823b9..3f9cc5a0f8a 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -3,8 +3,9 @@ from __future__ import annotations from collections import deque +from collections.abc import Collection from functools import cache -from importlib.metadata import metadata +from importlib.metadata import files, metadata import json import os import re @@ -300,6 +301,64 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } +FORBIDDEN_PACKAGE_NAMES: set[str] = { + "doc", + "docs", + "test", + "tests", +} +FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = { + # In the form dict("domain": {"package": {"reason1", "reason2"}}) + # - domain is the integration domain + # - package is the package (can be transitive) referencing the dependency + # - reasonX should be the name of the invalid dependency + # https://github.com/jaraco/jaraco.net + "abode": {"jaraco-abode": {"jaraco-net"}}, + # https://github.com/coinbase/coinbase-advanced-py + "coinbase": {"homeassistant": {"coinbase-advanced-py"}}, + # https://github.com/ggrammar/pizzapi + "dominos": {"homeassistant": {"pizzapi"}}, + # https://github.com/u9n/dlms-cosem + "dsmr": {"dsmr-parser": {"dlms-cosem"}}, + # https://github.com/ChrisMandich/PyFlume # Fixed with >=0.7.1 + "flume": {"homeassistant": {"pyflume"}}, + # https://github.com/fortinet-solutions-cse/fortiosapi + "fortios": {"homeassistant": {"fortiosapi"}}, + # https://github.com/manzanotti/geniushub-client + "geniushub": {"homeassistant": {"geniushub-client"}}, + # https://github.com/basnijholt/aiokef + "kef": {"homeassistant": {"aiokef"}}, + # https://github.com/danifus/pyzipper + "knx": {"xknxproject": {"pyzipper"}}, + # https://github.com/hthiery/python-lacrosse + "lacrosse": {"homeassistant": {"pylacrosse"}}, + # ??? + "linode": {"homeassistant": {"linode-api"}}, + # https://github.com/timmo001/aiolyric + "lyric": {"homeassistant": {"aiolyric"}}, + # https://github.com/microBeesTech/pythonSDK/ + "microbees": {"homeassistant": {"microbeespy"}}, + # https://github.com/tiagocoutinho/async_modbus + "nibe_heatpump": {"nibe": {"async-modbus"}}, + # https://github.com/ejpenney/pyobihai + "obihai": {"homeassistant": {"pyobihai"}}, + # https://github.com/iamkubi/pydactyl + "pterodactyl": {"homeassistant": {"py-dactyl"}}, + # https://github.com/markusressel/raspyrfm-client + "raspyrfm": {"homeassistant": {"raspyrfm-client"}}, + # https://github.com/sstallion/sensorpush-api + "sensorpush_cloud": { + "homeassistant": {"sensorpush-api"}, + "sensorpush-ha": {"sensorpush-api"}, + }, + # https://github.com/smappee/pysmappee + "smappee": {"homeassistant": {"pysmappee"}}, + # https://github.com/watergate-ai/watergate-local-api-python + "watergate": {"homeassistant": {"watergate-local-api"}}, + # https://github.com/markusressel/xs1-api-client + "xs1": {"homeassistant": {"xs1-api-client"}}, +} + PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # In the form dict("domain": {"package": {"dependency1", "dependency2"}}) # - domain is the integration domain @@ -311,6 +370,8 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { }, } +_packages_checked_files_cache: dict[str, set[str]] = {} + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle requirements for integrations.""" @@ -476,6 +537,12 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ) needs_forbidden_package_exceptions = False + packages_checked_files: set[str] = set() + forbidden_package_files_exceptions = FORBIDDEN_PACKAGE_FILES_EXCEPTIONS.get( + integration.domain, {} + ) + needs_forbidden_package_files_exception = False + package_version_check_exceptions = PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS.get( integration.domain, {} ) @@ -517,6 +584,17 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: f"({requires_python}) in {package}", ) + # Check package names + if package not in packages_checked_files: + packages_checked_files.add(package) + if not check_dependency_files( + integration, + "homeassistant", + package, + forbidden_package_files_exceptions.get("homeassistant", ()), + ): + needs_forbidden_package_files_exception = True + # Use inner loop to check dependencies # so we have access to the dependency parent (=current package) dependencies: dict[str, str] = item["dependencies"] @@ -540,6 +618,17 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: ): needs_package_version_check_exception = True + # Check package names + if pkg not in packages_checked_files: + packages_checked_files.add(pkg) + if not check_dependency_files( + integration, + package, + pkg, + forbidden_package_files_exceptions.get(package, ()), + ): + needs_forbidden_package_files_exception = True + to_check.extend(dependencies) if forbidden_package_exceptions and not needs_forbidden_package_exceptions: @@ -560,6 +649,15 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: f"Integration {integration.domain} version restrictions for Python have " "been resolved, please remove from `PYTHON_VERSION_CHECK_EXCEPTIONS`", ) + if ( + forbidden_package_files_exceptions + and not needs_forbidden_package_files_exception + ): + integration.add_error( + "requirements", + f"Integration {integration.domain} runtime files dependency exceptions " + "have been resolved, please remove from `FORBIDDEN_PACKAGE_FILES_EXCEPTIONS`", + ) return all_requirements @@ -635,6 +733,34 @@ def _is_dependency_version_range_valid( return False +def check_dependency_files( + integration: Integration, + package: str, + pkg: str, + package_exceptions: Collection[str], +) -> bool: + """Check dependency files for forbidden package names.""" + if (results := _packages_checked_files_cache.get(pkg)) is None: + top_level: set[str] = set() + for file in files(pkg) or (): + top = file.parts[0].lower() + if top.endswith((".dist-info", ".py")): + continue + top_level.add(top) + results = FORBIDDEN_PACKAGE_NAMES & top_level + _packages_checked_files_cache[pkg] = results + if not results: + return True + + for dir_name in results: + integration.add_warning_or_error( + pkg in package_exceptions, + "requirements", + f"Package {pkg} has a forbidden top level directory {dir_name} in {package}", + ) + return False + + def install_requirements(integration: Integration, requirements: set[str]) -> bool: """Install integration requirements. diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index dcd35a3aca7..944b06d3c90 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -1,5 +1,7 @@ """Tests for hassfest requirements.""" +from collections.abc import Generator +from importlib.metadata import PackagePath from pathlib import Path from unittest.mock import patch @@ -7,8 +9,11 @@ import pytest from script.hassfest.model import Config, Integration from script.hassfest.requirements import ( + FORBIDDEN_PACKAGE_NAMES, PACKAGE_CHECK_PREPARE_UPDATE, PACKAGE_CHECK_VERSION_RANGE, + _packages_checked_files_cache, + check_dependency_files, check_dependency_version_range, validate_requirements_format, ) @@ -35,6 +40,19 @@ def integration(): ) +@pytest.fixture +def mock_forbidden_package_names() -> Generator[None]: + """Fixture for FORBIDDEN_PACKAGE_NAMES.""" + # pylint: disable-next=global-statement + global FORBIDDEN_PACKAGE_NAMES # noqa: PLW0603 + original = FORBIDDEN_PACKAGE_NAMES.copy() + FORBIDDEN_PACKAGE_NAMES = {"test", "tests"} + try: + yield + finally: + FORBIDDEN_PACKAGE_NAMES = original + + def test_validate_requirements_format_with_space(integration: Integration) -> None: """Test validate requirement with space around separator.""" integration.manifest["requirements"] = ["test_package == 1"] @@ -149,3 +167,103 @@ def test_dependency_version_range_prepare_update( ) == result ) + + +@pytest.mark.usefixtures("mock_forbidden_package_names") +def test_check_dependency_files(integration: Integration) -> None: + """Test dependency files check for forbidden package names is working correctly.""" + package = "homeassistant" + pkg = "my_package" + + # Forbidden top level directories: test, tests + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package-1.0.0.dist-info/METADATA"), + PackagePath("tests/test_some_function.py"), + PackagePath("test/submodule/test_some_other_function.py"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert _packages_checked_files_cache[pkg] == {"tests", "test"} + assert len(integration.errors) == 2 + assert ( + f"Package {pkg} has a forbidden top level directory tests in {package}" + in x.error + for x in integration.errors + ) + assert ( + f"Package {pkg} has a forbidden top level directory test in {package}" + in x.error + for x in integration.errors + ) + integration.errors.clear() + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert mock_files.call_count == 1 + assert len(integration.errors) == 2 + integration.errors.clear() + + # Exceptions set + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package.dist-info/METADATA"), + PackagePath("tests/test_some_function.py"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert ( + check_dependency_files(integration, package, pkg, package_exceptions={pkg}) + is False + ) + assert _packages_checked_files_cache[pkg] == {"tests"} + assert len(integration.errors) == 0 + assert len(integration.warnings) == 1 + assert ( + f"Package {pkg} has a forbidden top level directory tests in {package}" + in x.error + for x in integration.warnings + ) + integration.warnings.clear() + + # Repeated call should use cache + assert ( + check_dependency_files(integration, package, pkg, package_exceptions={pkg}) + is False + ) + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 + assert len(integration.warnings) == 1 + integration.warnings.clear() + + # All good + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert _packages_checked_files_cache[pkg] == set() + assert len(integration.errors) == 0 + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 From eaedefe1055dcf6a96800d8dea6f620fa8581697 Mon Sep 17 00:00:00 2001 From: Nick Kuiper <65495045+NickKoepr@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:45:40 +0200 Subject: [PATCH 69/73] Update bluecurrent-api to 1.3.1 (#150559) --- homeassistant/components/blue_current/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index 84604c62951..7c76657eb79 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", "loggers": ["bluecurrent_api"], - "requirements": ["bluecurrent-api==1.2.4"] + "requirements": ["bluecurrent-api==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 46480c71c47..810643c99bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ blinkpy==0.23.0 blockchain==1.4.4 # homeassistant.components.blue_current -bluecurrent-api==1.2.4 +bluecurrent-api==1.3.1 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3456a3276f..6943a3c83ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -574,7 +574,7 @@ blebox-uniapi==2.5.0 blinkpy==0.23.0 # homeassistant.components.blue_current -bluecurrent-api==1.2.4 +bluecurrent-api==1.3.1 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 From 7670146faf11957865cabdf7df6768e41609cb29 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 15 Aug 2025 15:51:48 +0200 Subject: [PATCH 70/73] Improve handling decode errors in rest (#150699) --- homeassistant/components/rest/data.py | 16 ++++++-- tests/components/rest/test_data.py | 57 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 3341f296fb9..2964ef73d46 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -45,6 +45,7 @@ class RestData: self._method = method self._resource = resource self._encoding = encoding + self._force_use_set_encoding = False # Convert auth tuple to aiohttp.BasicAuth if needed if isinstance(auth, tuple) and len(auth) == 2: @@ -152,10 +153,19 @@ class RestData: # Read the response # Only use configured encoding if no charset in Content-Type header # If charset is present in Content-Type, let aiohttp use it - if response.charset: + if self._force_use_set_encoding is False and response.charset: # Let aiohttp use the charset from Content-Type header - self.data = await response.text() - else: + try: + self.data = await response.text() + except UnicodeDecodeError as ex: + self._force_use_set_encoding = True + _LOGGER.debug( + "Response charset came back as %s but could not be decoded, continue with configured encoding %s. %s", + response.charset, + self._encoding, + ex, + ) + if self._force_use_set_encoding or not response.charset: # Use configured encoding as fallback self.data = await response.text(encoding=self._encoding) self.headers = response.headers diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py index 4d6bc000fac..01581c8ac68 100644 --- a/tests/components/rest/test_data.py +++ b/tests/components/rest/test_data.py @@ -1,13 +1,17 @@ """Test REST data module logging improvements.""" +from datetime import timedelta import logging +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.rest import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -89,6 +93,59 @@ async def test_rest_data_no_warning_on_200_with_wrong_content_type( ) +async def test_rest_data_with_incorrect_charset_in_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that we can handle sites which provides an incorrect charset.""" + aioclient_mock.get( + "http://example.com/api", + status=200, + text="

Some html

", + headers={"Content-Type": "text/html; charset=utf-8"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "encoding": "windows-1250", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + with patch( + "tests.test_util.aiohttp.AiohttpClientMockResponse.text", + side_effect=UnicodeDecodeError("utf-8", b"", 1, 0, ""), + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + log_text = "Response charset came back as utf-8 but could not be decoded, continue with configured encoding windows-1250." + assert log_text in caplog.text + + caplog.clear() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Only log once as we only try once with automatic decoding + assert log_text not in caplog.text + + async def test_rest_data_no_warning_on_success_json( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 793a82923603bd64ef472226fcd5fcec4022c75a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:52:01 +0200 Subject: [PATCH 71/73] Add serial number to Vodafone Station device (#150709) --- homeassistant/components/vodafone_station/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 57d39151160..35c32ab2af3 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -187,4 +187,5 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): model=sensors_data.get("sys_model_name"), hw_version=sensors_data["sys_hardware_version"], sw_version=sensors_data["sys_firmware_version"], + serial_number=self.serial_number, ) From d5a74892e6f9eb2d9dc6b08588cca0a9abe81936 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Aug 2025 15:52:13 +0200 Subject: [PATCH 72/73] Remove unnecessary hass assignment in coordinators (#150696) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/android_ip_webcam/coordinator.py | 3 +-- homeassistant/components/fritz/coordinator.py | 1 - homeassistant/components/glances/coordinator.py | 1 - homeassistant/components/homeassistant_hardware/coordinator.py | 1 - homeassistant/components/lacrosse_view/coordinator.py | 1 - homeassistant/components/livisi/coordinator.py | 1 - homeassistant/components/plaato/coordinator.py | 1 - homeassistant/components/rachio/coordinator.py | 2 -- homeassistant/components/romy/coordinator.py | 1 - homeassistant/components/speedtestdotnet/coordinator.py | 3 +-- homeassistant/components/wemo/coordinator.py | 1 - 11 files changed, 2 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/android_ip_webcam/coordinator.py b/homeassistant/components/android_ip_webcam/coordinator.py index c72d6ae1177..ec701cdf7d3 100644 --- a/homeassistant/components/android_ip_webcam/coordinator.py +++ b/homeassistant/components/android_ip_webcam/coordinator.py @@ -30,10 +30,9 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]): cam: PyDroidIPCam, ) -> None: """Initialize the Android IP Webcam.""" - self.hass = hass self.cam = cam super().__init__( - self.hass, + hass, _LOGGER, config_entry=config_entry, name=f"{DOMAIN} {config_entry.data[CONF_HOST]}", diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index d8d3bbd7a53..25687f0061a 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -120,7 +120,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.fritz_guest_wifi: FritzGuestWLAN = None self.fritz_hosts: FritzHosts = None self.fritz_status: FritzStatus = None - self.hass = hass self.host = host self.mesh_role = MeshRoles.NONE self.mesh_wifi_uplink = False diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 28cf40aae6e..5df8fe1b2e4 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -29,7 +29,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, entry: GlancesConfigEntry, api: Glances ) -> None: """Initialize the Glances data.""" - self.hass = hass self.host: str = entry.data[CONF_HOST] self.api = api super().__init__( diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py index 36a2f407282..6c4b2cb38e4 100644 --- a/homeassistant/components/homeassistant_hardware/coordinator.py +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -40,7 +40,6 @@ class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): update_interval=FIRMWARE_REFRESH_INTERVAL, config_entry=config_entry, ) - self.hass = hass self.session = session self.client = FirmwareUpdateClient(url, session) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 1499dd02900..c6f3c2312c0 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -42,7 +42,6 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): self.last_update = time() self.username = entry.data["username"] self.password = entry.data["password"] - self.hass = hass self.name = entry.data["name"] self.id = entry.data["id"] super().__init__( diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 8d490dca952..1339ae7d68c 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -45,7 +45,6 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): name="Livisi devices", update_interval=timedelta(seconds=DEVICE_POLLING_DELAY), ) - self.hass = hass self.aiolivisi = aiolivisi self.websocket = Websocket(aiolivisi) self.devices: set[str] = set() diff --git a/homeassistant/components/plaato/coordinator.py b/homeassistant/components/plaato/coordinator.py index df360d50068..74ff8566729 100644 --- a/homeassistant/components/plaato/coordinator.py +++ b/homeassistant/components/plaato/coordinator.py @@ -31,7 +31,6 @@ class PlaatoCoordinator(DataUpdateCoordinator): ) -> None: """Initialize.""" self.api = Plaato(auth_token=auth_token) - self.hass = hass self.device_type = device_type self.platforms: list[Platform] = [] diff --git a/homeassistant/components/rachio/coordinator.py b/homeassistant/components/rachio/coordinator.py index 62d42f2afda..6d482e9c900 100644 --- a/homeassistant/components/rachio/coordinator.py +++ b/homeassistant/components/rachio/coordinator.py @@ -44,7 +44,6 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): base_count: int, ) -> None: """Initialize the Rachio Update Coordinator.""" - self.hass = hass self.rachio = rachio self.base_station = base_station super().__init__( @@ -83,7 +82,6 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]] base_station, ) -> None: """Initialize a Rachio schedule coordinator.""" - self.hass = hass self.rachio = rachio self.base_station = base_station super().__init__( diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index d666ec44f80..de5352191d7 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -25,7 +25,6 @@ class RomyVacuumCoordinator(DataUpdateCoordinator[None]): name=DOMAIN, update_interval=UPDATE_INTERVAL, ) - self.hass = hass self.romy = romy async def _async_update_data(self) -> None: diff --git a/homeassistant/components/speedtestdotnet/coordinator.py b/homeassistant/components/speedtestdotnet/coordinator.py index 1308cb1d825..fac78a113f2 100644 --- a/homeassistant/components/speedtestdotnet/coordinator.py +++ b/homeassistant/components/speedtestdotnet/coordinator.py @@ -29,11 +29,10 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): api: speedtest.Speedtest, ) -> None: """Initialize the data object.""" - self.hass = hass self.api = api self.servers: dict[str, dict] = {DEFAULT_SERVER: {}} super().__init__( - self.hass, + hass, _LOGGER, config_entry=config_entry, name=DOMAIN, diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 6cda83f6419..cb3c8a558b6 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -102,7 +102,6 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): name=wemo.name, update_interval=timedelta(seconds=30), ) - self.hass = hass self.wemo = wemo self.device_id: str | None = None self.device_info = _create_device_info(wemo) From d5970e7733bc51919d8c5b96eb7d4572ce506f86 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 15 Aug 2025 16:52:36 +0300 Subject: [PATCH 73/73] Anthropic thinking content (#150341) --- homeassistant/components/anthropic/entity.py | 185 +++++++++--------- .../snapshots/test_conversation.ambr | 53 ++++- .../components/anthropic/test_conversation.py | 5 +- 3 files changed, 148 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index a58130ccd92..7338cbe2906 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -2,11 +2,10 @@ from collections.abc import AsyncGenerator, Callable, Iterable import json -from typing import Any, cast +from typing import Any import anthropic from anthropic import AsyncStream -from anthropic._types import NOT_GIVEN from anthropic.types import ( InputJSONDelta, MessageDeltaUsage, @@ -17,7 +16,6 @@ from anthropic.types import ( RawContentBlockStopEvent, RawMessageDeltaEvent, RawMessageStartEvent, - RawMessageStopEvent, RedactedThinkingBlock, RedactedThinkingBlockParam, SignatureDelta, @@ -35,6 +33,7 @@ from anthropic.types import ( ToolUseBlockParam, Usage, ) +from anthropic.types.message_create_params import MessageCreateParamsStreaming from voluptuous_openapi import convert from homeassistant.components import conversation @@ -129,6 +128,28 @@ def _convert_content( ) ) + if isinstance(content.native, ThinkingBlock): + messages[-1]["content"].append( # type: ignore[union-attr] + ThinkingBlockParam( + type="thinking", + thinking=content.thinking_content or "", + signature=content.native.signature, + ) + ) + elif isinstance(content.native, RedactedThinkingBlock): + redacted_thinking_block = RedactedThinkingBlockParam( + type="redacted_thinking", + data=content.native.data, + ) + if isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + redacted_thinking_block, + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + redacted_thinking_block + ) if content.content: messages[-1]["content"].append( # type: ignore[union-attr] TextBlockParam(type="text", text=content.content) @@ -152,10 +173,9 @@ def _convert_content( return messages -async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place +async def _transform_stream( chat_log: conversation.ChatLog, - result: AsyncStream[MessageStreamEvent], - messages: list[MessageParam], + stream: AsyncStream[MessageStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform the response stream into HA format. @@ -186,31 +206,25 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have Each message could contain multiple blocks of the same type. """ - if result is None: + if stream is None: raise TypeError("Expected a stream of messages") - current_message: MessageParam | None = None - current_block: ( - TextBlockParam - | ToolUseBlockParam - | ThinkingBlockParam - | RedactedThinkingBlockParam - | None - ) = None + current_tool_block: ToolUseBlockParam | None = None current_tool_args: str input_usage: Usage | None = None + has_content = False + has_native = False - async for response in result: + async for response in stream: LOGGER.debug("Received response: %s", response) if isinstance(response, RawMessageStartEvent): if response.message.role != "assistant": raise ValueError("Unexpected message role") - current_message = MessageParam(role=response.message.role, content=[]) input_usage = response.message.usage elif isinstance(response, RawContentBlockStartEvent): if isinstance(response.content_block, ToolUseBlock): - current_block = ToolUseBlockParam( + current_tool_block = ToolUseBlockParam( type="tool_use", id=response.content_block.id, name=response.content_block.name, @@ -218,75 +232,64 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have ) current_tool_args = "" elif isinstance(response.content_block, TextBlock): - current_block = TextBlockParam( - type="text", text=response.content_block.text - ) - yield {"role": "assistant"} + if has_content: + yield {"role": "assistant"} + has_native = False + has_content = True if response.content_block.text: yield {"content": response.content_block.text} elif isinstance(response.content_block, ThinkingBlock): - current_block = ThinkingBlockParam( - type="thinking", - thinking=response.content_block.thinking, - signature=response.content_block.signature, - ) + if has_native: + yield {"role": "assistant"} + has_native = False + has_content = False elif isinstance(response.content_block, RedactedThinkingBlock): - current_block = RedactedThinkingBlockParam( - type="redacted_thinking", data=response.content_block.data - ) LOGGER.debug( "Some of Claude’s internal reasoning has been automatically " "encrypted for safety reasons. This doesn’t affect the quality of " "responses" ) + if has_native: + yield {"role": "assistant"} + has_native = False + has_content = False + yield {"native": response.content_block} + has_native = True elif isinstance(response, RawContentBlockDeltaEvent): - if current_block is None: - raise ValueError("Unexpected delta without a block") if isinstance(response.delta, InputJSONDelta): current_tool_args += response.delta.partial_json elif isinstance(response.delta, TextDelta): - text_block = cast(TextBlockParam, current_block) - text_block["text"] += response.delta.text yield {"content": response.delta.text} elif isinstance(response.delta, ThinkingDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["thinking"] += response.delta.thinking + yield {"thinking_content": response.delta.thinking} elif isinstance(response.delta, SignatureDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["signature"] += response.delta.signature + yield { + "native": ThinkingBlock( + type="thinking", + thinking="", + signature=response.delta.signature, + ) + } + has_native = True elif isinstance(response, RawContentBlockStopEvent): - if current_block is None: - raise ValueError("Unexpected stop event without a current block") - if current_block["type"] == "tool_use": - # tool block + if current_tool_block is not None: tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_block["input"] = tool_args + current_tool_block["input"] = tool_args yield { "tool_calls": [ llm.ToolInput( - id=current_block["id"], - tool_name=current_block["name"], + id=current_tool_block["id"], + tool_name=current_tool_block["name"], tool_args=tool_args, ) ] } - elif current_block["type"] == "thinking": - # thinking block - LOGGER.debug("Thinking: %s", current_block["thinking"]) - - if current_message is None: - raise ValueError("Unexpected stop event without a current message") - current_message["content"].append(current_block) # type: ignore[union-attr] - current_block = None + current_tool_block = None elif isinstance(response, RawMessageDeltaEvent): if (usage := response.usage) is not None: chat_log.async_trace(_create_token_stats(input_usage, usage)) if response.delta.stop_reason == "refusal": raise HomeAssistantError("Potential policy violation detected") - elif isinstance(response, RawMessageStopEvent): - if current_message is not None: - messages.append(current_message) - current_message = None def _create_token_stats( @@ -351,48 +354,48 @@ class AnthropicBaseLLMEntity(Entity): thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model_args = MessageCreateParamsStreaming( + model=model, + messages=messages, + max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + system=system.content, + stream=True, + ) + if tools: + model_args["tools"] = tools + if ( + model.startswith(tuple(THINKING_MODELS)) + and thinking_budget >= MIN_THINKING_BUDGET + ): + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget + ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - "system": system.content, - "stream": True, - } - if ( - model.startswith(tuple(THINKING_MODELS)) - and thinking_budget >= MIN_THINKING_BUDGET - ): - model_args["thinking"] = ThinkingConfigEnabledParam( - type="enabled", budget_tokens=thinking_budget - ) - else: - model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") - model_args["temperature"] = options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ) - try: stream = await client.messages.create(**model_args) + + messages.extend( + _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_log, stream), + ) + ] + ) + ) except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" ) from err - messages.extend( - _convert_content( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_log, stream, messages), - ) - if not isinstance(content, conversation.AssistantContent) - ] - ) - ) - if not chat_log.unresponded_tool_results: break diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 9afa6bf5d76..95cc02f4576 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -18,10 +18,26 @@ }), dict({ 'agent_id': 'conversation.claude_conversation', - 'content': 'Certainly, calling it now!', - 'native': None, + 'content': None, + 'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'), + 'role': 'assistant', + 'thinking_content': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), 'role': 'assistant', 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Certainly, calling it now!', + 'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'), + 'role': 'assistant', + 'thinking_content': "Okay, let's give it a shot. Will I pass the test?", 'tool_calls': list([ dict({ 'id': 'toolu_0123456789AbCdEfGhIjKlM', @@ -321,6 +337,39 @@ }), ]) # --- +# name: test_redacted_thinking + list([ + dict({ + 'attachments': None, + 'content': 'ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A98432ECCCE4C1253D5E2D82641AC0E52CC2876CB', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'How can I help you today?', + 'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- # name: test_unknown_hass_api dict({ 'continue_conversation': False, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 83770e7ee34..f8cccd786fc 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -728,6 +728,7 @@ async def test_redacted_thinking( hass: HomeAssistant, mock_config_entry_with_extended_thinking: MockConfigEntry, mock_init_component, + snapshot: SnapshotAssertion, ) -> None: """Test extended thinking with redacted thinking blocks.""" with patch( @@ -756,8 +757,8 @@ async def test_redacted_thinking( chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( result.conversation_id ) - assert len(chat_log.content) == 3 - assert chat_log.content[2].content == "How can I help you today?" + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot @patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")