diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b6bdcad1c7194c..bd72cf9fc96828 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,7 +21,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Python Syntax Notes -- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14. ## Testing diff --git a/AGENTS.md b/AGENTS.md index d9340c42027605..406a618c2b1db6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Python Syntax Notes -- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14. ## Testing diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 0f0213ec984d95..70ffee8973fc33 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -11,7 +11,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfVolume +from homeassistant.const import UnitOfVolume, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -34,7 +34,8 @@ key="current_interval", translation_key="current_interval", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -65,14 +66,16 @@ key="last_60_min", translation_key="last_60_min", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/h", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_HOUR, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="last_24_hrs", translation_key="last_24_hrs", suggested_display_precision=2, - native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/d", + native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_DAY, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e9ec83fd8e412d..f5ba1caabf7239 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.7"] + "requirements": ["home-assistant-frontend==20260325.8"] } diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 7adae8d87465e4..92838755cd002e 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -87,7 +87,18 @@ async def async_setup_entry( class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): - """Update entity to handle updates for the Supervisor add-ons.""" + """Update entity to handle updates for the Supervisor add-ons. + + The ``addon_manager_update`` job emits a ``done=True`` WS event as soon as + Supervisor finishes the container work, a few milliseconds before the + ``/store/addons//update`` HTTP call returns. If we clear + ``_attr_in_progress`` on that event while the coordinator data still + carries the pre-update version, the UI briefly flips back to + "Update available" before ``async_install`` can refresh. ``_update_ongoing`` + survives both the WS done event and the base ``UpdateEntity`` reset, so + the installing state remains until the coordinator confirms a new + ``installed_version``. + """ _attr_supported_features = ( UpdateEntityFeature.INSTALL @@ -95,6 +106,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): | UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.PROGRESS ) + _update_ongoing: bool = False + _version_before_update: str | None = None @property def _addon_data(self) -> dict: @@ -121,6 +134,13 @@ def installed_version(self) -> str | None: """Version installed and in use.""" return self._addon_data[ATTR_VERSION] + @property + def in_progress(self) -> bool | None: + """Return combined progress from the update job and refresh phase.""" + if self._update_ongoing: + return True + return self._attr_in_progress + @property def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" @@ -154,13 +174,34 @@ async def async_install( **kwargs: Any, ) -> None: """Install an update.""" + self._version_before_update = self.installed_version + self._update_ongoing = True self._attr_in_progress = True self.async_write_ha_state() - await update_addon( - self.hass, self._addon_slug, backup, self.title, self.installed_version - ) + try: + await update_addon( + self.hass, self._addon_slug, backup, self.title, self.installed_version + ) + except HomeAssistantError: + self._update_ongoing = False + self._version_before_update = None + self._attr_in_progress = False + self._attr_update_percentage = None + self.async_write_ha_state() + raise await self.coordinator.async_refresh() + @callback + def _handle_coordinator_update(self) -> None: + """Clear the ongoing flag once the installed version has changed.""" + if ( + self._update_ongoing + and self.installed_version != self._version_before_update + ): + self._update_ongoing = False + self._version_before_update = None + super()._handle_coordinator_update() + @callback def _update_job_changed(self, job: Job) -> None: """Process update for this entity's update job.""" diff --git a/homeassistant/components/indevolt/button.py b/homeassistant/components/indevolt/button.py index 6abcf50048bee9..320c5ce4f54c6d 100644 --- a/homeassistant/components/indevolt/button.py +++ b/homeassistant/components/indevolt/button.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Final +from indevolt_api import IndevoltRealtimeAction + from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -66,5 +68,4 @@ def __init__( async def async_press(self) -> None: """Handle the button press.""" - - await self.coordinator.async_execute_realtime_action([0, 0, 0]) + await self.coordinator.async_realtime_action(IndevoltRealtimeAction.STOP) diff --git a/homeassistant/components/indevolt/const.py b/homeassistant/components/indevolt/const.py index 3b469282a643c3..cf815c56315f57 100644 --- a/homeassistant/components/indevolt/const.py +++ b/homeassistant/components/indevolt/const.py @@ -16,8 +16,7 @@ ENERGY_MODE_WRITE_KEY: Final = "47005" PORTABLE_MODE: Final = 0 -# API write key and value for real-time control mode -REALTIME_ACTION_KEY: Final = "47015" +# Value for real-time control mode REALTIME_ACTION_MODE: Final = 4 # API key fields diff --git a/homeassistant/components/indevolt/coordinator.py b/homeassistant/components/indevolt/coordinator.py index 19320eec5441f6..a7f7844a575e45 100644 --- a/homeassistant/components/indevolt/coordinator.py +++ b/homeassistant/components/indevolt/coordinator.py @@ -7,7 +7,7 @@ from typing import Any, Final from aiohttp import ClientError -from indevolt_api import IndevoltAPI, TimeOutException +from indevolt_api import IndevoltAPI, IndevoltRealtimeAction, TimeOutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL @@ -24,7 +24,6 @@ ENERGY_MODE_READ_KEY, ENERGY_MODE_WRITE_KEY, PORTABLE_MODE, - REALTIME_ACTION_KEY, REALTIME_ACTION_MODE, SENSOR_KEYS, ) @@ -146,19 +145,16 @@ async def async_switch_energy_mode( if refresh: await self.async_request_refresh() - async def async_execute_realtime_action(self, action: list[int]) -> None: + async def async_realtime_action( + self, + action_code: IndevoltRealtimeAction, + ) -> None: """Switch mode, execute action, and refresh for real-time control.""" - await self.async_switch_energy_mode(REALTIME_ACTION_MODE, refresh=False) - try: - success = await self.async_push_data(REALTIME_ACTION_KEY, action) - - except (DeviceTimeoutError, DeviceConnectionError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="failed_to_execute_realtime_action", - ) from err + match action_code: + case IndevoltRealtimeAction.STOP: + success = await self.api.stop() if not success: raise HomeAssistantError( diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json index 0362f7d2224cb5..ad9f3c1a17f9b5 100644 --- a/homeassistant/components/israel_rail/manifest.json +++ b/homeassistant/components/israel_rail/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["israelrailapi"], - "requirements": ["israel-rail-api==0.1.4"] + "requirements": ["israel-rail-api==0.1.5"] } diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index bce75244d6d6db..375779bc99c383 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -440,6 +440,31 @@ def _update_from_device(self) -> None: featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared, allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="BooleanStateConfigurationCurrentSensitivityLevel", + entity_category=EntityCategory.CONFIG, + translation_key="sensitivity_level", + native_min_value=1, + native_step=1, + device_to_ha=lambda x: x + 1, + ha_to_device=lambda x: int(x) - 1, + max_attribute=( + clusters.BooleanStateConfiguration.Attributes.SupportedSensitivityLevels + ), + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel, + clusters.BooleanStateConfiguration.Attributes.SupportedSensitivityLevels, + ), + featuremap_contains=( + clusters.BooleanStateConfiguration.Bitmaps.Feature.kSensitivityLevel + ), + allow_multi=True, + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 21ee86302b82ec..481ebf6ade3b9f 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -559,11 +559,15 @@ def _update_from_device(self) -> None: clusters.PumpConfigurationAndControl.Attributes.OperationMode, ), ), + # Keep the legacy vendor-specific select entities until HA 2026.11.0, + # so existing users can migrate before we remove them in favor of the + # generic number slider. MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["10 mm", "20 mm", "30 mm"], device_to_ha={ @@ -583,12 +587,14 @@ def _update_from_device(self) -> None: ), vendor_id=(4447,), product_id=(8194,), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ @@ -611,12 +617,14 @@ def _update_from_device(self) -> None: 8197, 8195, ), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="HeimanOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, translation_key="sensitivity_level", options=["low", "standard", "high"], device_to_ha={ @@ -636,6 +644,7 @@ def _update_from_device(self) -> None: ), vendor_id=(4619,), product_id=(4097,), + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.SELECT, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1286b8bae94487..a6c196c6ba50e0 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -259,6 +259,9 @@ "pump_setpoint": { "name": "Setpoint" }, + "sensitivity_level": { + "name": "Sensitivity" + }, "speaker_setpoint": { "name": "Volume" }, diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 32e733265a81b2..b47aa78cb08359 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -84,6 +84,7 @@ Platform.COVER, Platform.EVENT, Platform.LIGHT, + Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 9f0b7d5dd888fe..6c755959faf377 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -219,8 +219,6 @@ BLOCK_WRONG_SLEEP_PERIOD = 21600 BLOCK_EXPECTED_SLEEP_PERIOD = 43200 -UPTIME_DEVIATION: Final = 60 - # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 diff --git a/homeassistant/components/shelly/media_player.py b/homeassistant/components/shelly/media_player.py new file mode 100644 index 00000000000000..8479f2e72e779c --- /dev/null +++ b/homeassistant/components/shelly/media_player.py @@ -0,0 +1,430 @@ +"""Media player for Shelly.""" + +from __future__ import annotations + +import base64 +import binascii +from dataclasses import dataclass +import datetime +import hashlib +from typing import Any, Final, cast + +from aioshelly.const import RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError + +from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityDescription, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator +from .entity import ( + RpcEntityDescription, + ShellyRpcAttributeEntity, + async_setup_entry_rpc, + rpc_call, +) +from .utils import get_device_entry_gen + +CONTENT_TYPE_AUDIO = "audio" +CONTENT_TYPE_RADIO = "radio" + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RpcMediaPlayerDescription(RpcEntityDescription, MediaPlayerEntityDescription): + """Class to describe a Shelly RPC media player entity.""" + + +RPC_MEDIA_PLAYER_ENTITIES: Final = { + "media": RpcMediaPlayerDescription( + key="media", + device_class=MediaPlayerDeviceClass.SPEAKER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up media player for Shelly devices.""" + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: + return _async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return None + + +@callback +def _async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_MEDIA_PLAYER_ENTITIES, + ShellyRpcMediaPlayer, + ) + + +class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity): + """Representation of a Shelly RPC media player entity.""" + + _attr_name = None + _attr_supported_features = ( + MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + ) + _attr_media_content_type = MediaType.MUSIC + entity_description: RpcMediaPlayerDescription + + _last_media_position: int | None = None + _last_media_position_updated_at: datetime.datetime | None = None + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcMediaPlayerDescription, + ) -> None: + """Initialize Shelly RPC media player.""" + super().__init__(coordinator, key, attribute, description) + + @property + def _media_meta(self) -> dict[str, Any]: + """Return the media metadata.""" + return cast(dict[str, Any], self.status["playback"].get("media_meta", {})) + + @property + def state(self) -> MediaPlayerState: + """Return the state of the media player.""" + if self.status["playback"]["buffering"]: + return MediaPlayerState.BUFFERING + + if self.status["playback"]["enable"]: + return MediaPlayerState.PLAYING + + return MediaPlayerState.IDLE + + @property + def volume_level(self) -> float | None: + """Return the volume level of the media player (0..1).""" + volume = self.status["playback"]["volume"] + + return cast(float, volume) / 10 + + @property + def media_title(self) -> str | None: + """Return the title of current playing media.""" + if title := self._media_meta.get("title"): + return cast(str, title) + + return None + + @property + def media_artist(self) -> str | None: + """Return the artist of current playing media.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if artist := self._media_meta.get("artist"): + return cast(str, artist) + + return None + + @property + def media_album_name(self) -> str | None: + """Return the album name of current playing media.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if album := self._media_meta.get("album"): + return cast(str, album) + + return None + + @property + def media_duration(self) -> int | None: + """Return the duration of current playing media in seconds.""" + if self.status["playback"].get("media_type") == "RADIO": + return None + + if (duration := self._media_meta.get("duration")) is not None: + return cast(int, duration) // 1000 + + return None + + @property + def media_position(self) -> int | None: + """Return the current playback position in seconds.""" + if (position := self._get_updated_media_position()) is not None: + return position // 1000 + + return None + + @property + def media_position_updated_at(self) -> datetime.datetime | None: + """Return when the position was last updated.""" + self._get_updated_media_position() + + return self._last_media_position_updated_at + + @property + def media_image_url(self) -> str | None: + """Return the image URL of current playing media.""" + if (thumb := self._media_meta.get("thumb")) and thumb.startswith("http"): + return cast(str, thumb) + + return None + + @property + def media_image_remotely_accessible(self) -> bool: + """Return True if the image URL is remotely accessible.""" + return self.media_image_url is not None + + @property + def media_image_hash(self) -> str | None: + """Hash value for media image.""" + if (thumb := self._media_meta.get("thumb")) and thumb.startswith("data"): + return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16] + return super().media_image_hash + + def _get_updated_media_position(self) -> int | None: + """Return the current playback position and update its timestamp.""" + if (position := self._media_meta.get("position")) is None: + self._last_media_position = None + self._last_media_position_updated_at = None + return None + + current_position = cast(int, position) + if current_position != self._last_media_position: + self._last_media_position = current_position + self._last_media_position_updated_at = dt_util.utcnow() + + return current_position + + async def async_get_media_image(self) -> tuple[bytes | None, str | None]: + """Fetch media image of current playing track.""" + thumb = self._media_meta["thumb"] + try: + prefix, image_data = thumb.split(",", 1) + image = base64.b64decode(image_data, validate=True) + mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1] + except binascii.Error, ValueError: + return await super().async_get_media_image() + + return image, mime + + @rpc_call + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + await self.coordinator.device.media_play_or_pause() + + @rpc_call + async def async_media_pause(self) -> None: + """Send pause command.""" + if self.state == MediaPlayerState.PLAYING: + await self.coordinator.device.media_play_or_pause() + + @rpc_call + async def async_media_stop(self) -> None: + """Send stop command.""" + await self.coordinator.device.media_stop() + + @rpc_call + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self.coordinator.device.media_next() + + @rpc_call + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.coordinator.device.media_previous() + + @rpc_call + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.coordinator.device.media_set_volume(round(volume * 10)) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse radio stations and audio files.""" + if not media_content_type: + return await self._async_browse_media_root() + + try: + if media_content_type == CONTENT_TYPE_RADIO: + return await self._async_browse_radio_stations(expanded=True) + if media_content_type == CONTENT_TYPE_AUDIO: + return await self._async_browse_audio_files(expanded=True) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError as err: + await self.coordinator.async_shutdown_device_and_start_reauth() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={ + "device": self.coordinator.name, + }, + ) from err + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_content_type", + translation_placeholders={"media_content_type": str(media_content_type)}, + ) + + async def _async_browse_media_root(self) -> BrowseMedia: + """Return root BrowseMedia tree.""" + return BrowseMedia( + title="Shelly", + media_class=MediaClass.DIRECTORY, + media_content_type="", + media_content_id="", + children=[ + await self._async_browse_radio_stations(), + await self._async_browse_audio_files(), + ], + can_play=False, + can_expand=True, + ) + + async def _async_browse_audio_files(self, expanded: bool = False) -> BrowseMedia: + """Return BrowseMedia tree for audio files.""" + if expanded: + result: list[ + dict[str, Any] + ] = await self.coordinator.device.media_list_media() + children: list[BrowseMedia] | None = [ + BrowseMedia( + title=item["title"], + media_class=MediaClass.MUSIC, + media_content_type=CONTENT_TYPE_AUDIO, + media_content_id=str(item["id"]), + thumbnail=item["preview"], + can_play=True, + can_expand=False, + ) + for item in result + if item["type"] == "AUDIO" + ] + else: + children = None + + return BrowseMedia( + title="Audio files", + media_class=MediaClass.DIRECTORY, + media_content_type=CONTENT_TYPE_AUDIO, + media_content_id=CONTENT_TYPE_AUDIO, + children_media_class=MediaClass.MUSIC, + children=children, + can_play=False, + can_expand=True, + ) + + async def _async_browse_radio_stations(self, expanded: bool = False) -> BrowseMedia: + """Return BrowseMedia tree for radio stations.""" + if expanded: + result: list[ + dict[str, Any] + ] = await self.coordinator.device.media_list_radio_stations() + children: list[BrowseMedia] | None = [ + BrowseMedia( + title=station["name"], + media_class=MediaClass.MUSIC, + media_content_type=CONTENT_TYPE_RADIO, + media_content_id=str(station["id"]), + thumbnail=station["icon"], + can_play=True, + can_expand=False, + ) + for station in result + ] + else: + children = None + + return BrowseMedia( + title="Radio stations", + media_class=MediaClass.DIRECTORY, + media_content_type=CONTENT_TYPE_RADIO, + media_content_id=CONTENT_TYPE_RADIO, + children_media_class=MediaClass.MUSIC, + children=children, + can_play=False, + can_expand=True, + ) + + @rpc_call + async def async_play_media( + self, + media_type: MediaType | str, + media_id: str, + **kwargs: Any, + ) -> None: + """Play media by type and id.""" + if media_id.isdecimal() is False: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_id", + translation_placeholders={"media_id": media_id}, + ) + + if media_type == CONTENT_TYPE_RADIO: + await self.coordinator.device.media_play_radio_station(int(media_id)) + return + + if media_type == CONTENT_TYPE_AUDIO: + await self.coordinator.device.media_play_media(int(media_id)) + return + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={"media_type": str(media_type)}, + ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 5eeb818c59a56d..c4b9b477be9681 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta from typing import Final, cast from aioshelly.block_device import Block @@ -41,6 +42,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow from .const import CONF_SLEEP_PERIOD, ROLE_GENERIC from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator @@ -62,7 +64,6 @@ async_remove_orphaned_entities, get_blu_trv_device_info, get_device_entry_gen, - get_device_uptime, get_shelly_air_lamp_life, get_virtual_component_unit, is_rpc_wifi_stations_disabled, @@ -467,8 +468,8 @@ def __init__( "uptime": RestSensorDescription( key="uptime", translation_key="last_restart", - value=lambda status, last: get_device_uptime(status["uptime"], last), - device_class=SensorDeviceClass.TIMESTAMP, + value=lambda status, _: utcnow() - timedelta(seconds=status["uptime"]), + device_class=SensorDeviceClass.UPTIME, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -1243,8 +1244,8 @@ def __init__( key="sys", sub_key="uptime", translation_key="last_restart", - value=get_device_uptime, - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, + value=lambda status, _: utcnow() - timedelta(seconds=status), entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 7312bf14a50341..e50f9fdfbcc3b6 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -657,6 +657,15 @@ "rpc_call_error": { "message": "RPC call error occurred for {device}" }, + "unsupported_media_content_type": { + "message": "Unsupported media content type for Shelly device: {media_content_type}" + }, + "unsupported_media_id": { + "message": "Unsupported media ID for Shelly device: {media_id}" + }, + "unsupported_media_type": { + "message": "Unsupported media type for Shelly device: {media_type}" + }, "update_error": { "message": "An error occurred while retrieving data from {device}" }, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 69d4d719569683..7d26eee7c74b8f 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address from typing import TYPE_CHECKING, Any, cast @@ -51,7 +50,6 @@ DeviceInfo, ) from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.util.dt import utcnow from .const import ( API_WS_URL, @@ -78,7 +76,6 @@ SHELLY_EMIT_EVENT_PATTERN, SHELLY_WALL_DISPLAY_MODELS, SHIX3_1_INPUTS_EVENTS_TYPES, - UPTIME_DEVIATION, VIRTUAL_COMPONENTS, VIRTUAL_COMPONENTS_MAP, WALL_DISPLAY_RELEASE_URL, @@ -194,29 +191,6 @@ def is_block_exclude_from_relay(settings: dict[str, Any], block: Block) -> bool: return is_block_channel_type_light(settings, block) -def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: - """Return device uptime string, tolerate up to 5 seconds deviation.""" - delta_uptime = utcnow() - timedelta(seconds=uptime) - - if ( - not last_uptime - or (diff := abs((delta_uptime - last_uptime).total_seconds())) - > UPTIME_DEVIATION - ): - if last_uptime: - LOGGER.debug( - "Time deviation %s > %s: uptime=%s, last_uptime=%s, delta_uptime=%s", - diff, - UPTIME_DEVIATION, - uptime, - last_uptime, - delta_uptime, - ) - return delta_uptime - - return last_uptime - - def get_block_input_triggers( device: BlockDevice, block: Block ) -> list[tuple[str, str]]: diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 3ffbcce54665a2..b40a53560484f8 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -5,7 +5,11 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -56,6 +60,9 @@ def __init__( ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, information.serial)}, + connections={ + (CONNECTION_NETWORK_MAC, format_mac(mac)) for mac in network.macs + }, name=network.hostname, manufacturer="Synology", model=information.model, diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 87e408b2a5849e..921913413b73ec 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -102,13 +102,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> tractive = TractiveClient(hass, client, creds["user_id"], entry) + trackables = [] try: - trackable_objects = await client.trackable_objects() - trackables = await asyncio.gather( - *(_generate_trackables(client, item) for item in trackable_objects) - ) + for obj in await client.trackable_objects(): + # To avoid hitting Tractive API rate limits, we add a small + # delay between requests to fetch trackable details. + await asyncio.sleep(2) + trackables.append(await _generate_trackables(client, obj)) except aiotractive.exceptions.TractiveError as error: + await client.close() raise ConfigEntryNotReady from error + except ConfigEntryNotReady: + await client.close() + raise # When the pet defined in Tractive has no tracker linked we get None as `trackable`. # So we have to remove None values from trackables list. @@ -164,12 +170,11 @@ async def _generate_trackables( tracker = client.tracker(trackable_data["device_id"]) trackable_pet = client.trackable_object(trackable_data["_id"]) - tracker_details, hw_info, pos_report, health_overview = await asyncio.gather( - tracker.details(), - tracker.hw_info(), - tracker.pos_report(), - trackable_pet.health_overview(), - ) + # Sequential fetching to prevent HTTP 429 Rate Limits + tracker_details = await tracker.details() + hw_info = await tracker.hw_info() + pos_report = await tracker.pos_report() + health_overview = await trackable_pet.health_overview() if not tracker_details.get("_id"): raise ConfigEntryNotReady( diff --git a/homeassistant/components/unifi_access/manifest.json b/homeassistant/components/unifi_access/manifest.json index e58627afee6a0d..07095919d5c19c 100644 --- a/homeassistant/components/unifi_access/manifest.json +++ b/homeassistant/components/unifi_access/manifest.json @@ -8,6 +8,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["unifi_access_api"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["py-unifi-access==1.3.0"] } diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 334dab34cea739..685768f96a0fe2 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -97,13 +97,17 @@ def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: self._attr_device_class = CoverDeviceClass.SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return the current position of the cover.""" + if not self.node.position.known: + return None return 100 - self.node.position.position_percent @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" + if not self.node.position.known: + return None return self.node.position.closed @property @@ -168,22 +172,29 @@ def __init__( self.part = part @property - def current_cover_position(self) -> int: - """Return the current position of the cover.""" + def _part_position(self) -> Position: + """Return the pyvlx Position for this part of the shutter.""" if self.part == VeluxDualRollerPart.UPPER: - return 100 - self.node.position_upper_curtain.position_percent + return self.node.position_upper_curtain if self.part == VeluxDualRollerPart.LOWER: - return 100 - self.node.position_lower_curtain.position_percent - return 100 - self.node.position.position_percent + return self.node.position_lower_curtain + return self.node.position + + @property + def current_cover_position(self) -> int | None: + """Return the current position of the cover.""" + position = self._part_position + if not position.known: + return None + return 100 - position.position_percent @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" - if self.part == VeluxDualRollerPart.UPPER: - return self.node.position_upper_curtain.closed - if self.part == VeluxDualRollerPart.LOWER: - return self.node.position_lower_curtain.closed - return self.node.position.closed + position = self._part_position + if not position.known: + return None + return position.closed @wrap_pyvlx_call_exceptions async def async_close_cover(self, **kwargs: Any) -> None: @@ -227,6 +238,8 @@ def __init__(self, node: Blind, config_entry_id: str) -> None: @property def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" + if not self.node.orientation.known: + return None return 100 - self.node.orientation.position_percent @wrap_pyvlx_call_exceptions diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 46229a1ccef694..8d24b6cb4ee027 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.1.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d25547077ef5ee..1f76813abf19bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1245,7 +1245,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 @@ -1368,7 +1368,7 @@ isal==1.8.0 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.4 +israel-rail-api==0.1.5 # homeassistant.components.abode jaraco.abode==6.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17c1d0d087cf6d..c0bb45f567aa47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,7 +1109,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 @@ -1217,7 +1217,7 @@ isal==1.8.0 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.4 +israel-rail-api==0.1.5 # homeassistant.components.abode jaraco.abode==6.4.0 diff --git a/tests/components/alarm_control_panel/test_condition.py b/tests/components/alarm_control_panel/test_condition.py index 5ecbc088d99bad..22e215126ea53b 100644 --- a/tests/components/alarm_control_panel/test_condition.py +++ b/tests/components/alarm_control_panel/test_condition.py @@ -16,6 +16,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -49,6 +50,36 @@ async def test_alarm_control_panel_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("alarm_control_panel.is_armed", {}, True, False), + ("alarm_control_panel.is_armed_away", {}, True, True), + ("alarm_control_panel.is_armed_home", {}, True, True), + ("alarm_control_panel.is_armed_night", {}, True, True), + ("alarm_control_panel.is_armed_vacation", {}, True, True), + ("alarm_control_panel.is_disarmed", {}, True, True), + ("alarm_control_panel.is_triggered", {}, True, True), + ], +) +async def test_alarm_control_panel_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that alarm_control_panel conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/assist_satellite/test_condition.py b/tests/components/assist_satellite/test_condition.py index 26c43ec7db9c02..3594c3ba3e94df 100644 --- a/tests/components/assist_satellite/test_condition.py +++ b/tests/components/assist_satellite/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -42,6 +43,33 @@ async def test_assist_satellite_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("assist_satellite.is_idle", {}, True, True), + ("assist_satellite.is_listening", {}, True, True), + ("assist_satellite.is_processing", {}, True, True), + ("assist_satellite.is_responding", {}, True, True), + ], +) +async def test_assist_satellite_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that assist_satellite conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index 8c828c0add8c9a..dd8606171c8348 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -17,6 +17,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -57,6 +58,33 @@ async def test_battery_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("battery.is_low", {}, True, True), + ("battery.is_not_low", {}, True, True), + ("battery.is_charging", {}, True, True), + ("battery.is_not_charging", {}, True, True), + ], +) +async def test_battery_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that battery conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/calendar/test_condition.py b/tests/components/calendar/test_condition.py index 05b7c71131493b..2f49b982bc5d29 100644 --- a/tests/components/calendar/test_condition.py +++ b/tests/components/calendar/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -38,6 +39,30 @@ async def test_calendar_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("calendar.is_event_active", {}, True, True), + ], +) +async def test_calendar_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that calendar conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 13bf598241a204..b943a0005d9edc 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -22,6 +22,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, other_states, parametrize_condition_states_all, @@ -59,6 +60,34 @@ async def test_climate_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("climate.is_off", {}, True, True), + ("climate.is_on", {}, True, False), + ("climate.is_cooling", {}, True, False), + ("climate.is_drying", {}, True, False), + ("climate.is_heating", {}, True, False), + ], +) +async def test_climate_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that climate conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/counter/test_condition.py b/tests/components/counter/test_condition.py index c25695edbfb6e0..36d89889f86bfd 100644 --- a/tests/components/counter/test_condition.py +++ b/tests/components/counter/test_condition.py @@ -11,6 +11,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -31,6 +32,33 @@ async def test_counter_condition_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value") +_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("counter.is_value", _PLAIN_THRESHOLD, True, False), + ], +) +async def test_counter_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that counter conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/cover/test_condition.py b/tests/components/cover/test_condition.py index 2ee5f034e82e85..d33d8a1974fb93 100644 --- a/tests/components/cover/test_condition.py +++ b/tests/components/cover/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -50,6 +51,39 @@ async def test_cover_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("cover.awning_is_closed", {}, True, False), + ("cover.awning_is_open", {}, True, False), + ("cover.blind_is_closed", {}, True, False), + ("cover.blind_is_open", {}, True, False), + ("cover.curtain_is_closed", {}, True, False), + ("cover.curtain_is_open", {}, True, False), + ("cover.shade_is_closed", {}, True, False), + ("cover.shade_is_open", {}, True, False), + ("cover.shutter_is_closed", {}, True, False), + ("cover.shutter_is_open", {}, True, False), + ], +) +async def test_cover_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that cover conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/door/test_condition.py b/tests/components/door/test_condition.py index 7c267a8df8baaf..1b8eb9d675a0a8 100644 --- a/tests/components/door/test_condition.py +++ b/tests/components/door/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,31 @@ async def test_door_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("door.is_closed", {}, True, False), + ("door.is_open", {}, True, False), + ], +) +async def test_door_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that door conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + # --- binary_sensor tests --- diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py index f2a475097ae6f1..457e05a0af8a6e 100644 --- a/tests/components/dsmr/test_diagnostics.py +++ b/tests/components/dsmr/test_diagnostics.py @@ -10,6 +10,7 @@ GAS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -19,6 +20,9 @@ from tests.typing import ClientSessionGenerator +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index c67bdbfd444fd5..12db2ed17c0dff 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -51,6 +51,9 @@ from tests.common import MockConfigEntry, patch +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_default_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/fan/test_condition.py b/tests/components/fan/test_condition.py index 425af847667427..d3cde640af8b10 100644 --- a/tests/components/fan/test_condition.py +++ b/tests/components/fan/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_fan_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("fan.is_off", {}, True, True), + ("fan.is_on", {}, True, True), + ], +) +async def test_fan_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that fan conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 5d2ac1a440624b..573529245a82d5 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -85,6 +85,7 @@ async def test_storage_data_writing( assert await async_setup_config_entry( hass, VALID_CONFIG_DEFAULT, return_value=feed_one_event ) + await hass.async_block_till_done() # one new event assert len(events) == 1 diff --git a/tests/components/flume/conftest.py b/tests/components/flume/conftest.py index 6173db1e2b9564..49d403a659b8d5 100644 --- a/tests/components/flume/conftest.py +++ b/tests/components/flume/conftest.py @@ -41,6 +41,7 @@ "type": 2, # Sensor "location": { "name": "Sensor Location", + "tz": "America/New_York", }, "name": "Flume Sensor", "connected": True, diff --git a/tests/components/flume/snapshots/test_sensor.ambr b/tests/components/flume/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..17c7716e072c23 --- /dev/null +++ b/tests/components/flume/snapshots/test_sensor.ambr @@ -0,0 +1,413 @@ +# serializer version: 1 +# name: test_sensors[sensor.flume_sensor_sensor_location_24_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_24_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '24 hours', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '24 hours', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_24_hrs', + 'unique_id': 'last_24_hrs_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_24_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flume Sensor Sensor Location 24 hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_24_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.4', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_30_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_30_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '30 days', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '30 days', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_30_days', + 'unique_id': 'last_30_days_1234', + 'unit_of_measurement': 'gal/mo', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_30_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'friendly_name': 'Flume Sensor Sensor Location 30 days', + 'state_class': , + 'unit_of_measurement': 'gal/mo', + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_30_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150.8', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_60_minutes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_60_minutes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '60 minutes', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '60 minutes', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_60_min', + 'unique_id': 'last_60_min_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_60_minutes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flume Sensor Sensor Location 60 minutes', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_60_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_interval', + 'unique_id': 'current_interval_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flume Sensor Sensor Location Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current day', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current day', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'today', + 'unique_id': 'today_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'water', + 'friendly_name': 'Flume Sensor Sensor Location Current day', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.2', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current month', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current month', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'month_to_date', + 'unique_id': 'month_to_date_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'water', + 'friendly_name': 'Flume Sensor Sensor Location Current month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.1', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_week-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current_week', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current week', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current week', + 'platform': 'flume', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'week_to_date', + 'unique_id': 'week_to_date_1234', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current_week-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Flume API', + 'device_class': 'water', + 'friendly_name': 'Flume Sensor Sensor Location Current week', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_week', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.5', + }) +# --- diff --git a/tests/components/flume/test_sensor.py b/tests/components/flume/test_sensor.py new file mode 100644 index 00000000000000..6d541de479fbe6 --- /dev/null +++ b/tests/components/flume/test_sensor.py @@ -0,0 +1,49 @@ +"""Test the flume sensor.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def platforms_fixture(): + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.flume.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("access_token", "device_list") +async def test_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + flume_values = { + "current_interval": 1.23, + "month_to_date": 100.1, + "week_to_date": 50.5, + "today": 10.2, + "last_60_min": 5.5, + "last_24_hrs": 20.4, + "last_30_days": 150.8, + } + + with patch("homeassistant.components.flume.sensor.FlumeData") as mock_flume_data: + mock_flume_data.return_value.values = flume_values + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/garage_door/test_condition.py b/tests/components/garage_door/test_condition.py index f85aa719f16236..68b3993c6e9952 100644 --- a/tests/components/garage_door/test_condition.py +++ b/tests/components/garage_door/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,31 @@ async def test_garage_door_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("garage_door.is_closed", {}, True, False), + ("garage_door.is_open", {}, True, False), + ], +) +async def test_garage_door_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that garage_door conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + # --- binary_sensor tests --- diff --git a/tests/components/gate/test_condition.py b/tests/components/gate/test_condition.py index 85d072fca715cf..1ed3d1ecfedb42 100644 --- a/tests/components/gate/test_condition.py +++ b/tests/components/gate/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -41,6 +42,31 @@ async def test_gate_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("gate.is_closed", {}, True, False), + ("gate.is_open", {}, True, False), + ], +) +async def test_gate_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that gate conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 2ac4040efe54ae..fefce2d43e438b 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1101,9 +1101,11 @@ async def check_progress( async def test_update_addon_resets_progress_on_error( - hass: HomeAssistant, supervisor_client: AsyncMock + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, ) -> None: - """Test addon update resets in_progress to False when update fails.""" + """Test addon update resets in_progress and update_percentage on failure.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -1118,11 +1120,48 @@ async def test_update_addon_resets_progress_on_error( state = hass.states.get("update.test_update") assert state.attributes.get("in_progress") is False + assert state.attributes.get("update_percentage") is None + + ws = await hass_ws_client(hass) + job_uuid = uuid4().hex + + async def fake_update_addon_error( + _hass: HomeAssistant, + _addon: str, + _backup: bool, + _addon_name: str | None, + _installed_version: str | None, + ) -> None: + """Report some progress, then fail - as a mid-pull network error would.""" + await ws.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "job", + "data": { + "uuid": job_uuid, + "created": "2025-09-29T00:00:00.000000+00:00", + "name": "addon_manager_update", + "reference": "test", + "progress": 42, + "done": False, + "stage": None, + "extra": {"total": 1234567890}, + "errors": [], + }, + }, + } + ) + msg = await ws.receive_json() + assert msg["success"] + await hass.async_block_till_done() + raise HomeAssistantError with ( patch( "homeassistant.components.hassio.update.update_addon", - side_effect=HomeAssistantError, + side_effect=fake_update_addon_error, ), pytest.raises(HomeAssistantError), ): @@ -1137,6 +1176,163 @@ async def test_update_addon_resets_progress_on_error( assert state.attributes.get("in_progress") is False, ( "in_progress should be reset to False after error" ) + assert state.attributes.get("update_percentage") is None, ( + "update_percentage should be reset to None after error" + ) + + +def _bump_addon_to( + addons_list: AsyncMock, + addon_installed: AsyncMock, + version: str, + version_latest: str, +) -> None: + """Rewrite the addon fixtures to report a post-update version.""" + current = addons_list.return_value + addons_list.return_value = [ + replace( + current[0], + version=version, + version_latest=version_latest, + update_available=version != version_latest, + ), + *current[1:], + ] + + def _updated_info(slug: str): + addon = Mock( + spec=InstalledAddonComplete, + to_dict=addon_installed.return_value.to_dict, + **addon_installed.return_value.to_dict(), + ) + addon.name = "test" + addon.slug = "test" + addon.version = version + addon.version_latest = version_latest + addon.update_available = version != version_latest + addon.state = AddonState.STARTED + addon.url = "https://github.com/home-assistant/addons/test" + addon.auto_update = True + return addon + + addon_installed.side_effect = _updated_info + + +async def test_update_addon_stays_in_progress_until_refresh( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + update_addon: AsyncMock, + addon_installed: AsyncMock, + addons_list: AsyncMock, +) -> None: + """Test addon update entity stays in progress until coordinator refresh. + + Supervisor emits the ``addon_manager_update`` job ``done=True`` WS event a + few milliseconds before ``/store/addons//update`` returns. Without + the ``_update_ongoing`` guard, ``_attr_in_progress`` is cleared while the + coordinator still holds the pre-update version and the UI briefly flips + back to "Update available". + """ + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + entity_id = "update.test_update" + assert hass.states.get(entity_id).state == "on" + + ws = await hass_ws_client(hass) + job_uuid = uuid4().hex + in_progress_after_done: list[bool | None] = [] + + async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None: + """Mimic Supervisor: fire done=True on WS, then return HTTP response.""" + await ws.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "job", + "data": { + "uuid": job_uuid, + "created": "2025-09-29T00:00:00.000000+00:00", + "name": "addon_manager_update", + "reference": "test", + "progress": 100, + "done": True, + "stage": None, + "extra": {"total": 1234567890}, + "errors": [], + }, + }, + } + ) + msg = await ws.receive_json() + assert msg["success"] + await hass.async_block_till_done() + in_progress_after_done.append( + hass.states.get(entity_id).attributes.get("in_progress") + ) + _bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.1") + + update_addon.side_effect = fake_update_addon + + await hass.services.async_call( + "update", "install", {"entity_id": entity_id}, blocking=True + ) + + # The done=True WS event fired mid-install must not drop in_progress; the + # coordinator data at that instant still carries the pre-update version. + assert in_progress_after_done == [True] + + state = hass.states.get(entity_id) + assert state.attributes.get("in_progress") is False + assert state.state == "off" + + +async def test_update_addon_completes_on_any_version_change( + hass: HomeAssistant, + update_addon: AsyncMock, + addon_installed: AsyncMock, + addons_list: AsyncMock, +) -> None: + """Test completion when installed version changes from the pre-install one. + + If a newer upstream release appears between install start and the refresh, + ``installed_version`` will not equal ``latest_version`` but will differ + from the pre-install version. The ongoing flag must still clear. + """ + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + entity_id = "update.test_update" + + async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None: + _bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.2") + + update_addon.side_effect = fake_update_addon + + await hass.services.async_call( + "update", "install", {"entity_id": entity_id}, blocking=True + ) + + state = hass.states.get(entity_id) + assert state.attributes.get("in_progress") is False + assert state.state == "on" async def test_update_supervisor( diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index b45f8882964ed4..76d4acabaadaaa 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -30,6 +30,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_attribute_condition_above_below_all, @@ -63,6 +64,33 @@ async def test_humidifier_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("humidifier.is_off", {}, True, True), + ("humidifier.is_on", {}, True, True), + ("humidifier.is_drying", {}, True, False), + ("humidifier.is_humidifying", {}, True, False), + ], +) +async def test_humidifier_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that humidifier conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index f878dfe14a005a..d62065853daf0d 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -20,6 +20,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_numerical_attribute_condition_above_below_all, parametrize_numerical_attribute_condition_above_below_any, parametrize_numerical_condition_above_below_all, @@ -68,6 +69,33 @@ async def test_humidity_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("humidity.is_value", _PLAIN_THRESHOLD, True, False), + ], +) +async def test_humidity_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that humidity conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/illuminance/test_condition.py b/tests/components/illuminance/test_condition.py index d82a29581c3d0e..614ea7146ffd03 100644 --- a/tests/components/illuminance/test_condition.py +++ b/tests/components/illuminance/test_condition.py @@ -17,6 +17,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -55,6 +56,31 @@ async def test_illuminance_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("illuminance.is_detected", {}, True, True), + ("illuminance.is_not_detected", {}, True, True), + ], +) +async def test_illuminance_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that illuminance conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/indevolt/conftest.py b/tests/components/indevolt/conftest.py index 384ecc469b8014..324c1f7dc67731 100644 --- a/tests/components/indevolt/conftest.py +++ b/tests/components/indevolt/conftest.py @@ -87,6 +87,9 @@ def mock_indevolt(generation: int) -> Generator[AsyncMock]: client = mock_client.return_value client.fetch_data.return_value = fixture_data client.set_data.return_value = True + client.stop.return_value = True + client.charge.return_value = True + client.discharge.return_value = True client.get_config.return_value = { "device": { "sn": device_info["sn"], diff --git a/tests/components/indevolt/test_button.py b/tests/components/indevolt/test_button.py index a5ea45c8d886b8..6844fa7a95285d 100644 --- a/tests/components/indevolt/test_button.py +++ b/tests/components/indevolt/test_button.py @@ -1,8 +1,7 @@ """Tests for the Indevolt button platform.""" -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, patch -from indevolt_api import TimeOutException import pytest from syrupy.assertion import SnapshotAssertion @@ -11,7 +10,6 @@ ENERGY_MODE_READ_KEY, ENERGY_MODE_WRITE_KEY, PORTABLE_MODE, - REALTIME_ACTION_KEY, REALTIME_ACTION_MODE, ) from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -64,14 +62,11 @@ async def test_button_press_standby( blocking=True, ) - # Verify set_data was called twice with correct parameters - assert mock_indevolt.set_data.call_count == 2 - mock_indevolt.set_data.assert_has_calls( - [ - call(ENERGY_MODE_WRITE_KEY, REALTIME_ACTION_MODE), - call(REALTIME_ACTION_KEY, [0, 0, 0]), - ] + # Verify set_data was called for mode switch and stop() was called + mock_indevolt.set_data.assert_called_once_with( + ENERGY_MODE_WRITE_KEY, REALTIME_ACTION_MODE ) + mock_indevolt.stop.assert_called_once() @pytest.mark.parametrize("generation", [2], indirect=True) @@ -98,22 +93,23 @@ async def test_button_press_standby_already_in_realtime_mode( blocking=True, ) - # Verify set_data was called once with correct parameters - mock_indevolt.set_data.assert_called_once_with(REALTIME_ACTION_KEY, [0, 0, 0]) + # Verify stop() was called and no mode switch was needed + mock_indevolt.set_data.assert_not_called() + mock_indevolt.stop.assert_called_once() @pytest.mark.parametrize("generation", [2], indirect=True) -async def test_button_press_standby_timeout_error( +async def test_button_press_standby_rejected_command( hass: HomeAssistant, mock_indevolt: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test pressing standby raises HomeAssistantError when the device times out.""" + """Test pressing standby raises HomeAssistantError when the device rejects the command.""" with patch("homeassistant.components.indevolt.PLATFORMS", [Platform.BUTTON]): await setup_integration(hass, mock_config_entry) - # Simulate an API push failure - mock_indevolt.set_data.side_effect = TimeOutException("Timed out") + # Simulate stop() returning False (device rejected the command) + mock_indevolt.stop.return_value = False # Mock call to pause (dis)charging with pytest.raises(HomeAssistantError): diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index 45f0d8d52c229c..a47f0cb58ee422 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -62,6 +62,9 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_no_telegram() +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_sensor_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test restoring KNX sensor state.""" ADDRESS = "2/2/2" diff --git a/tests/components/lawn_mower/test_condition.py b/tests/components/lawn_mower/test_condition.py index 25bdf62d8fa82d..27ab7503a0002c 100644 --- a/tests/components/lawn_mower/test_condition.py +++ b/tests/components/lawn_mower/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -43,6 +44,34 @@ async def test_lawn_mower_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("lawn_mower.is_docked", {}, True, True), + ("lawn_mower.is_encountering_an_error", {}, True, True), + ("lawn_mower.is_mowing", {}, True, True), + ("lawn_mower.is_paused", {}, True, True), + ("lawn_mower.is_returning", {}, True, True), + ], +) +async def test_lawn_mower_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that lawn_mower conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index 6851527aee23fa..e52d9b60f62e90 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -151,6 +152,31 @@ async def test_light_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("light.is_off", {}, True, True), + ("light.is_on", {}, True, True), + ], +) +async def test_light_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that light conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/lock/test_condition.py b/tests/components/lock/test_condition.py index 73d51620974945..d2a009b5a5cedf 100644 --- a/tests/components/lock/test_condition.py +++ b/tests/components/lock/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -42,6 +43,33 @@ async def test_lock_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("lock.is_jammed", {}, True, True), + ("lock.is_locked", {}, True, True), + ("lock.is_open", {}, True, True), + ("lock.is_unlocked", {}, True, True), + ], +) +async def test_lock_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that lock conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index c6fcd206b15f91..12ec3dc5240104 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -22,6 +22,7 @@ "air_quality_sensor", "aqara_door_window_p2", "aqara_motion_p2", + "aqara_multi_state_p100", "aqara_presence_fp300", "aqara_sensor_w100", "aqara_thermostat_w500", diff --git a/tests/components/matter/fixtures/nodes/aqara_multi_state_p100.json b/tests/components/matter/fixtures/nodes/aqara_multi_state_p100.json new file mode 100644 index 00000000000000..74019b1c431a2e --- /dev/null +++ b/tests/components/matter/fixtures/nodes/aqara_multi_state_p100.json @@ -0,0 +1,407 @@ +{ + "node_id": 364, + "date_commissioned": "2026-03-10T00:21:04.489000", + "last_interview": "2026-04-10T11:57:17.154000", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/65533": 2, + "0/29/65532": 0, + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 53, 60, 62, 63, 70], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/29/65529": [], + "0/29/65528": [], + "0/31/1": [], + "0/31/65533": 2, + "0/31/65532": 0, + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/31/65529": [], + "0/31/65528": [], + "0/40/11": "20251229", + "0/40/12": "AS080", + "0/40/13": "https://www.aqara.com/en/products.html", + "0/40/14": "Multi-State Sensor P100", + "0/40/15": "54EF4410015E5399", + "0/40/16": false, + "0/40/24": 1, + "0/40/65533": 4, + "0/40/0": 18, + "0/40/1": "Aqara", + "0/40/2": 4447, + "0/40/3": "Multi-State Sensor P100", + "0/40/4": 8203, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1000, + "0/40/8": "1.0.0.0", + "0/40/9": 1002, + "0/40/10": "1.0.0.2", + "0/40/18": "FC9C116E96AF14A0", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039616, + "0/40/22": 5, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/42/65533": 1, + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/42/65529": [0], + "0/42/65528": [], + "0/48/65533": 2, + "0/48/65532": 0, + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/48/65529": [0, 2, 4], + "0/48/65528": [1, 3, 5], + "0/49/65532": 2, + "0/49/2": 10, + "0/49/3": 20, + "0/49/9": 4, + "0/49/10": 4, + "0/49/65533": 2, + "0/49/0": 1, + "0/49/1": [ + { + "0": "/yXnNCiUSvw=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "/yXnNCiUSvw=", + "0/49/7": null, + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65528": [1, 5, 7], + "0/51/65532": 1, + "0/51/4": 6, + "0/51/5": [], + "0/51/65533": 2, + "0/51/0": [ + { + "0": "MyHome1895415629", + "1": true, + "2": null, + "3": null, + "4": "cqbSuXtndsc=", + "5": [], + "6": [ + "/YXIOtIiAAHLfipa/IZ15A==", + "/dH8OtkWot0AAAD//gC4Aw==", + "/dH8OtkWot1GlaI6UhuJiQ==", + "/oAAAAAAAABwptK5e2d2xw==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 679, + "0/51/8": false, + "0/51/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/65529": [0, 1], + "0/51/65528": [2], + "0/52/65532": 1, + "0/52/0": [ + { + "0": 11, + "1": "Bluetoot", + "3": 1395 + }, + { + "0": 10, + "1": "Bluetoot", + "3": 107 + }, + { + "0": 2, + "1": "OT Stack", + "3": 756 + }, + { + "0": 3, + "1": "sys_evt", + "3": 1832 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 306 + }, + { + "0": 9, + "1": "Tmr Svc", + "3": 855 + }, + { + "0": 6, + "1": "app", + "3": 718 + }, + { + "0": 8, + "1": "IDLE", + "3": 237 + }, + { + "0": 5, + "1": "CHIP", + "3": 231 + } + ], + "0/52/1": 54632, + "0/52/2": 36560, + "0/52/3": 4294951292, + "0/52/65533": 1, + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/52/65529": [0], + "0/52/65528": [], + "0/53/65532": 15, + "0/53/6": 0, + "0/53/22": 3127, + "0/53/23": 3125, + "0/53/24": 2, + "0/53/39": 255, + "0/53/40": 182, + "0/53/41": 2, + "0/53/63": null, + "0/53/64": null, + "0/53/65533": 2, + "0/53/0": 25, + "0/53/1": 2, + "0/53/2": "MyHome1895415629", + "0/53/3": 49399, + "0/53/4": 18385355265015040764, + "0/53/5": "QP3R/DrZFqLd", + "0/53/7": [ + { + "0": 15199626930737624439, + "1": 628, + "2": 47104, + "3": 577932, + "4": 206026, + "5": 3, + "6": -41, + "7": -42, + "8": 28, + "9": 1, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 15199626930737624439, + "1": 47104, + "2": 46, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 116, + "8": true, + "9": true + } + ], + "0/53/9": 1216112755, + "0/53/10": 68, + "0/53/11": 52, + "0/53/12": 177, + "0/53/13": 20, + "0/53/59": { + "0": 672, + "1": 57487 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 22, 23, 24, 39, 40, 41, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/53/65529": [0], + "0/53/65528": [], + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/60/65529": [0, 1, 2], + "0/60/65528": [], + "0/62/65533": 1, + "0/62/0": [ + { + "1": "FTABAQQkAgE3AyQTAhgmBFx8YC8mBdyyDUQ3BiQVAiURbAEYJAcBJAgBMAlBBDP7qKQsHuBi45zn4RejInN+AEUzFqUrzJk6EuoYjYmH3Yp9c9PiK8DR/bn66Z/4cughMr4Uewh1Blstnj0lUJk3CjUBKAEYJAIBNgMEAgQBGDAEFKzIdkDOIFno0xGg5dwLqRevOcKsMAUU/eZfRuhWvUFT8WNU1R/sUE0q70YYMAtAyoqHj64qMq2D0pXxHe3/kBtkNBQLhzwTKxWlNyUb73BypVsgO8GXoY7X4ei3l9sWicjmi2TxSjWwWYiGWlc9/xg=", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEJB6axSR6Xj7Ab+FB5+C+slsdDtj0qCvcRHCCpCYTX6svgMPs/yVVEfvJgIUXZ5gkLS9jK1CpsF4u8MZR6qsNZzcKNQEpARgkAmAwBBT95l9G6Fa9QVPxY1TVH+xQTSrvRjAFFLyrP0JigOFmlOJIyXL9CANMKs5NGDALQNykW7UIqcgXgx+UezCVYPRU8/CpHh9CJBqL/7wKfTM62ujWJlrH0P5DEZ5bV9ZihCk4Wg/DMM2BUuUcTOEEqzEY", + "254": 2 + } + ], + "0/62/1": [ + { + "1": "BCZ12LdJK3WZUiquu2PD6iWSaeQK6J6DWw86GihFX4HiWOG1JQip6ILp0IFNffrIGwriEteEhksN56MylydpF/s=", + "2": 4939, + "3": 2, + "4": 364, + "5": "Home", + "254": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABEQDQx1YK3DNF4VspLtBSH0RWJAIBNwMmFKXW6WQYJgSH9oQvJgWHuJzrNwYmFKXW6WQYJAcBJAgBMAlBBGCkW/XG7wOs/MmWOEItvplPFRgUY9sAtyYIn/4rFIak+Z0AXSkROqxLUAQa8V7DLPgpo0JBqXvHPHi9xLO5tb83CjUBKQEYJAJgMAQUZZlpdl2hjoDT0Rf0y6zCQ129lKowBRRlmWl2XaGOgNPRF/TLrMJDXb2UqhgwC0Dq2rZf/UEww43h7sE2IUGQvb4vrhhK/Iqbx4ginWbxp/h9th7NRTa76ByxReos8Y4mJ9QGaGvcNThhSGF5lx5+GA==", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEJnXYt0krdZlSKq67Y8PqJZJp5AronoNbDzoaKEVfgeJY4bUlCKnogunQgU19+sgbCuIS14SGSw3nozKXJ2kX+zcKNQEpARgkAmAwBBS8qz9CYoDhZpTiSMly/QgDTCrOTTAFFLyrP0JigOFmlOJIyXL9CANMKs5NGDALQIfPjG1LeoSoRd3sJ2NeaS3VrHyftI8l6dOwafhoGMQdCRwyadYABiUG/Po1BnWmg4laSh88nP3zAAnQ2j0l4tAY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65528": [1, 3, 5, 8], + "0/63/65533": 2, + "0/63/65532": 0, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 0, + "0/63/3": 3, + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [2, 5], + "0/70/65533": 3, + "0/70/65532": 0, + "0/70/0": 300, + "0/70/1": 100, + "0/70/2": 500, + "0/70/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/70/65529": [], + "0/70/65528": [], + "1/29/65533": 2, + "1/29/65532": 0, + "1/29/0": [ + { + "0": 21, + "1": 2 + } + ], + "1/29/1": [3, 29, 69, 128], + "1/29/2": [], + "1/29/3": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/29/65529": [], + "1/29/65528": [], + "1/128/65532": 8, + "1/128/0": 6, + "1/128/1": 10, + "1/128/2": 4, + "1/128/65533": 1, + "1/128/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/128/65529": [], + "1/128/65528": [], + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/3/65529": [0, 64], + "1/3/65528": [], + "1/69/65533": 1, + "1/69/0": false, + "1/69/65532": 0, + "1/69/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/69/65529": [], + "1/69/65528": [], + "2/29/65533": 2, + "2/29/65532": 0, + "2/29/0": [ + { + "0": 17, + "1": 1 + } + ], + "2/29/1": [29, 47], + "2/29/2": [], + "2/29/3": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/29/65529": [], + "2/29/65528": [], + "2/47/65532": 10, + "2/47/11": 2972, + "2/47/12": 200, + "2/47/14": 0, + "2/47/15": false, + "2/47/16": 2, + "2/47/19": "CR2450", + "2/47/25": 1, + "2/47/65533": 2, + "2/47/0": 1, + "2/47/1": 0, + "2/47/2": "Battery", + "2/47/31": [], + "2/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 19, 25, 31, 65528, 65529, 65531, 65532, 65533 + ], + "2/47/65529": [], + "2/47/65528": [] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index f000171a17e20a..efc83eed307474 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -101,6 +101,57 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[aqara_multi_state_p100][binary_sensor.multi_state_sensor_p100_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.multi_state_sensor_p100_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-1-ContactSensor-69-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[aqara_multi_state_p100][binary_sensor.multi_state_sensor_p100_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Multi-State Sensor P100 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.multi_state_sensor_p100_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[aqara_presence_fp300][binary_sensor.presence_multi_sensor_fp300_1_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 39ef9b81827c1a..6037ac85013517 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -152,6 +152,57 @@ 'state': 'unknown', }) # --- +# name: test_buttons[aqara_multi_state_p100][button.multi_state_sensor_p100_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.multi_state_sensor_p100_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[aqara_multi_state_p100][button.multi_state_sensor_p100_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Multi-State Sensor P100 Identify', + }), + 'context': , + 'entity_id': 'button.multi_state_sensor_p100_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[aqara_presence_fp300][button.presence_multi_sensor_fp300_1_identify_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index d0392b49717a6e..15962c8f15b7ad 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_numbers[aqara_door_window_p2][number.aqara_door_and_window_sensor_p2_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_door_and_window_sensor_p2_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_door_window_p2][number.aqara_door_and_window_sensor_p2_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Door and Window Sensor P2 Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.aqara_door_and_window_sensor_p2_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_hold_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -59,6 +118,124 @@ 'state': '30', }) # --- +# name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_motion_and_light_sensor_p2_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_motion_p2][number.aqara_motion_and_light_sensor_p2_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aqara Motion and Light Sensor P2 Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.aqara_motion_and_light_sensor_p2_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_numbers[aqara_multi_state_p100][number.multi_state_sensor_p100_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multi_state_sensor_p100_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_multi_state_p100][number.multi_state_sensor_p100_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multi-State Sensor P100 Sensitivity', + 'max': 10, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.multi_state_sensor_p100_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- # name: test_numbers[aqara_presence_fp300][number.presence_multi_sensor_fp300_1_hold_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -119,6 +296,65 @@ 'state': '10', }) # --- +# name: test_numbers[aqara_presence_fp300][number.presence_multi_sensor_fp300_1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.presence_multi_sensor_fp300_1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-00000000000000CD-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[aqara_presence_fp300][number.presence_multi_sensor_fp300_1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Presence Multi-Sensor FP300 1 Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.presence_multi_sensor_fp300_1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_numbers[aqara_thermostat_w500][number.floor_heating_thermostat_occupied_setback-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -838,6 +1074,65 @@ 'state': '70', }) # --- +# name: test_numbers[heiman_motion_sensor_m1][number.smart_motion_sensor_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.smart_motion_sensor_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensitivity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity_level', + 'unique_id': '00000000000004D2-0000000000000058-MatterNodeDevice-1-BooleanStateConfigurationCurrentSensitivityLevel-128-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[heiman_motion_sensor_m1][number.smart_motion_sensor_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart motion sensor Sensitivity', + 'max': 3, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.smart_motion_sensor_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_numbers[inovelli_vtm30][number.white_series_onoff_switch_led_off_intensity_load_control-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 0136173018a5e2..5d63b72d1a1f25 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1,187 +1,4 @@ # serializer version: 1 -# name: test_selects[aqara_door_window_p2][select.aqara_door_and_window_sensor_p2_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '10 mm', - '20 mm', - '30 mm', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.aqara_door_and_window_sensor_p2_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-000000000000005B-MatterNodeDevice-1-AqaraBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[aqara_door_window_p2][select.aqara_door_and_window_sensor_p2_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aqara Door and Window Sensor P2 Sensitivity', - 'options': list([ - '10 mm', - '20 mm', - '30 mm', - ]), - }), - 'context': , - 'entity_id': 'select.aqara_door_and_window_sensor_p2_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30 mm', - }) -# --- -# name: test_selects[aqara_motion_p2][select.aqara_motion_and_light_sensor_p2_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.aqara_motion_and_light_sensor_p2_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[aqara_motion_p2][select.aqara_motion_and_light_sensor_p2_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aqara Motion and Light Sensor P2 Sensitivity', - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.aqara_motion_and_light_sensor_p2_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'standard', - }) -# --- -# name: test_selects[aqara_presence_fp300][select.presence_multi_sensor_fp300_1_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.presence_multi_sensor_fp300_1_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-00000000000000CD-MatterNodeDevice-1-AqaraOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[aqara_presence_fp300][select.presence_multi_sensor_fp300_1_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Presence Multi-Sensor FP300 1 Sensitivity', - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.presence_multi_sensor_fp300_1_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'low', - }) -# --- # name: test_selects[aqara_thermostat_w500][select.floor_heating_thermostat_temperature_display_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1050,67 +867,6 @@ 'state': 'previous', }) # --- -# name: test_selects[heiman_motion_sensor_m1][select.smart_motion_sensor_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.smart_motion_sensor_sensitivity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sensitivity', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensitivity', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity_level', - 'unique_id': '00000000000004D2-0000000000000058-MatterNodeDevice-1-HeimanOccupancySensorBooleanStateConfigurationCurrentSensitivityLevel-128-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[heiman_motion_sensor_m1][select.smart_motion_sensor_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Smart motion sensor Sensitivity', - 'options': list([ - 'low', - 'standard', - 'high', - ]), - }), - 'context': , - 'entity_id': 'select.smart_motion_sensor_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'high', - }) -# --- # name: test_selects[inovelli_vtm30][select.white_series_onoff_switch_button_press_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index d339bed55735cc..4e68dffa6dea12 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -951,6 +951,172 @@ 'state': '37.0', }) # --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-2-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Multi-State Sensor P100 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery type', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery type', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_description', + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-2-PowerSourceBatReplacementDescription-47-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multi-State Sensor P100 Battery type', + }), + 'context': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CR2450', + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-000000000000016C-MatterNodeDevice-2-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[aqara_multi_state_p100][sensor.multi_state_sensor_p100_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Multi-State Sensor P100 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.multi_state_sensor_p100_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.972', + }) +# --- # name: test_sensors[aqara_presence_fp300][sensor.presence_multi_sensor_fp300_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 8e82c04bed6bc1..51ba3f761eecc5 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -342,6 +342,44 @@ async def test_thermostat_occupied_setback( ) +@pytest.mark.parametrize("node_fixture", ["aqara_multi_state_p100"]) +async def test_boolean_state_configuration_current_sensitivity_level( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test sensitivity level number entity for Aqara P100.""" + entity_id = "number.multi_state_sensor_p100_sensitivity" + + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": 1, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=( + clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel + ), + ), + value=0, + ) + + set_node_attribute(matter_node, 1, 128, 0, 4) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.state == "5" + + @pytest.mark.parametrize("node_fixture", ["mock_door_lock"]) async def test_lock_attributes( hass: HomeAssistant, diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index b1a6b60f411415..867e4dd100bd71 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -281,6 +281,7 @@ async def test_microwave_oven( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("node_fixture", ["aqara_door_window_p2"]) async def test_aqara_door_window_p2( hass: HomeAssistant, diff --git a/tests/components/media_player/test_condition.py b/tests/components/media_player/test_condition.py index 2dded050bfd62e..55ed770ea0d7bb 100644 --- a/tests/components/media_player/test_condition.py +++ b/tests/components/media_player/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -43,6 +44,34 @@ async def test_media_player_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("media_player.is_off", {}, True, True), + ("media_player.is_on", {}, True, False), + ("media_player.is_not_playing", {}, True, False), + ("media_player.is_paused", {}, True, True), + ("media_player.is_playing", {}, True, True), + ], +) +async def test_media_player_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that media_player conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py index 65d7e7c76d07e8..7c636a7c90bc46 100644 --- a/tests/components/moisture/test_condition.py +++ b/tests/components/moisture/test_condition.py @@ -17,6 +17,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -55,6 +56,31 @@ async def test_moisture_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("moisture.is_detected", {}, True, True), + ("moisture.is_not_detected", {}, True, True), + ], +) +async def test_moisture_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that moisture conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/motion/test_condition.py b/tests/components/motion/test_condition.py index b4b3c717e03368..dda997d91183fe 100644 --- a/tests/components/motion/test_condition.py +++ b/tests/components/motion/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -40,6 +41,31 @@ async def test_motion_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("motion.is_detected", {}, True, True), + ("motion.is_not_detected", {}, True, True), + ], +) +async def test_motion_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that motion conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/occupancy/test_condition.py b/tests/components/occupancy/test_condition.py index f4753d06acd6cb..1e06a88bfb994a 100644 --- a/tests/components/occupancy/test_condition.py +++ b/tests/components/occupancy/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -40,6 +41,31 @@ async def test_occupancy_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("occupancy.is_detected", {}, True, True), + ("occupancy.is_not_detected", {}, True, True), + ], +) +async def test_occupancy_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that occupancy conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 02cbaac4db33be..695a33ef9d2de7 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -72,6 +72,9 @@ class MockPlexTVEpisode(MockPlexMedia): parentYear = 2021 +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_library_sensor_values( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index e5bff95dff50eb..3e9b7cf4a575bd 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, parametrize_numerical_condition_above_below_all, parametrize_numerical_condition_above_below_any, @@ -37,6 +38,38 @@ async def test_power_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_WATT_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 50, "unit_of_measurement": "W"}, + } +} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("power.is_value", _WATT_THRESHOLD, True, False), + ], +) +async def test_power_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that power conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/remote/test_condition.py b/tests/components/remote/test_condition.py index b3052de5bd7156..04c187f0dd68c4 100644 --- a/tests/components/remote/test_condition.py +++ b/tests/components/remote/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_remote_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("remote.is_off", {}, True, True), + ("remote.is_on", {}, True, True), + ], +) +async def test_remote_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that remote conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index fd113bceaa0f43..6b8ce286fd9a88 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -87,6 +87,9 @@ async def test_default_setup( assert hass.states.get("binary_sensor.test").state == STATE_OFF +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_entity_availability( hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py index 2f0164a55f9a1e..96204043298ff9 100644 --- a/tests/components/rflink/test_sensor.py +++ b/tests/components/rflink/test_sensor.py @@ -131,6 +131,9 @@ async def test_disable_automatic_add( assert not hass.states.get("sensor.test2") +@pytest.mark.xfail( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_entity_availability( hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/components/schedule/test_condition.py b/tests/components/schedule/test_condition.py index e9eb1fcf61cc4f..107d83183779ef 100644 --- a/tests/components/schedule/test_condition.py +++ b/tests/components/schedule/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -40,6 +41,31 @@ async def test_schedule_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("schedule.is_off", {}, True, True), + ("schedule.is_on", {}, True, True), + ], +) +async def test_schedule_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that schedule conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/select/test_condition.py b/tests/components/select/test_condition.py index edd97c41ee2698..1d97b13113a7c1 100644 --- a/tests/components/select/test_condition.py +++ b/tests/components/select/test_condition.py @@ -16,6 +16,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,30 @@ async def test_select_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("select.is_option_selected", {"option": ["option_a"]}, True, False), + ], +) +async def test_select_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that select conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 24b3175f2e0568..78031e3cb23a03 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -468,7 +468,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -483,7 +483,7 @@ # name: test_device[cury_gen4][sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , @@ -1379,7 +1379,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -1394,7 +1394,7 @@ # name: test_device[duo_bulb_gen3][sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , @@ -3588,7 +3588,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -3603,7 +3603,7 @@ # name: test_device[power_strip_gen4][sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , @@ -5168,7 +5168,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -5183,7 +5183,7 @@ # name: test_device[presence_gen4][sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , @@ -6075,6 +6075,61 @@ 'state': 'unknown', }) # --- +# name: test_device[wall_display_xl][media_player.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-media-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[wall_display_xl][media_player.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test name', + 'media_content_type': , + 'supported_features': , + 'volume_level': 0.7, + }), + 'context': , + 'entity_id': 'media_player.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- # name: test_device[wall_display_xl][sensor.test_name_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -6275,7 +6330,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -6290,7 +6345,7 @@ # name: test_device[wall_display_xl][sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , @@ -7304,7 +7359,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -7319,7 +7374,7 @@ # name: test_shelly_2pm_gen3_cover[sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , @@ -8373,7 +8428,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -8388,7 +8443,7 @@ # name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , @@ -10042,7 +10097,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'shelly', @@ -10057,7 +10112,7 @@ # name: test_shelly_pro_3em[sensor.test_name_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Test name Last restart', }), 'context': , diff --git a/tests/components/shelly/snapshots/test_media_player.ambr b/tests/components/shelly/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..d4b527d52867c1 --- /dev/null +++ b/tests/components/shelly/snapshots/test_media_player.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_rpc_media_player[media_player.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-media-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_media_player[media_player.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': 'https://www.radio_station.pl/icon.png', + 'friendly_name': 'Test name', + 'media_content_type': , + 'media_title': 'Radio Station', + 'supported_features': , + 'volume_level': 0.5, + }), + 'context': , + 'entity_id': 'media_player.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/shelly/test_media_player.py b/tests/components/shelly/test_media_player.py new file mode 100644 index 00000000000000..d1f1b6ce74e35c --- /dev/null +++ b/tests/components/shelly/test_media_player.py @@ -0,0 +1,633 @@ +"""Tests for Shelly media player platform.""" + +from copy import deepcopy +from unittest.mock import Mock + +from aioshelly.const import MODEL_WALL_DISPLAY +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_PLAY_MEDIA, + SERVICE_VOLUME_SET, +) +from homeassistant.components.shelly.media_player import ( + CONTENT_TYPE_AUDIO, + CONTENT_TYPE_RADIO, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_BUFFERING, + STATE_IDLE, + STATE_PLAYING, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import init_integration, patch_platforms + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +ENTITY_ID = f"{MEDIA_PLAYER_DOMAIN}.test_name" + +AUDIO_FILES = [ + { + "album": "Album Placeholder", + "artist": "Artist Placeholder", + "duration": 106000, + "filename": "track_alpha.mp3", + "id": 16, + "index": 0, + "preview": "https://example.com/media/thumb?id=16&_t=track_alpha.mp3", + "size": 3390000, + "title": "Track Alpha", + "track": 0, + "type": "AUDIO", + "valid": True, + "year": 0, + }, + { + "album": "Album Placeholder", + "artist": "Artist Placeholder", + "duration": 138000, + "filename": "track_beta.mp3", + "id": 15, + "index": 0, + "preview": "https://example.com/media/thumb?id=15&_t=track_beta.mp3", + "size": 4425000, + "title": "Track Beta", + "track": 0, + "type": "AUDIO", + "valid": True, + "year": 0, + }, + { + "filename": "ringtone_gamma.mp3", + "id": 17, + "index": 0, + "preview": "https://example.com/media/thumb?id=17&_t=ringtone_gamma.mp3", + "size": 552000, + "title": "Ringtone Gamma", + "type": "RINGTONE", + "valid": True, + }, +] + +RADIO_STATIONS = [ + { + "id": 0, + "name": "Station Alpha", + "country_code": "XX", + "icon": "https://example.com/icons/alpha.png", + }, + { + "id": 1, + "name": "Station Beta", + "country_code": "XX", + "icon": "https://example.com/icons/beta.png", + }, + { + "id": 2, + "name": "Station Gamma", + "country_code": "XX", + "icon": "https://example.com/icons/gamma.png", + }, + { + "id": 3, + "name": "Station Delta", + "country_code": "XX", + "icon": "https://example.com/icons/delta.png", + }, +] +STATUS_RADIO_STATION = { + "playback": { + "enable": True, + "buffering": False, + "volume": 5, + "media_meta": { + "thumb": "https://www.radio_station.pl/icon.png", + "title": "Radio Station", + }, + "media_type": "RADIO", + }, +} +STATUS_AUDIO_FILE = { + "playback": { + "buffering": False, + "enable": True, + "volume": 2, + "media_meta": { + "album": "Album Name", + "artist": "Artist", + "duration": 132415, + "position": 64644, + "thumb": "data:image/webp;base64,UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP79NmgA", + "title": "Title", + }, + "media_type": "AUDIO", + } +} + + +@pytest.fixture(autouse=True) +def fixture_platforms(): + """Limit platforms under test.""" + with patch_platforms([Platform.MEDIA_PLAYER]): + yield + + +async def test_rpc_media_player( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test a Shelly RPC media player.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_RADIO_STATION + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert (state := hass.states.get(ENTITY_ID)) + assert state == snapshot( + name=f"{ENTITY_ID}-state", exclude=props("entity_picture_local") + ) + + assert (entry := entity_registry.async_get(ENTITY_ID)) + assert entry == snapshot(name=f"{ENTITY_ID}-entry") + + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", False) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "buffering", True) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_BUFFERING + + +async def test_rpc_media_player_audio_file( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a Shelly RPC media player.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_PLAYING + assert state.attributes[ATTR_MEDIA_TITLE] == "Title" + assert state.attributes[ATTR_MEDIA_ARTIST] == "Artist" + assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Album Name" + assert state.attributes[ATTR_MEDIA_DURATION] == 132 + assert state.attributes[ATTR_MEDIA_POSITION] == 64 + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", False) + mock_rpc_device.mock_update() + + mock_rpc_device.media_play_or_pause.assert_called_once() + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_IDLE + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", True) + mock_rpc_device.mock_update() + + assert len(mock_rpc_device.media_play_or_pause.mock_calls) == 2 + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_PLAYING + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + monkeypatch.setitem(mock_rpc_device.status["media"]["playback"], "enable", False) + mock_rpc_device.mock_update() + + mock_rpc_device.media_stop.assert_called_once() + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_IDLE + + +async def test_rpc_media_player_actions( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a Shelly RPC media player.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_rpc_device.media_next.assert_called_once() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_rpc_device.mock_update() + + mock_rpc_device.media_previous.assert_called_once() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + + mock_rpc_device.media_set_volume.assert_called_once_with(5) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: CONTENT_TYPE_AUDIO, + ATTR_MEDIA_CONTENT_ID: "12", + }, + blocking=True, + ) + + mock_rpc_device.media_play_media.assert_called_once_with(12) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: CONTENT_TYPE_RADIO, + ATTR_MEDIA_CONTENT_ID: "2", + }, + blocking=True, + ) + + mock_rpc_device.media_play_radio_station.assert_called_once_with(2) + + +async def test_rpc_media_player_play_media_errors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a Shelly RPC errors in play media method.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + with pytest.raises( + HomeAssistantError, match="Unsupported media ID for Shelly device: invalid" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: CONTENT_TYPE_RADIO, + ATTR_MEDIA_CONTENT_ID: "invalid", + }, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, match="Unsupported media type for Shelly device: invalid" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "invalid", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + ) + + +async def test_get_image_http( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test get image via http command.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert "entity_picture_local" not in state.attributes + + client = await hass_client_no_auth() + + resp = await client.get(state.attributes["entity_picture"]) + content = await resp.read() + + assert isinstance(content, bytes) + + +async def test_get_image_http_base64_decode_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test get image via http command base64 decode error.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + status["media"]["playback"]["media_meta"]["thumb"] = "data:image/webp;base64,0" + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert "entity_picture_local" not in state.attributes + + client = await hass_client_no_auth() + + resp = await client.get(state.attributes["entity_picture"]) + content = await resp.read() + + assert isinstance(content, bytes) + + +async def test_rpc_media_player_browse_media_root( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media root.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"]["title"] == "Shelly" + assert msg["result"]["media_class"] == "directory" + assert msg["result"]["media_content_id"] == "" + assert [child["title"] for child in msg["result"]["children"]] == [ + "Radio stations", + "Audio files", + ] + assert [child["media_content_type"] for child in msg["result"]["children"]] == [ + CONTENT_TYPE_RADIO, + CONTENT_TYPE_AUDIO, + ] + + +async def test_rpc_media_player_browse_media_radio_stations( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media radio stations.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_RADIO_STATION + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_radio_stations.return_value = RADIO_STATIONS + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": CONTENT_TYPE_RADIO, + "media_content_id": CONTENT_TYPE_RADIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"]["title"] == "Radio stations" + assert msg["result"]["media_class"] == "directory" + assert msg["result"]["media_content_type"] == CONTENT_TYPE_RADIO + assert [child["title"] for child in msg["result"]["children"]] == [ + station["name"] for station in RADIO_STATIONS + ] + assert [child["media_content_id"] for child in msg["result"]["children"]] == [ + str(station["id"]) for station in RADIO_STATIONS + ] + assert [child["thumbnail"] for child in msg["result"]["children"]] == [ + station["icon"] for station in RADIO_STATIONS + ] + + +async def test_rpc_media_player_browse_media_audio_files( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media audio files.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_media.return_value = AUDIO_FILES + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": CONTENT_TYPE_AUDIO, + "media_content_id": CONTENT_TYPE_AUDIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"]["title"] == "Audio files" + assert msg["result"]["media_class"] == "directory" + assert msg["result"]["media_content_type"] == CONTENT_TYPE_AUDIO + assert [child["title"] for child in msg["result"]["children"]] == [ + item["title"] for item in AUDIO_FILES if item["type"] == "AUDIO" + ] + assert [child["media_content_id"] for child in msg["result"]["children"]] == [ + str(item["id"]) for item in AUDIO_FILES if item["type"] == "AUDIO" + ] + assert [child["thumbnail"] for child in msg["result"]["children"]] == [ + item["preview"] for item in AUDIO_FILES if item["type"] == "AUDIO" + ] + + +async def test_rpc_media_player_browse_media_unsupported_media_type( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test Shelly media player browse media returns unsupported media content type.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_media.return_value = AUDIO_FILES + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": "invalid", + "media_content_id": CONTENT_TYPE_AUDIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["error"] + assert msg["error"]["code"] == "home_assistant_error" + assert msg["error"]["message"] == ( + "Unsupported media content type for Shelly device: invalid" + ) + + +@pytest.mark.parametrize( + ("side_effect", "expected_message"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for media_player.test_name of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for media_player.test_name of Test name", + ), + ( + InvalidAuthError, + "Authentication failed for Test name, please update your credentials", + ), + ], +) +async def test_rpc_media_player_browse_media_errors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + hass_ws_client: WebSocketGenerator, + side_effect: Exception, + expected_message: str, +) -> None: + """Test Shelly media player browse media returns errors.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + monkeypatch.setattr(mock_rpc_device, "status", status) + mock_rpc_device.media_list_media.side_effect = side_effect + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID, + "media_content_type": CONTENT_TYPE_AUDIO, + "media_content_id": CONTENT_TYPE_AUDIO, + } + ) + + msg = await websocket_client.receive_json() + + assert msg["error"] + assert msg["error"]["code"] == "home_assistant_error" + assert msg["error"]["message"] == expected_message + + +async def test_rpc_media_player_no_media_meta( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test a Shelly RPC media player with no media metadata.""" + status = deepcopy(mock_rpc_device.status) + status["media"] = STATUS_AUDIO_FILE + status["media"]["playback"].pop("media_meta") + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_PLAYING + assert state.attributes.get(ATTR_MEDIA_TITLE) is None + assert state.attributes.get(ATTR_MEDIA_ARTIST) is None + assert state.attributes.get(ATTR_MEDIA_ALBUM_NAME) is None + assert state.attributes.get(ATTR_MEDIA_DURATION) is None + assert state.attributes.get(ATTR_MEDIA_POSITION) is None diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 93c086d6da54a2..b9d37ad1156f01 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -22,7 +22,6 @@ GEN1_RELEASE_URL, GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, - UPTIME_DEVIATION, WALL_DISPLAY_RELEASE_URL, ) from homeassistant.components.shelly.utils import ( @@ -30,7 +29,6 @@ get_block_device_sleep_period, get_block_input_triggers, get_block_number_of_channels, - get_device_uptime, get_host, get_release_url, get_rpc_channel_name, @@ -39,7 +37,6 @@ is_block_momentary_input, mac_address_from_name, ) -from homeassistant.util import dt as dt_util DEVICE_BLOCK_ID = 4 @@ -149,19 +146,6 @@ async def test_get_block_device_sleep_period( assert get_block_device_sleep_period(settings) == sleep_period -@pytest.mark.freeze_time("2019-01-10 18:43:00+00:00") -async def test_get_device_uptime() -> None: - """Test block test get device uptime.""" - assert get_device_uptime( - 55, dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) - ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) - - assert get_device_uptime( - 55 - UPTIME_DEVIATION, - dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")), - ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:43:05+00:00")) - - async def test_get_block_input_triggers( mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/components/siren/test_condition.py b/tests/components/siren/test_condition.py index 8da90f57b97d73..3399208cf7a9f1 100644 --- a/tests/components/siren/test_condition.py +++ b/tests/components/siren/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_siren_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("siren.is_off", {}, True, True), + ("siren.is_on", {}, True, True), + ], +) +async def test_siren_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that siren conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/switch/test_condition.py b/tests/components/switch/test_condition.py index 16154bc027e356..7a7cdbc4aa4129 100644 --- a/tests/components/switch/test_condition.py +++ b/tests/components/switch/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -46,6 +47,31 @@ async def test_switch_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("switch.is_off", {}, True, True), + ("switch.is_on", {}, True, True), + ], +) +async def test_switch_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that switch conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index f636dbb79a83c9..0d0c77dc9c4264 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -15,7 +15,7 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import ( mock_dsm_external_usb_devices_usb0, @@ -356,3 +356,17 @@ async def test_no_external_usb( """Test Synology DSM without USB.""" sensor = hass.states.get("sensor.nas_meontheinternet_com_usb_disk_1_device_size") assert sensor is None + + +async def test_hub_device_info_mac_connections( + hass: HomeAssistant, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test that the hub DeviceInfo includes MAC address connections.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device is not None + assert device.connections == { + ("mac", "00:11:32:xx:xx:59"), + ("mac", "00:11:32:xx:xx:5a"), + } diff --git a/tests/components/temperature/test_condition.py b/tests/components/temperature/test_condition.py index 96199ea2c881e7..fc78895e75892f 100644 --- a/tests/components/temperature/test_condition.py +++ b/tests/components/temperature/test_condition.py @@ -14,6 +14,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, parametrize_numerical_attribute_condition_above_below_all, parametrize_numerical_attribute_condition_above_below_any, @@ -61,6 +62,38 @@ async def test_temperature_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +_CELSIUS_THRESHOLD = { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "\u00b0C"}, + } +} + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("temperature.is_value", _CELSIUS_THRESHOLD, True, False), + ], +) +async def test_temperature_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that temperature conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/text/test_condition.py b/tests/components/text/test_condition.py index 292a5e0b5bc01b..ebe761e4421c38 100644 --- a/tests/components/text/test_condition.py +++ b/tests/components/text/test_condition.py @@ -21,6 +21,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -48,6 +49,30 @@ async def test_text_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("text.is_equal_to", {"value": "hello"}, True, False), + ], +) +async def test_text_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that text conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + CONDITION_STATES_ANY = [ *parametrize_condition_states_any( condition="text.is_equal_to", diff --git a/tests/components/timer/test_condition.py b/tests/components/timer/test_condition.py index 3a60edca4c0c6e..781c088cf03197 100644 --- a/tests/components/timer/test_condition.py +++ b/tests/components/timer/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -40,6 +41,32 @@ async def test_timer_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("timer.is_active", {}, True, True), + ("timer.is_paused", {}, True, True), + ("timer.is_idle", {}, True, True), + ], +) +async def test_timer_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that timer conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/todo/test_condition.py b/tests/components/todo/test_condition.py index 26a0ef33566fb6..9723d1cc2a063a 100644 --- a/tests/components/todo/test_condition.py +++ b/tests/components/todo/test_condition.py @@ -11,6 +11,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -38,6 +39,30 @@ async def test_todo_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("todo.all_completed", {}, True, True), + ], +) +async def test_todo_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that todo conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py index 1a9a865c1c1897..c4a03a081e7b83 100644 --- a/tests/components/tractive/conftest.py +++ b/tests/components/tractive/conftest.py @@ -89,6 +89,10 @@ def send_server_unavailable_event(hass: HomeAssistant) -> None: patch( "homeassistant.components.tractive.aiotractive.Tractive", autospec=True ) as mock_client, + patch( + "homeassistant.components.tractive.asyncio.sleep", + new_callable=AsyncMock, + ), ): client = mock_client.return_value client.authenticate.return_value = {"user_id": "12345"} diff --git a/tests/components/update/test_condition.py b/tests/components/update/test_condition.py index e8e839d3a14e0d..6344828f8a1a13 100644 --- a/tests/components/update/test_condition.py +++ b/tests/components/update/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -39,6 +40,31 @@ async def test_update_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("update.is_available", {}, True, True), + ("update.is_not_available", {}, True, True), + ], +) +async def test_update_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that update conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/vacuum/test_condition.py b/tests/components/vacuum/test_condition.py index 7d3abfc38117ec..366a882e117a70 100644 --- a/tests/components/vacuum/test_condition.py +++ b/tests/components/vacuum/test_condition.py @@ -12,6 +12,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -43,6 +44,34 @@ async def test_vacuum_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vacuum.is_cleaning", {}, True, True), + ("vacuum.is_docked", {}, True, True), + ("vacuum.is_encountering_an_error", {}, True, True), + ("vacuum.is_paused", {}, True, True), + ("vacuum.is_returning", {}, True, True), + ], +) +async def test_vacuum_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vacuum conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/valve/test_condition.py b/tests/components/valve/test_condition.py index 5ec78a90229636..20b52236f5f37b 100644 --- a/tests/components/valve/test_condition.py +++ b/tests/components/valve/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -40,6 +41,31 @@ async def test_valve_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("valve.is_open", {}, True, False), + ("valve.is_closed", {}, True, False), + ], +) +async def test_valve_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that valve conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 2c4cd51a97b77a..fb7018bb6ef5bd 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -74,7 +74,7 @@ def mock_window() -> AsyncMock: window.device_updated_cbs = [] window.is_opening = False window.is_closing = False - window.position = MagicMock(position_percent=30, closed=False) + window.position = MagicMock(position_percent=30, closed=False, known=True) window.wink = AsyncMock() window.pyvlx = MagicMock() return window @@ -89,9 +89,13 @@ def mock_dual_roller_shutter() -> AsyncMock: cover.serial_number = "987654321" cover.is_opening = False cover.is_closing = False - cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) - cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) - cover.position = MagicMock(position_percent=30, closed=False) + cover.position_upper_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position_lower_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position = MagicMock(position_percent=30, closed=False, known=True) cover.pyvlx = MagicMock() return cover @@ -104,11 +108,11 @@ def mock_blind() -> AsyncMock: blind.name = "Test Blind" blind.serial_number = "4711" # Standard cover position (used by current_cover_position) - blind.position = MagicMock(position_percent=40, closed=False) + blind.position = MagicMock(position_percent=40, closed=False, known=True) blind.is_opening = False blind.is_closing = False # Orientation/tilt-related attributes and methods - blind.orientation = MagicMock(position_percent=25) + blind.orientation = MagicMock(position_percent=25, known=True) blind.open_orientation = AsyncMock() blind.close_orientation = AsyncMock() blind.stop_orientation = AsyncMock() @@ -175,9 +179,13 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: cover.serial_number = f"serial_{request.param.__name__}" cover.is_opening = False cover.is_closing = False - cover.position = MagicMock(position_percent=30, closed=False) - cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) - cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) + cover.position = MagicMock(position_percent=30, closed=False, known=True) + cover.position_upper_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position_lower_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) cover.pyvlx = MagicMock() return cover diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index a2620aac31dc41..483fbca5593e3e 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -33,6 +33,7 @@ STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -475,6 +476,77 @@ async def test_non_blind_has_no_tilt_position( assert "current_tilt_position" not in state.attributes +# Unknown position tests + + +async def test_window_unknown_position( + hass: HomeAssistant, mock_window: AsyncMock +) -> None: + """When the device position is not known, state and position must be unknown.""" + + entity_id = "cover.test_window" + + mock_window.position.known = False + await update_callback_entity(hass, mock_window) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("current_position") is None + + +@pytest.mark.parametrize( + ("unknown_attr", "unknown_entity_id"), + [ + ("position", "cover.test_dual_roller_shutter"), + ("position_upper_curtain", "cover.test_dual_roller_shutter_upper_shutter"), + ("position_lower_curtain", "cover.test_dual_roller_shutter_lower_shutter"), + ], +) +async def test_dual_roller_shutter_unknown_position( + hass: HomeAssistant, + mock_dual_roller_shutter: AsyncMock, + unknown_attr: str, + unknown_entity_id: str, +) -> None: + """Each part falls back to unknown independently when only its position is unknown.""" + + all_entity_ids = { + "cover.test_dual_roller_shutter", + "cover.test_dual_roller_shutter_upper_shutter", + "cover.test_dual_roller_shutter_lower_shutter", + } + + getattr(mock_dual_roller_shutter, unknown_attr).known = False + await update_callback_entity(hass, mock_dual_roller_shutter) + + state = hass.states.get(unknown_entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("current_position") is None + + for entity_id in all_entity_ids - {unknown_entity_id}: + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNKNOWN + assert state.attributes.get("current_position") == 70 + + +async def test_blind_unknown_tilt_position( + hass: HomeAssistant, mock_blind: AsyncMock +) -> None: + """Tilt position must be None when the orientation is not known.""" + + entity_id = "cover.test_blind" + + mock_blind.orientation.known = False + await update_callback_entity(hass, mock_blind) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes.get("current_tilt_position") is None + + # Exception handling tests diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index f4965ec70b3ea2..0ddf476a58cc19 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -26,6 +26,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, assert_numerical_condition_unit_conversion, parametrize_condition_states_all, parametrize_condition_states_any, @@ -71,6 +72,31 @@ async def test_water_heater_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("water_heater.is_off", {}, True, True), + ("water_heater.is_on", {}, True, False), + ], +) +async def test_water_heater_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that water_heater conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), diff --git a/tests/components/window/test_condition.py b/tests/components/window/test_condition.py index 5e64d10b0e632f..65bbe3f73f1e9f 100644 --- a/tests/components/window/test_condition.py +++ b/tests/components/window/test_condition.py @@ -13,6 +13,7 @@ assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_gated_by_labs_flag, + assert_condition_options_supported, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -47,6 +48,31 @@ async def test_window_conditions_gated_by_labs_flag( await assert_condition_gated_by_labs_flag(hass, caplog, condition) +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("window.is_closed", {}, True, False), + ("window.is_open", {}, True, False), + ], +) +async def test_window_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that window conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + # --- binary_sensor tests ---