diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 1c815668b6a17a..aeff456b1be283 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,6 +1,5 @@ """The Airly integration.""" -from datetime import timedelta import logging from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_DOMAIN @@ -9,7 +8,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_USE_NEAREST, DOMAIN, MIN_UPDATE_INTERVAL +from .const import CONF_USE_NEAREST, DOMAIN from .coordinator import AirlyConfigEntry, AirlyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -55,8 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> boo websession = async_get_clientsession(hass) - update_interval = timedelta(minutes=MIN_UPDATE_INTERVAL) - coordinator = AirlyDataUpdateCoordinator( hass, entry, @@ -64,7 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> boo api_key, latitude, longitude, - update_interval, use_nearest, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index 2447096e7a4125..cac9c25cb234a2 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -73,7 +73,6 @@ def __init__( api_key: str, latitude: float, longitude: float, - update_interval: timedelta, use_nearest: bool, ) -> None: """Initialize.""" @@ -82,31 +81,31 @@ def __init__( # Currently, Airly only supports Polish and English language = "pl" if hass.config.language == "pl" else "en" self.airly = Airly(api_key, session, language=language) - self.use_nearest = use_nearest + + if use_nearest: + self.measurements = self.airly.create_measurements_session_nearest( + latitude, longitude, max_distance_km=5 + ) + else: + self.measurements = self.airly.create_measurements_session_point( + latitude, longitude + ) super().__init__( hass, _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=update_interval, + update_interval=timedelta(minutes=MIN_UPDATE_INTERVAL), ) @override async def _async_update_data(self) -> dict[str, str | float | int]: """Update data via library.""" data: dict[str, str | float | int] = {} - if self.use_nearest: - measurements = self.airly.create_measurements_session_nearest( - self.latitude, self.longitude, max_distance_km=5 - ) - else: - measurements = self.airly.create_measurements_session_point( - self.latitude, self.longitude - ) try: async with timeout(DEFAULT_TIMEOUT): - await measurements.update() + await self.measurements.update() except (AirlyError, ClientConnectorError, TimeoutError) as error: raise UpdateFailed( translation_domain=DOMAIN, @@ -131,9 +130,9 @@ async def _async_update_data(self) -> dict[str, str | float | int]: self.airly.requests_remaining, ) - values = measurements.current["values"] - index = measurements.current["indexes"][0] - standards = measurements.current["standards"] + values = self.measurements.current["values"] + index = self.measurements.current["indexes"][0] + standards = self.measurements.current["standards"] if index["description"] == NO_AIRLY_SENSORS: raise UpdateFailed( diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 103e4b4ef08d70..2768d0922fbfc0 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -45,35 +45,30 @@ class BrotherSensorEntityDescription(SensorEntityDescription): BrotherSensorEntityDescription( key="status", translation_key="status", - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", translation_key="page_counter", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.page_counter, ), BrotherSensorEntityDescription( key="bw_counter", translation_key="bw_pages", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.bw_counter, ), BrotherSensorEntityDescription( key="color_counter", translation_key="color_pages", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.color_counter, ), BrotherSensorEntityDescription( key="duplex_unit_pages_counter", translation_key="duplex_unit_page_counter", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.duplex_unit_pages_counter, ), BrotherSensorEntityDescription( @@ -81,21 +76,18 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_remaining_life, ), BrotherSensorEntityDescription( key="drum_remaining_pages", translation_key="drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_remaining_pages, ), BrotherSensorEntityDescription( key="drum_counter", translation_key="drum_page_counter", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.drum_counter, ), BrotherSensorEntityDescription( @@ -103,21 +95,18 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_remaining_life, ), BrotherSensorEntityDescription( key="black_drum_remaining_pages", translation_key="black_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_remaining_pages, ), BrotherSensorEntityDescription( key="black_drum_counter", translation_key="black_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_drum_counter, ), BrotherSensorEntityDescription( @@ -125,21 +114,18 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_remaining_life, ), BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", translation_key="cyan_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_remaining_pages, ), BrotherSensorEntityDescription( key="cyan_drum_counter", translation_key="cyan_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_drum_counter, ), BrotherSensorEntityDescription( @@ -147,21 +133,18 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_remaining_life, ), BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", translation_key="magenta_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_remaining_pages, ), BrotherSensorEntityDescription( key="magenta_drum_counter", translation_key="magenta_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_drum_counter, ), BrotherSensorEntityDescription( @@ -169,21 +152,18 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_remaining_life, ), BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", translation_key="yellow_drum_remaining_pages", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_remaining_pages, ), BrotherSensorEntityDescription( key="yellow_drum_counter", translation_key="yellow_drum_page_counter", state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_drum_counter, ), BrotherSensorEntityDescription( @@ -191,7 +171,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.belt_unit_remaining_life, ), BrotherSensorEntityDescription( @@ -199,7 +178,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.fuser_remaining_life, ), BrotherSensorEntityDescription( @@ -207,7 +185,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.laser_remaining_life, ), BrotherSensorEntityDescription( @@ -215,7 +192,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.pf_kit_1_remaining_life, ), BrotherSensorEntityDescription( @@ -223,7 +199,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.pf_kit_mp_remaining_life, ), BrotherSensorEntityDescription( @@ -231,7 +206,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_toner_remaining, ), BrotherSensorEntityDescription( @@ -239,7 +213,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_toner_remaining, ), BrotherSensorEntityDescription( @@ -247,7 +220,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_toner_remaining, ), BrotherSensorEntityDescription( @@ -255,7 +227,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_toner_remaining, ), BrotherSensorEntityDescription( @@ -263,7 +234,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.black_ink_remaining, ), BrotherSensorEntityDescription( @@ -271,7 +241,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.cyan_ink_remaining, ), BrotherSensorEntityDescription( @@ -279,7 +248,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.magenta_ink_remaining, ), BrotherSensorEntityDescription( @@ -287,7 +255,6 @@ class BrotherSensorEntityDescription(SensorEntityDescription): translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.yellow_ink_remaining, ), BrotherSensorEntityDescription( diff --git a/homeassistant/components/gatus/icons.json b/homeassistant/components/gatus/icons.json new file mode 100644 index 00000000000000..16d37aed6705a6 --- /dev/null +++ b/homeassistant/components/gatus/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "dns_rcode": { + "default": "mdi:dns" + }, + "last_event": { + "default": "mdi:history", + "state": { + "healthy": "mdi:check-circle-outline", + "resolved": "mdi:check-circle-outline", + "start": "mdi:play-circle-outline", + "unhealthy": "mdi:alert-circle-outline" + } + }, + "status_code": { + "default": "mdi:list-status" + } + } + } +} diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index 462f466a546931..ed597d3a37ee2b 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -71,9 +71,7 @@ rules: status: exempt comment: Entity names are dynamically provided by the Gatus service. exception-translations: done - icon-translations: - status: exempt - comment: Entities use the connectivity device class for their icon and define no custom icons. + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 661b5a13f55661..a623d60fcb6d58 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING, Any, override -from linkplay.bridge import LinkPlayBridge +from linkplay.bridge import LinkPlayBridge, LinkPlayPlayer from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus from linkplay.controller import LinkPlayController, LinkPlayMultiroom from linkplay.exceptions import LinkPlayRequestException @@ -189,37 +189,37 @@ async def async_set_volume_level(self, volume: float) -> None: @override async def async_media_pause(self) -> None: """Send pause command.""" - await self._bridge.player.pause() + await self._active_player.pause() @exception_wrap @override async def async_media_play(self) -> None: """Send play command.""" - await self._bridge.player.resume() + await self._active_player.resume() @exception_wrap @override async def async_media_stop(self) -> None: """Send stop command.""" - await self._bridge.player.stop() + await self._active_player.stop() @exception_wrap @override async def async_media_next_track(self) -> None: """Send next command.""" - await self._bridge.player.next() + await self._active_player.next() @exception_wrap @override async def async_media_previous_track(self) -> None: """Send previous command.""" - await self._bridge.player.previous() + await self._active_player.previous() @exception_wrap @override async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" - await self._bridge.player.set_loop_mode(REPEAT_MAP_INV[repeat]) + await self._active_player.set_loop_mode(REPEAT_MAP_INV[repeat]) @override async def async_browse_media( @@ -253,13 +253,13 @@ async def async_play_media( media_id = play_item.url url = async_process_play_media_url(self.hass, media_id) - await self._bridge.player.play(url) + await self._active_player.play(url) @exception_wrap async def async_play_preset(self, preset_number: int) -> None: """Play preset number.""" try: - await self._bridge.player.play_preset(preset_number) + await self._active_player.play_preset(preset_number) except ValueError as err: raise HomeAssistantError(err) from err @@ -267,7 +267,7 @@ async def async_play_preset(self, preset_number: int) -> None: @override async def async_media_seek(self, position: float) -> None: """Seek to a position.""" - await self._bridge.player.seek(round(position)) + await self._active_player.seek(round(position)) @exception_wrap @override @@ -326,12 +326,25 @@ def group_members(self) -> list[str]: assert leader_id is not None return [leader_id, *followers] + @property + def _active_player(self) -> LinkPlayPlayer: + """Return the player that holds the active media info. + + A follower in a multiroom group does not expose the media info of the + stream it is playing; that info is only available on the group leader. + """ + multiroom = self._bridge.multiroom + if multiroom is not None and multiroom.leader is not self._bridge: + return multiroom.leader.player + return self._bridge.player + @property @override def media_image_url(self) -> str | None: """Image url of playing media.""" - if self._bridge.player.status in [PlayingStatus.PLAYING, PlayingStatus.PAUSED]: - return str(self._bridge.player.album_art) + player = self._active_player + if player.status in [PlayingStatus.PLAYING, PlayingStatus.PAUSED]: + return str(player.album_art) return None @exception_wrap @@ -352,24 +365,28 @@ def _update_properties(self) -> None: self._attr_state = STATE_MAP[self._bridge.player.status] self._attr_volume_level = self._bridge.player.volume / 100 self._attr_is_volume_muted = self._bridge.player.muted - self._attr_repeat = REPEAT_MAP[self._bridge.player.loop_mode] - self._attr_shuffle = self._bridge.player.loop_mode == LoopMode.RANDOM_PLAYBACK + self._attr_repeat = REPEAT_MAP[self._active_player.loop_mode] + self._attr_shuffle = self._active_player.loop_mode == LoopMode.RANDOM_PLAYBACK self._attr_sound_mode = self._bridge.player.equalizer_mode.value self._attr_supported_features = DEFAULT_FEATURES if self._bridge.player.status == PlayingStatus.PLAYING: - if self._bridge.player.total_length != 0: + # A follower mirrors the media info from the group leader and routes + # its transport controls to the leader, so the seekable features + # follow the leader's player as well. + player = self._active_player + if player.total_length != 0: self._attr_supported_features = ( self._attr_supported_features | SEEKABLE_FEATURES ) self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other") - self._attr_media_position = self._bridge.player.current_position_in_seconds + self._attr_media_position = player.current_position_in_seconds self._attr_media_position_updated_at = utcnow() - self._attr_media_duration = self._bridge.player.total_length_in_seconds - self._attr_media_artist = self._bridge.player.artist - self._attr_media_title = self._bridge.player.title - self._attr_media_album_name = self._bridge.player.album + self._attr_media_duration = player.total_length_in_seconds + self._attr_media_artist = player.artist + self._attr_media_title = player.title + self._attr_media_album_name = player.album elif self._bridge.player.status == PlayingStatus.STOPPED: self._attr_media_position = None self._attr_media_position_updated_at = None diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 93f7f72b14f0df..dd9ad0ea75481b 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -83,14 +83,14 @@ class ReolinkCameraEntityDescription( key="autotrack_snapshots_sub", stream="autotrack_snapshots_sub", translation_key="telephoto_snapshots_sub", - supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + supported=lambda api, ch: api.supported(ch, "autotrack_snapshot"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="autotrack_snapshots_main", stream="autotrack_snapshots_main", translation_key="telephoto_snapshots_main", - supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + supported=lambda api, ch: api.supported(ch, "autotrack_snapshot"), entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index d31620d0e526a4..c6ec0e7f5fb4d8 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.21.16"] + "requirements": ["reolink-aio==0.21.17"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index f309e283edd5d6..a12c95d1445d20 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -165,8 +165,8 @@ class ReolinkChimeNumberEntityDescription( entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, - native_min_value=30, - native_max_value=900, + get_min_value=lambda api, ch: api.whiteled_event_on_time_range(ch)[0], + get_max_value=lambda api, ch: api.whiteled_event_on_time_range(ch)[1], supported=lambda api, ch: api.supported(ch, "floodlight_event"), value=lambda api, ch: api.whiteled_event_on_time(ch), method=lambda api, ch, value: api.baichuan.set_floodlight( @@ -183,8 +183,8 @@ class ReolinkChimeNumberEntityDescription( entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, - native_min_value=10, - native_max_value=30, + get_min_value=lambda api, ch: api.whiteled_event_flash_time_range(ch)[0], + get_max_value=lambda api, ch: api.whiteled_event_flash_time_range(ch)[1], supported=lambda api, ch: api.supported(ch, "floodlight_event"), value=lambda api, ch: api.whiteled_event_flash_time(ch), method=lambda api, ch, value: api.baichuan.set_floodlight( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 34ceb130877fe9..1fcb860f906a40 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -553,6 +553,7 @@ "state": { "adaptive": "Adaptive", "auto": "[%key:common::state::auto%]", + "auto_pir": "Auto PIR", "autoadaptive": "Auto adaptive", "off": "[%key:common::state::off%]", "onatnight": "On at night", diff --git a/homeassistant/config.py b/homeassistant/config.py index c2c7ed5e336285..ca29e418c51212 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -826,8 +826,8 @@ def _get_log_message_and_stack_print_pref( hass, exception, platform_path, platform_config, link ) if annotation := find_annotation(platform_config, exception.path): - placeholders["config_file"], line = annotation - placeholders["line"] = str(line) + placeholders["config_file"] = _relpath(hass, annotation[0]) + placeholders["line"] = str(annotation[1]) else: if TYPE_CHECKING: assert isinstance(exception, HomeAssistantError) @@ -835,8 +835,8 @@ def _get_log_message_and_stack_print_pref( hass, exception, platform_path, platform_config, link ) if annotation := find_annotation(platform_config, [platform_path]): - placeholders["config_file"], line = annotation - placeholders["line"] = str(line) + placeholders["config_file"] = _relpath(hass, annotation[0]) + placeholders["line"] = str(annotation[1]) show_stack_trace = True return (log_message, show_stack_trace, placeholders) diff --git a/requirements_all.txt b/requirements_all.txt index 936d8cb728aa2e..a73f3adccb790e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2989,7 +2989,7 @@ renault-api==0.5.13 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.21.16 +reolink-aio==0.21.17 # homeassistant.components.radio_frequency rf-protocols==4.3.0 diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index 414ac9e52d039a..8b3a26d9f1f924 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -1,13 +1,32 @@ """Test blueprint models.""" +from collections.abc import Callable import logging +from typing import Any from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, errors, models +from homeassistant.config import _get_annotation from homeassistant.core import HomeAssistant from homeassistant.util.yaml import Input +from homeassistant.util.yaml.objects import NodeDictClass, NodeListClass, NodeStrClass + +from tests.common import get_test_config_dir + +# A real blueprint in the test config dir, loaded from disk by DomainBlueprints. +BLUEPRINT_PATH = "test_event_sensor.yaml" +BLUEPRINT_FILE = get_test_config_dir("blueprints", "template", BLUEPRINT_PATH) + +SUBSTITUTE_XFAIL = pytest.mark.xfail( + strict=True, + reason=( + "annotatedyaml's substitute (input.py:51,54) rebuilds containers with " + "comprehensions, so the node class and the __config_file__/__line__ slots " + "are both dropped" + ), +) @pytest.fixture @@ -62,6 +81,41 @@ def blueprint_2(request: pytest.FixtureRequest) -> models.Blueprint: return models.Blueprint(blueprint, schema=BLUEPRINT_SCHEMA) +@pytest.fixture +async def yaml_blueprint(hass: HomeAssistant) -> models.Blueprint: + """Blueprint loaded from a real YAML file, so its nodes carry source annotations.""" + domain_bps = models.DomainBlueprints( + hass, + "template", + logging.getLogger(__name__), + None, + AsyncMock(), + BLUEPRINT_SCHEMA, + ) + return await domain_bps.async_get_blueprint(BLUEPRINT_PATH) + + +@pytest.fixture +def yaml_blueprint_inputs( + yaml_blueprint: models.Blueprint, +) -> models.BlueprintInputs: + """Validated inputs for the YAML blueprint fixture.""" + inputs = models.BlueprintInputs( + yaml_blueprint, + { + "use_blueprint": { + "path": BLUEPRINT_PATH, + "input": { + "event_type": "my_event", + "event_data": {"hello": "world"}, + }, + } + }, + ) + inputs.validate() + return inputs + + @pytest.fixture def domain_bps(hass: HomeAssistant) -> models.DomainBlueprints: """Domain blueprints fixture.""" @@ -227,6 +281,92 @@ def test_blueprint_inputs_override_default(blueprint_2: models.Blueprint) -> Non assert inputs.async_substitute() == {"example": 1, "example-default": "custom"} +def test_yaml_blueprint_keeps_annotations_through_the_schema( + yaml_blueprint: models.Blueprint, +) -> None: + """Test the blueprint schema hands the config through with its annotations. + + BLUEPRINT_SCHEMA allows extra keys, and probatio stores the original value + object for those, so anything missing after async_substitute was dropped by + the substitution rather than by the schema. + """ + data = yaml_blueprint.data + + assert type(data["triggers"]) is NodeListClass + assert _get_annotation(data["triggers"]) == (BLUEPRINT_FILE, 18) + assert _get_annotation(data["triggers"][0]) == (BLUEPRINT_FILE, 18) + assert _get_annotation(data["sensor"]) == (BLUEPRINT_FILE, 24) + assert _get_annotation(data["sensor"]["attributes"]) == (BLUEPRINT_FILE, 27) + + +@pytest.mark.parametrize( + ("select", "expected_type", "expected_annotation"), + [ + pytest.param( + lambda config: next(key for key in config if key == "triggers"), + NodeStrClass, + (BLUEPRINT_FILE, 17), + id="key_at_root", + ), + pytest.param( + lambda config: next( + key for key in config["triggers"][0] if key == "trigger" + ), + NodeStrClass, + (BLUEPRINT_FILE, 18), + id="key_in_list_element", + ), + pytest.param( + lambda config: config["triggers"], + NodeListClass, + (BLUEPRINT_FILE, 18), + marks=SUBSTITUTE_XFAIL, + id="list_in_dict", + ), + pytest.param( + lambda config: config["triggers"][0], + NodeDictClass, + (BLUEPRINT_FILE, 18), + marks=SUBSTITUTE_XFAIL, + id="dict_in_list", + ), + pytest.param( + lambda config: config["sensor"], + NodeDictClass, + (BLUEPRINT_FILE, 24), + marks=SUBSTITUTE_XFAIL, + id="dict_in_dict", + ), + pytest.param( + lambda config: config["sensor"]["attributes"], + NodeDictClass, + (BLUEPRINT_FILE, 27), + marks=SUBSTITUTE_XFAIL, + id="dict_in_dict_in_dict", + ), + ], +) +def test_substituted_blueprint_keeps_annotations( + yaml_blueprint_inputs: models.BlueprintInputs, + select: Callable[[dict], Any], + expected_type: type, + expected_annotation: tuple[str, int], +) -> None: + """Test the substituted config keeps the node classes and their locations. + + The keys pass today because substitute's dict comprehension hands them + through unchanged, which is the only reason a blueprint-backed config still + reports where an error came from. The result's top level is deliberately not + asserted: async_substitute merges it into a fresh dict literal, so it can + never carry an annotation. + """ + config = yaml_blueprint_inputs.async_substitute() + node = select(config) + + assert type(node) is expected_type + assert _get_annotation(node) == expected_annotation + + async def test_domain_blueprints_get_blueprint_errors( hass: HomeAssistant, domain_bps: models.DomainBlueprints ) -> None: diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index 5d703ca7524c9b..64b8a6cdcaeca5 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -14,7 +14,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_b_w_pages', 'has_entity_name': True, 'hidden_by': None, @@ -68,7 +68,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_belt_unit_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -122,7 +122,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -176,7 +176,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -230,7 +230,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -284,7 +284,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_black_toner_remaining', 'has_entity_name': True, 'hidden_by': None, @@ -338,7 +338,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_color_pages', 'has_entity_name': True, 'hidden_by': None, @@ -392,7 +392,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -446,7 +446,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -500,7 +500,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -554,7 +554,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_cyan_toner_remaining', 'has_entity_name': True, 'hidden_by': None, @@ -608,7 +608,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -662,7 +662,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -716,7 +716,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -770,7 +770,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -824,7 +824,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_fuser_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -878,7 +878,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_magenta_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -932,7 +932,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -986,7 +986,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -1040,7 +1040,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_magenta_toner_remaining', 'has_entity_name': True, 'hidden_by': None, @@ -1094,7 +1094,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -1148,7 +1148,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_pf_kit_1_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -1200,7 +1200,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_status', 'has_entity_name': True, 'hidden_by': None, @@ -1303,7 +1303,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_yellow_drum_page_counter', 'has_entity_name': True, 'hidden_by': None, @@ -1357,7 +1357,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_lifetime', 'has_entity_name': True, 'hidden_by': None, @@ -1411,7 +1411,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_pages', 'has_entity_name': True, 'hidden_by': None, @@ -1465,7 +1465,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.hl_l2340dw_yellow_toner_remaining', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/linkplay/fixtures/getPlayerEx_follower.json b/tests/components/linkplay/fixtures/getPlayerEx_follower.json new file mode 100644 index 00000000000000..fbf16edec989a1 --- /dev/null +++ b/tests/components/linkplay/fixtures/getPlayerEx_follower.json @@ -0,0 +1,19 @@ +{ + "type": "0", + "ch": "0", + "mode": "99", + "loop": "0", + "eq": "0", + "status": "play", + "curpos": "0", + "offset_pts": "0", + "totlen": "0", + "Title": "", + "Artist": "", + "Album": "", + "alarmflag": "0", + "plicount": "0", + "plicurr": "0", + "vol": "80", + "mute": "0" +} diff --git a/tests/components/linkplay/test_media_player.py b/tests/components/linkplay/test_media_player.py new file mode 100644 index 00000000000000..a28d0ffc16e6a3 --- /dev/null +++ b/tests/components/linkplay/test_media_player.py @@ -0,0 +1,165 @@ +"""Tests for the LinkPlay media player.""" + +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import AsyncMock, patch + +from linkplay.bridge import ( + LinkPlayBridge, + LinkPlayDevice, + LinkPlayMultiroom, + LinkPlayPlayer, +) +from linkplay.consts import API_ENDPOINT, LoopMode, PlayingStatus +import pytest + +from homeassistant.components.linkplay.const import DOMAIN, SHARED_DATA +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + RepeatMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, + STATE_PLAYING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from . import setup_integration +from .conftest import HOST, mock_lp_aiohttp_client + +from tests.common import MockConfigEntry, async_load_fixture + +ENTITY_ID = "media_player.smart_zone_1_54b9" +LEADER_ENTITY_ID = "media_player.leader" +LEADER_UUID = "FF31F09E-5001-FBDE-0546-2DBFFF31F0AA" + + +@pytest.fixture +async def leader_player( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> AsyncGenerator[AsyncMock]: + """Set up a device that is a follower in a multiroom group and mock its leader.""" + + with ( + mock_lp_aiohttp_client() as mock_session, + patch.object(LinkPlayMultiroom, "update_status", return_value=None), + ): + for endpoint in (f"https://{HOST}", f"http://{HOST}"): + mock_session.get( + API_ENDPOINT.format(endpoint, "getPlayerStatusEx"), + text=await async_load_fixture( + hass, "getPlayerEx_follower.json", DOMAIN + ), + ) + mock_session.get( + API_ENDPOINT.format(endpoint, "getStatusEx"), + text=await async_load_fixture(hass, "getStatusEx.json", DOMAIN), + ) + + await setup_integration(hass, mock_config_entry) + + player = AsyncMock(spec=LinkPlayPlayer) + player.status = PlayingStatus.PLAYING + player.title = "Zelda's Lullaby" + player.artist = "Spiritual Concepts" + player.album = "Cello Covers" + player.current_position_in_seconds = 17 + player.total_length = 62000 + player.total_length_in_seconds = 62 + player.loop_mode = LoopMode.RANDOM_PLAYBACK + + leader = AsyncMock(spec=LinkPlayBridge) + leader.player = player + leader.device = AsyncMock(spec=LinkPlayDevice) + leader.device.uuid = LEADER_UUID + hass.data[DOMAIN][SHARED_DATA].entity_to_bridge[LEADER_ENTITY_ID] = LEADER_UUID + + bridge = mock_config_entry.runtime_data.bridge + bridge.multiroom = LinkPlayMultiroom(leader) + bridge.multiroom.followers = [bridge] + + await async_update_entity(hass, ENTITY_ID) + yield player + + +@pytest.mark.usefixtures("leader_player") +async def test_follower_mirrors_leader_media_info(hass: HomeAssistant) -> None: + """Test that a follower shows the media info of its group leader.""" + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_PLAYING + assert state.attributes[ATTR_MEDIA_TITLE] == "Zelda's Lullaby" + assert state.attributes[ATTR_MEDIA_ARTIST] == "Spiritual Concepts" + assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Cello Covers" + assert state.attributes[ATTR_MEDIA_POSITION] == 17 + assert state.attributes[ATTR_MEDIA_DURATION] == 62 + assert state.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.ALL + assert state.attributes[ATTR_MEDIA_SHUFFLE] is True + + # volume and source stay on the follower itself + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.8 + assert state.attributes[ATTR_INPUT_SOURCE] == "Follower" + + +@pytest.mark.parametrize( + ("service", "service_data", "method", "method_args"), + [ + pytest.param(SERVICE_MEDIA_PAUSE, {}, "pause", (), id="pause"), + pytest.param(SERVICE_MEDIA_PLAY, {}, "resume", (), id="play"), + pytest.param(SERVICE_MEDIA_STOP, {}, "stop", (), id="stop"), + pytest.param(SERVICE_MEDIA_NEXT_TRACK, {}, "next", (), id="next_track"), + pytest.param( + SERVICE_MEDIA_PREVIOUS_TRACK, {}, "previous", (), id="previous_track" + ), + pytest.param( + SERVICE_MEDIA_SEEK, + {ATTR_MEDIA_SEEK_POSITION: 42}, + "seek", + (42,), + id="seek", + ), + pytest.param( + SERVICE_REPEAT_SET, + {ATTR_MEDIA_REPEAT: RepeatMode.ONE}, + "set_loop_mode", + (LoopMode.CONTINOUS_PLAY_ONE_SONG,), + id="repeat_set", + ), + ], +) +async def test_follower_transport_commands_go_to_leader( + hass: HomeAssistant, + leader_player: AsyncMock, + service: str, + service_data: dict[str, Any], + method: str, + method_args: tuple[Any, ...], +) -> None: + """Test that transport commands on a follower control the group leader.""" + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + blocking=True, + ) + + getattr(leader_player, method).assert_awaited_once_with(*method_args) diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 5c1752aa8a1b7f..c9021567e031f7 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -54,6 +54,8 @@ "mock_air_purifier", "mock_battery_storage", "mock_chime", + "mock_closure_covering_lift", + "mock_closure_roof_window", "mock_cooktop", "mock_dimmable_light", "mock_dimmable_plugin_unit", diff --git a/tests/components/matter/fixtures/nodes/mock_closure_covering_lift.json b/tests/components/matter/fixtures/nodes/mock_closure_covering_lift.json new file mode 100644 index 00000000000000..a00a7d44f041f2 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mock_closure_covering_lift.json @@ -0,0 +1,355 @@ +{ + "node_id": 72, + "date_commissioned": "2026-09-10T13:10:12.756000", + "last_interview": "2026-09-10T13:10:22.351000", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/70/65533": 3, + "0/70/65532": 0, + "0/70/0": 300, + "0/70/1": 300, + "0/70/2": 5000, + "0/70/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/70/65529": [], + "0/70/65528": [], + "0/65/0": [], + "0/65/65533": 1, + "0/65/65532": 0, + "0/65/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/65/65529": [], + "0/65/65528": [], + "0/63/65533": 3, + "0/63/65532": 0, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 4, + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [5, 2], + "0/62/65533": 2, + "0/62/0": [ + { + "1": "FTABAQIkAgE3AyQUABgmBMMXVDAmBUNOAUU3BiQVAiQRARgkBwEkCAEwCUEEBvUu2GoclkSBNg3FSKorIU5cWzMiLpRyMzOUtZYXgDoXXQu+3tJp9ZllqjAJz9DZp+LPrbGn9nj3dWv3rkVGZjcKNQEoARgkAgE2AwQCBAEYMAQUGVh/XVvHe/Mo2TUl5uFQU907fo8wBRQyTqkgyHXfU9Ncr9MYiNhxkpT0CxgwC0AlYrpbAMq/NTmaWiOlDM8tp4njE4h8G5iQMlcqH7dPhxFULMkrDcrO2ExXBUGfGRs6mvWOCdcH59IGRj7fBb0YGA==", + "2": null, + "254": 2 + } + ], + "0/62/1": [ + { + "1": "BD2uRGL3xKajROZ9KVGqW2ds+lbSVbtjvX0aHuaAN1QAzpniyczSzQpEu9DmkCbVQrY7jXSETUv4MMWg/DYczrw=", + "2": 4939, + "3": 2, + "4": 1, + "5": "Hjem", + "254": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQAkAgE3AycUDbJd2YHO7k4mFSbP/UsYJgSkmJwwJAUANwYnFA2yXdmBzu5OJhUmz/1LGCQHASQIATAJQQTl1FmKi3U1rKyQ5A9OlbeNxa5ZoUUfjBDNcrk9lJKkfqM9Q/LIJkdeRY4LSLJXI6Rjj04+25lnWy6SWmjhXXf0Nwo1ASkBGCQCYDAEFMhYWae/iYZmAZjH1Plowy3ae0G2MAUUyFhZp7+JhmYBmMfU+WjDLdp7QbYYMAtAfWipMkcKWZoLPV99btttVsbzyeHGYJDWTMoGJxVJaRneOtGoz7vzPuC8w9IWf+CJBuqZRtPwMtNkWh0SlgAGphg=", + "FTABAQAkAgE3AyQUABgmBBIJVDAmBZI/AUU3BiQUABgkBwEkCAEwCUEEPa5EYvfEpqNE5n0pUapbZ2z6VtJVu2O9fRoe5oA3VADOmeLJzNLNCkS70OaQJtVCtjuNdIRNS/gwxaD8NhzOvDcKNQEpARgkAmAwBBQyTqkgyHXfU9Ncr9MYiNhxkpT0CzAFFDJOqSDIdd9T01yv0xiI2HGSlPQLGDALQE7gI3I/bjNb6y4aAHKYTyZvNI0ZhYr+MN3Al8qrhRpLgd++Jg6FUZHHtkUmZfabZ53jWC0xr6wyTGG/D5junkcY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65528": [1, 3, 5, 8, 14], + "0/60/65533": 1, + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/60/65529": [0, 2], + "0/60/65528": [], + "0/53/65532": 15, + "0/53/6": 0, + "0/53/14": 1, + "0/53/15": 1, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 1, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 149, + "0/53/23": 132, + "0/53/24": 17, + "0/53/25": 132, + "0/53/26": 132, + "0/53/27": 17, + "0/53/28": 150, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 44, + "0/53/34": 1, + "0/53/35": 0, + "0/53/36": 2, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 121, + "0/53/40": 118, + "0/53/41": 3, + "0/53/42": 116, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 5, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 65536, + "0/53/57": 0, + "0/53/58": 0, + "0/53/65533": 3, + "0/53/0": 15, + "0/53/1": 4, + "0/53/2": "ha-thread-1234", + "0/53/3": 32971, + "0/53/4": 99798922824493142, + "0/53/5": "QP0sY46pEwFN", + "0/53/7": [], + "0/53/8": [ + { + "0": 0, + "1": 12288, + "2": 12, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 172, + "8": true, + "9": false + } + ], + "0/53/9": 1579515898, + "0/53/10": 65, + "0/53/11": 101, + "0/53/12": 106, + "0/53/13": 12, + "0/53/59": { "0": 672, "1": 143 }, + "0/53/60": "AB//4A==", + "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/63": 8278950144563218229, + "0/53/64": 12289, + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 63, 64, 65532, 65533, 65528, 65529, 65531 + ], + "0/53/65529": [0], + "0/53/65528": [], + "0/52/65532": 1, + "0/52/0": [ + { "0": 10, "1": "Bluetoot", "3": 1192 }, + { "0": 9, "1": "Bluetoot", "3": 608 }, + { "0": 8, "1": "Bluetoot", "3": 376 }, + { "0": 7, "1": "shell", "3": 1372 }, + { "0": 6, "1": "UART", "3": 528 }, + { "0": 5, "1": "OT Seria", "3": 3664 }, + { "0": 4, "1": "Closure", "3": 2964 }, + { "0": 3, "1": "Tmr Svc", "3": 1076 }, + { "0": 2, "1": "IDLE", "3": 1160 }, + { "0": 1, "1": "OT Stack", "3": 3016 }, + { "0": 0, "1": "CHIP", "3": 2896 } + ], + "0/52/1": 364512, + "0/52/2": 26904, + "0/52/3": 36520, + "0/52/65533": 1, + "0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/52/65529": [0], + "0/52/65528": [], + "0/51/3": 0, + "0/51/4": 6, + "0/51/10": { "0": 0, "1": 0, "2": 0, "3": 46, "4": 47 }, + "0/51/65533": 3, + "0/51/65532": 0, + "0/51/0": [ + { + "0": "ha-thread-1234", + "1": true, + "2": null, + "3": null, + "4": "cuS9Su/cBzU=", + "5": [], + "6": [ + "/TYvgjAZAAFvi/ppkIrBjg==", + "/SxjjqkTAU0AAAD//gAwAQ==", + "/SxjjqkTAU1JctxNckcLeA==", + "/oAAAAAAAABw5L1K79wHNQ==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 173, + "0/51/8": true, + "0/51/65531": [0, 1, 8, 3, 4, 2, 10, 65532, 65533, 65528, 65529, 65531], + "0/51/65529": [0, 1], + "0/51/65528": [2], + "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, 65532, 65533, 65528, 65529, 65531], + "0/48/65529": [0, 2, 4], + "0/48/65528": [1, 3, 5], + "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, 65532, 65533, 65528, 65529, 65531], + "0/42/65529": [0], + "0/42/65528": [], + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "EE3DA648DF937129", + "0/40/21": 17170432, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65533": 6, + "0/40/0": 19, + "0/40/1": "Mock", + "0/40/2": 65521, + "0/40/3": "Mock Closure Covering Lift", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/19": { "0": 3, "1": 3, "2": 4, "3": 4, "4": 45, "5": 45 }, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 24, 11, 12, 13, 14, 15, 16, + 18, 65532, 65533, 65528, 65529, 65531 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/31/65533": 3, + "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, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/31/65529": [], + "0/31/65528": [], + "0/29/65533": 3, + "0/29/65532": 0, + "0/29/0": [{ "0": 22, "1": 4 }], + "0/29/1": [70, 65, 63, 62, 60, 53, 52, 51, 48, 42, 40, 31, 29, 49], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/29/65529": [], + "0/29/65528": [], + "0/49/65532": 2, + "0/49/2": 10, + "0/49/3": 20, + "0/49/9": 10, + "0/49/10": 5, + "0/49/65533": 2, + "0/49/0": 1, + "0/49/1": [{ "0": "AWKOl2/rqFY=", "1": true }], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "AWKOl2/rqFY=", + "0/49/7": null, + "0/49/65531": [ + 0, 1, 4, 5, 6, 7, 2, 3, 9, 10, 65532, 65533, 65528, 65529, 65531 + ], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65528": [1, 5, 7], + "1/3/65533": 6, + "1/3/0": 0, + "1/3/1": 5, + "1/3/65532": 0, + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/3/65529": [0, 64], + "1/3/65528": [], + "1/260/65532": 9, + "1/260/0": null, + "1/260/65533": 1, + "1/260/1": 0, + "1/260/2": [], + "1/260/3": { "0": 2, "2": 0, "3": true }, + "1/260/4": { "0": null, "2": 0 }, + "1/260/65531": [1, 2, 3, 4, 0, 65532, 65533, 65528, 65529, 65531], + "1/260/65529": [0, 1], + "1/260/65528": [], + "1/29/65532": 1, + "1/29/4": [ + { "0": null, "1": 68, "2": 0, "3": "Closure.Covering" }, + { "0": null, "1": 70, "2": 0, "3": "Covering.Blind" } + ], + "1/29/65533": 3, + "1/29/0": [{ "0": 560, "1": 1 }], + "1/29/1": [3, 260, 29], + "1/29/2": [], + "1/29/3": [2], + "1/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "1/29/65529": [], + "1/29/65528": [], + "2/261/65532": 33, + "2/261/2": 100, + "2/261/3": 100, + "2/261/7": 0, + "2/261/65533": 1, + "2/261/0": { "0": 5000 }, + "2/261/1": { "0": 5000 }, + "2/261/65531": [0, 1, 2, 3, 7, 65532, 65533, 65528, 65529, 65531], + "2/261/65529": [0, 1], + "2/261/65528": [], + "2/29/65532": 1, + "2/29/4": [{ "0": null, "1": 69, "2": 0, "3": "ClosurePanel.Lift" }], + "2/29/65533": 3, + "2/29/0": [{ "0": 561, "1": 1 }], + "2/29/1": [261, 29], + "2/29/2": [], + "2/29/3": [], + "2/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "2/29/65529": [], + "2/29/65528": [] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/mock_closure_roof_window.json b/tests/components/matter/fixtures/nodes/mock_closure_roof_window.json new file mode 100644 index 00000000000000..fb49922517f321 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mock_closure_roof_window.json @@ -0,0 +1,480 @@ +{ + "node_id": 71, + "date_commissioned": "2026-09-10T13:04:37.756000", + "last_interview": "2026-09-10T13:04:47.351000", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/70/65533": 3, + "0/70/65532": 0, + "0/70/0": 300, + "0/70/1": 300, + "0/70/2": 5000, + "0/70/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/70/65529": [], + "0/70/65528": [], + "0/65/0": [], + "0/65/65533": 1, + "0/65/65532": 0, + "0/65/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/65/65529": [], + "0/65/65528": [], + "0/63/65533": 3, + "0/63/65532": 0, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 4, + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [5, 2], + "0/62/65533": 2, + "0/62/0": [ + { + "1": "FTABAQIkAgE3AyQUABgmBMMXVDAmBUNOAUU3BiQVAiQRARgkBwEkCAEwCUEEBvUu2GoclkSBNg3FSKorIU5cWzMiLpRyMzOUtZYXgDoXXQu+3tJp9ZllqjAJz9DZp+LPrbGn9nj3dWv3rkVGZjcKNQEoARgkAgE2AwQCBAEYMAQUGVh/XVvHe/Mo2TUl5uFQU907fo8wBRQyTqkgyHXfU9Ncr9MYiNhxkpT0CxgwC0AlYrpbAMq/NTmaWiOlDM8tp4njE4h8G5iQMlcqH7dPhxFULMkrDcrO2ExXBUGfGRs6mvWOCdcH59IGRj7fBb0YGA==", + "2": null, + "254": 2 + } + ], + "0/62/1": [ + { + "1": "BD2uRGL3xKajROZ9KVGqW2ds+lbSVbtjvX0aHuaAN1QAzpniyczSzQpEu9DmkCbVQrY7jXSETUv4MMWg/DYczrw=", + "2": 4939, + "3": 2, + "4": 1, + "5": "Hjem", + "254": 2 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQAkAgE3AycUDbJd2YHO7k4mFSbP/UsYJgSkmJwwJAUANwYnFA2yXdmBzu5OJhUmz/1LGCQHASQIATAJQQTl1FmKi3U1rKyQ5A9OlbeNxa5ZoUUfjBDNcrk9lJKkfqM9Q/LIJkdeRY4LSLJXI6Rjj04+25lnWy6SWmjhXXf0Nwo1ASkBGCQCYDAEFMhYWae/iYZmAZjH1Plowy3ae0G2MAUUyFhZp7+JhmYBmMfU+WjDLdp7QbYYMAtAfWipMkcKWZoLPV99btttVsbzyeHGYJDWTMoGJxVJaRneOtGoz7vzPuC8w9IWf+CJBuqZRtPwMtNkWh0SlgAGphg=", + "FTABAQAkAgE3AyQUABgmBBIJVDAmBZI/AUU3BiQUABgkBwEkCAEwCUEEPa5EYvfEpqNE5n0pUapbZ2z6VtJVu2O9fRoe5oA3VADOmeLJzNLNCkS70OaQJtVCtjuNdIRNS/gwxaD8NhzOvDcKNQEpARgkAmAwBBQyTqkgyHXfU9Ncr9MYiNhxkpT0CzAFFDJOqSDIdd9T01yv0xiI2HGSlPQLGDALQE7gI3I/bjNb6y4aAHKYTyZvNI0ZhYr+MN3Al8qrhRpLgd++Jg6FUZHHtkUmZfabZ53jWC0xr6wyTGG/D5junkcY" + ], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65528": [1, 3, 5, 8, 14], + "0/60/65533": 1, + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/60/65529": [0, 2], + "0/60/65528": [], + "0/53/65532": 15, + "0/53/6": 0, + "0/53/14": 1, + "0/53/15": 1, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 1, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 149, + "0/53/23": 132, + "0/53/24": 17, + "0/53/25": 132, + "0/53/26": 132, + "0/53/27": 17, + "0/53/28": 150, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 44, + "0/53/34": 1, + "0/53/35": 0, + "0/53/36": 2, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 121, + "0/53/40": 118, + "0/53/41": 3, + "0/53/42": 116, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 5, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 65536, + "0/53/57": 0, + "0/53/58": 0, + "0/53/65533": 3, + "0/53/0": 15, + "0/53/1": 4, + "0/53/2": "ha-thread-1234", + "0/53/3": 32971, + "0/53/4": 99798922824493142, + "0/53/5": "QP0sY46pEwFN", + "0/53/7": [], + "0/53/8": [ + { + "0": 0, + "1": 12288, + "2": 12, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 172, + "8": true, + "9": false + } + ], + "0/53/9": 1579515898, + "0/53/10": 65, + "0/53/11": 101, + "0/53/12": 106, + "0/53/13": 12, + "0/53/59": { + "0": 672, + "1": 143 + }, + "0/53/60": "AB//4A==", + "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/63": 8278950144563218229, + "0/53/64": 12289, + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 63, 64, 65532, 65533, 65528, 65529, 65531 + ], + "0/53/65529": [0], + "0/53/65528": [], + "0/52/65532": 1, + "0/52/0": [ + { + "0": 10, + "1": "Bluetoot", + "3": 1192 + }, + { + "0": 9, + "1": "Bluetoot", + "3": 608 + }, + { + "0": 8, + "1": "Bluetoot", + "3": 376 + }, + { + "0": 7, + "1": "shell", + "3": 1372 + }, + { + "0": 6, + "1": "UART", + "3": 528 + }, + { + "0": 5, + "1": "OT Seria", + "3": 3664 + }, + { + "0": 4, + "1": "Closure", + "3": 2964 + }, + { + "0": 3, + "1": "Tmr Svc", + "3": 1076 + }, + { + "0": 2, + "1": "IDLE", + "3": 1160 + }, + { + "0": 1, + "1": "OT Stack", + "3": 3016 + }, + { + "0": 0, + "1": "CHIP", + "3": 2896 + } + ], + "0/52/1": 364512, + "0/52/2": 26904, + "0/52/3": 36520, + "0/52/65533": 1, + "0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/52/65529": [0], + "0/52/65528": [], + "0/51/3": 0, + "0/51/4": 6, + "0/51/10": { + "0": 0, + "1": 0, + "2": 0, + "3": 46, + "4": 47 + }, + "0/51/65533": 3, + "0/51/65532": 0, + "0/51/0": [ + { + "0": "ha-thread-1234", + "1": true, + "2": null, + "3": null, + "4": "cuS9Su/cBzU=", + "5": [], + "6": [ + "/TYvgjAZAAFvi/ppkIrBjg==", + "/SxjjqkTAU0AAAD//gAwAQ==", + "/SxjjqkTAU1JctxNckcLeA==", + "/oAAAAAAAABw5L1K79wHNQ==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 173, + "0/51/8": true, + "0/51/65531": [0, 1, 8, 3, 4, 2, 10, 65532, 65533, 65528, 65529, 65531], + "0/51/65529": [0, 1], + "0/51/65528": [2], + "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, 65532, 65533, 65528, 65529, 65531], + "0/48/65529": [0, 2, 4], + "0/48/65528": [1, 3, 5], + "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, 65532, 65533, 65528, 65529, 65531], + "0/42/65529": [0], + "0/42/65528": [], + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "EE3DA648DF937128", + "0/40/21": 17170432, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65533": 6, + "0/40/0": 19, + "0/40/1": "Mock", + "0/40/2": 65521, + "0/40/3": "Mock Closure Roof Window", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/19": { + "0": 3, + "1": 3, + "2": 4, + "3": 4, + "4": 45, + "5": 45 + }, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 24, 11, 12, 13, 14, 15, 16, + 18, 65532, 65533, 65528, 65529, 65531 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/31/65533": 3, + "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, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/31/65529": [], + "0/31/65528": [], + "0/29/65533": 3, + "0/29/65532": 0, + "0/29/0": [ + { + "0": 22, + "1": 4 + } + ], + "0/29/1": [70, 65, 63, 62, 60, 53, 52, 51, 48, 42, 40, 31, 29, 49], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/29/65529": [], + "0/29/65528": [], + "0/49/65532": 2, + "0/49/2": 10, + "0/49/3": 20, + "0/49/9": 10, + "0/49/10": 5, + "0/49/65533": 2, + "0/49/0": 1, + "0/49/1": [ + { + "0": "AWKOl2/rqFY=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "AWKOl2/rqFY=", + "0/49/7": null, + "0/49/65531": [ + 0, 1, 4, 5, 6, 7, 2, 3, 9, 10, 65532, 65533, 65528, 65529, 65531 + ], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65528": [1, 5, 7], + "1/3/65533": 6, + "1/3/0": 0, + "1/3/1": 5, + "1/3/65532": 0, + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/3/65529": [0, 64], + "1/3/65528": [], + "1/260/65532": 473, + "1/260/0": null, + "1/260/65533": 1, + "1/260/1": 0, + "1/260/2": [], + "1/260/3": { + "0": 0, + "2": 0, + "3": true + }, + "1/260/4": { + "0": null, + "2": 0 + }, + "1/260/65531": [1, 2, 3, 4, 0, 65532, 65533, 65528, 65529, 65531], + "1/260/65529": [0, 1, 2], + "1/260/65528": [], + "1/29/65532": 1, + "1/29/4": [ + { + "0": null, + "1": 68, + "2": 1, + "3": "Closure.Window" + }, + { + "0": null, + "1": 71, + "2": 0, + "3": "Window.Roof" + } + ], + "1/29/65533": 3, + "1/29/0": [ + { + "0": 560, + "1": 1 + } + ], + "1/29/1": [3, 260, 29], + "1/29/2": [], + "1/29/3": [2], + "1/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "1/29/65529": [], + "1/29/65528": [], + "2/261/65532": 89, + "2/261/2": 1, + "2/261/3": 1, + "2/261/6": { + "0": 0, + "1": 10000 + }, + "2/261/8": 5, + "2/261/65533": 1, + "2/261/0": { + "0": 10000, + "2": 0 + }, + "2/261/1": { + "0": null, + "2": 0 + }, + "2/261/65531": [0, 1, 2, 3, 6, 8, 9, 65532, 65533, 65528, 65529, 65531], + "2/261/65529": [0, 1], + "2/261/65528": [], + "2/29/65532": 1, + "2/29/4": [ + { + "0": null, + "1": 69, + "2": 3, + "3": "ClosurePanel.Rotate" + } + ], + "2/29/65533": 3, + "2/29/0": [ + { + "0": 561, + "1": 1 + } + ], + "2/29/1": [261, 29], + "2/29/2": [], + "2/29/3": [], + "2/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "2/29/65529": [], + "2/29/65528": [] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index f5a5f59a05fd32..2e2f74c6d852c4 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2748,6 +2748,108 @@ 'state': 'unknown', }) # --- +# name: test_buttons[mock_closure_covering_lift][button.mock_closure_covering_lift_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.mock_closure_covering_lift_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-0000000000000048-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[mock_closure_covering_lift][button.mock_closure_covering_lift_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'identify', + : 'Mock Closure Covering Lift Identify', + }), + 'context': , + 'entity_id': 'button.mock_closure_covering_lift_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[mock_closure_roof_window][button.mock_closure_roof_window_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.mock_closure_roof_window_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-0000000000000047-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[mock_closure_roof_window][button.mock_closure_roof_window_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'identify', + : 'Mock Closure Roof Window Identify', + }), + 'context': , + 'entity_id': 'button.mock_closure_roof_window_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[mock_dimmable_plugin_unit][button.dimmable_plugin_unit_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 87c0d3497750d7..4722f49ed2e176 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -18601,6 +18601,698 @@ 'state': '2025-01-01T13:59:48+00:00', }) # --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_boot_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_closure_covering_lift_boot_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boot reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boot reason', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'boot_reason', + 'unique_id': '00000000000004D2-0000000000000048-MatterNodeDevice-0-GeneralDiagnosticsBootReason-51-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_boot_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Closure Covering Lift Boot reason', + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_closure_covering_lift_boot_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'software_reset', + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_reboot_count-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_closure_covering_lift_reboot_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reboot count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reboot count', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot_count', + 'unique_id': '00000000000004D2-0000000000000048-MatterNodeDevice-0-GeneralDiagnosticsRebootCount-51-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_reboot_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Closure Covering Lift Reboot count', + : , + }), + 'context': , + 'entity_id': 'sensor.mock_closure_covering_lift_reboot_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_thread_channel-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.mock_closure_covering_lift_thread_channel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread channel', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thread channel', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_channel', + 'unique_id': '00000000000004D2-0000000000000048-MatterNodeDevice-0-ThreadDiagnosticsChannel-53-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_thread_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Closure Covering Lift Thread channel', + }), + 'context': , + 'entity_id': 'sensor.mock_closure_covering_lift_thread_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_thread_network_name-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.mock_closure_covering_lift_thread_network_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread network name', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thread network name', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_network_name', + 'unique_id': '00000000000004D2-0000000000000048-MatterNodeDevice-0-ThreadDiagnosticsNetworkName-53-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_thread_network_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Closure Covering Lift Thread network name', + }), + 'context': , + 'entity_id': 'sensor.mock_closure_covering_lift_thread_network_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ha-thread-1234', + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_thread_routing_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'unspecified', + 'unassigned', + 'sleepy_end_device', + 'end_device', + 'reed', + 'router', + 'leader', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_closure_covering_lift_thread_routing_role', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread routing role', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thread routing role', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_routing_role', + 'unique_id': '00000000000004D2-0000000000000048-MatterNodeDevice-0-ThreadDiagnosticsRoutingRole-53-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_thread_routing_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Closure Covering Lift Thread routing role', + : list([ + 'unspecified', + 'unassigned', + 'sleepy_end_device', + 'end_device', + 'reed', + 'router', + 'leader', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_closure_covering_lift_thread_routing_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'reed', + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_uptime-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.mock_closure_covering_lift_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': '00000000000004D2-0000000000000048-MatterNodeDevice-0-GeneralDiagnosticsUpTime-51-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_covering_lift][sensor.mock_closure_covering_lift_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Mock Closure Covering Lift Uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_closure_covering_lift_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T13:57:07+00:00', + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_boot_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_closure_roof_window_boot_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boot reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boot reason', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'boot_reason', + 'unique_id': '00000000000004D2-0000000000000047-MatterNodeDevice-0-GeneralDiagnosticsBootReason-51-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_boot_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Closure Roof Window Boot reason', + : list([ + 'unspecified', + 'power_on_reboot', + 'brown_out_reset', + 'software_watchdog_reset', + 'hardware_watchdog_reset', + 'software_update_completed', + 'software_reset', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_closure_roof_window_boot_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'software_reset', + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_reboot_count-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_closure_roof_window_reboot_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reboot count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reboot count', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot_count', + 'unique_id': '00000000000004D2-0000000000000047-MatterNodeDevice-0-GeneralDiagnosticsRebootCount-51-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_reboot_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Closure Roof Window Reboot count', + : , + }), + 'context': , + 'entity_id': 'sensor.mock_closure_roof_window_reboot_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_thread_channel-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.mock_closure_roof_window_thread_channel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread channel', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thread channel', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_channel', + 'unique_id': '00000000000004D2-0000000000000047-MatterNodeDevice-0-ThreadDiagnosticsChannel-53-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_thread_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Closure Roof Window Thread channel', + }), + 'context': , + 'entity_id': 'sensor.mock_closure_roof_window_thread_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_thread_network_name-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.mock_closure_roof_window_thread_network_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread network name', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thread network name', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_network_name', + 'unique_id': '00000000000004D2-0000000000000047-MatterNodeDevice-0-ThreadDiagnosticsNetworkName-53-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_thread_network_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Closure Roof Window Thread network name', + }), + 'context': , + 'entity_id': 'sensor.mock_closure_roof_window_thread_network_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ha-thread-1234', + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_thread_routing_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'unspecified', + 'unassigned', + 'sleepy_end_device', + 'end_device', + 'reed', + 'router', + 'leader', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_closure_roof_window_thread_routing_role', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thread routing role', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thread routing role', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thread_routing_role', + 'unique_id': '00000000000004D2-0000000000000047-MatterNodeDevice-0-ThreadDiagnosticsRoutingRole-53-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_thread_routing_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Closure Roof Window Thread routing role', + : list([ + 'unspecified', + 'unassigned', + 'sleepy_end_device', + 'end_device', + 'reed', + 'router', + 'leader', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_closure_roof_window_thread_routing_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'reed', + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_uptime-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.mock_closure_roof_window_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': '00000000000004D2-0000000000000047-MatterNodeDevice-0-GeneralDiagnosticsUpTime-51-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_closure_roof_window][sensor.mock_closure_roof_window_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Mock Closure Roof Window Uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_closure_roof_window_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T13:57:07+00:00', + }) +# --- # name: test_sensors[mock_cooktop][sensor.mock_cooktop_boot_reason-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 8e43a077a7e239..c89d88af257ec3 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -182,6 +182,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: "zoom": {"min": 0, "max": 100}, "focus": {"min": 0, "max": 100}, } + host_mock.whiteled_event_on_time_range.return_value = (30, 900) + host_mock.whiteled_event_flash_time_range.return_value = (10, 30) host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} diff --git a/tests/fixtures/core/config/annotations/basic/configuration.yaml b/tests/fixtures/core/config/annotations/basic/configuration.yaml new file mode 100644 index 00000000000000..02c020091188e5 --- /dev/null +++ b/tests/fixtures/core/config/annotations/basic/configuration.yaml @@ -0,0 +1,9 @@ +test_domain: + mars: blah + servers: + - port: 8123 + name: kitchen + - port: 8124 + name: hallway + +included_domain: !include included.yaml diff --git a/tests/fixtures/core/config/annotations/basic/included.yaml b/tests/fixtures/core/config/annotations/basic/included.yaml new file mode 100644 index 00000000000000..82c675d79c6306 --- /dev/null +++ b/tests/fixtures/core/config/annotations/basic/included.yaml @@ -0,0 +1,4 @@ +mars: blah +servers: + - port: 9123 + name: garage diff --git a/tests/fixtures/core/config/annotations/include_dirs/configuration.yaml b/tests/fixtures/core/config/annotations/include_dirs/configuration.yaml new file mode 100644 index 00000000000000..a8f06ae29d6b2f --- /dev/null +++ b/tests/fixtures/core/config/annotations/include_dirs/configuration.yaml @@ -0,0 +1,4 @@ +dir_list: !include_dir_list entries +dir_merge_list: !include_dir_merge_list merge_entries +dir_named: !include_dir_named entries +dir_merge_named: !include_dir_merge_named entries diff --git a/tests/fixtures/core/config/annotations/include_dirs/entries/one.yaml b/tests/fixtures/core/config/annotations/include_dirs/entries/one.yaml new file mode 100644 index 00000000000000..5730cbdaac1b34 --- /dev/null +++ b/tests/fixtures/core/config/annotations/include_dirs/entries/one.yaml @@ -0,0 +1,2 @@ +name: kitchen +port: 8123 diff --git a/tests/fixtures/core/config/annotations/include_dirs/entries/two.yaml b/tests/fixtures/core/config/annotations/include_dirs/entries/two.yaml new file mode 100644 index 00000000000000..b59a4c7ec7267b --- /dev/null +++ b/tests/fixtures/core/config/annotations/include_dirs/entries/two.yaml @@ -0,0 +1,2 @@ +name: hallway +port: 8124 diff --git a/tests/fixtures/core/config/annotations/include_dirs/merge_entries/first.yaml b/tests/fixtures/core/config/annotations/include_dirs/merge_entries/first.yaml new file mode 100644 index 00000000000000..dbe4aad7bc16bf --- /dev/null +++ b/tests/fixtures/core/config/annotations/include_dirs/merge_entries/first.yaml @@ -0,0 +1,2 @@ +- name: garage + port: 8125 diff --git a/tests/fixtures/core/config/component_exceptions/configuration.yaml b/tests/fixtures/core/config/component_exceptions/configuration.yaml new file mode 100644 index 00000000000000..64d329ae864870 --- /dev/null +++ b/tests/fixtures/core/config/component_exceptions/configuration.yaml @@ -0,0 +1,5 @@ +# 'other_domain' keeps the root mapping's line different from the 'test_domain' +# key's line, so an asserted line number says which of the two find_annotation +# could answer with actually answered. +other_domain: {} +test_domain: {} diff --git a/tests/fixtures/core/config/component_exceptions/error_processing.yaml b/tests/fixtures/core/config/component_exceptions/error_processing.yaml new file mode 100644 index 00000000000000..3f0f4e1bc4ba50 --- /dev/null +++ b/tests/fixtures/core/config/component_exceptions/error_processing.yaml @@ -0,0 +1,6 @@ +# 'other_domain' keeps the root mapping's line different from the 'test_domain' +# key's line: an error reported at 'test_domain' gets the key's line, while an +# error reported at a path absent from the config falls back to the root +# container's line. +other_domain: {} +test_domain: [] diff --git a/tests/fixtures/core/config/component_exceptions/multi_platform.yaml b/tests/fixtures/core/config/component_exceptions/multi_platform.yaml new file mode 100644 index 00000000000000..2d47a96a2567c8 --- /dev/null +++ b/tests/fixtures/core/config/component_exceptions/multi_platform.yaml @@ -0,0 +1,3 @@ +test_domain: + - platform: test_platform1 + - platform: test_platform2 diff --git a/tests/fixtures/core/config/component_exceptions/platform.yaml b/tests/fixtures/core/config/component_exceptions/platform.yaml new file mode 100644 index 00000000000000..3141a924733613 --- /dev/null +++ b/tests/fixtures/core/config/component_exceptions/platform.yaml @@ -0,0 +1,2 @@ +test_domain: + platform: test_platform diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 2d32ab2ea7428d..4b5415a41f7c43 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -7,6 +7,7 @@ from functools import partial import logging import os +from pathlib import Path import re from socket import _GLOBAL_DEFAULT_TIMEOUT import threading @@ -29,6 +30,7 @@ ) from homeassistant.helpers.config_validation import TRIGGER_SCHEMA from homeassistant.util import dt as dt_util +from homeassistant.util.yaml import load_yaml_dict def test_boolean() -> None: @@ -1232,6 +1234,76 @@ def test_deprecated_logger_without_config_attributes( assert len(caplog.records) == 0 +@pytest.mark.parametrize( + ("validator", "option_status"), + [ + pytest.param(cv.deprecated, "is deprecated", id="deprecated"), + pytest.param( + partial(cv.removed, raise_if_present=False), + "has been removed", + id="removed", + ), + ], +) +@pytest.mark.parametrize( + "preprocess", + [ + pytest.param(lambda config: config, id="raw_loader_output"), + pytest.param(probatio.Schema(dict), id="pass_through_schema"), + pytest.param( + probatio.Schema({}, extra=probatio.ALLOW_EXTRA), + id="rebuilding_schema", + marks=pytest.mark.xfail( + strict=True, + reason=( + "The mapping engine allocates a fresh container and never copies " + "the __config_file__/__line__ slots, so _deprecated_or_removed " + "takes its 'except AttributeError' branch and logs no location." + ), + ), + ), + pytest.param( + cv.PLATFORM_SCHEMA_BASE, + id="platform_schema_base", + marks=pytest.mark.xfail( + strict=True, + reason=( + "Same rebuild, through the real PLATFORM_SCHEMA_BASE that " + "check_config.py runs before the platform's own PLATFORM_SCHEMA." + ), + ), + ), + ], +) +def test_deprecated_or_removed_location_survives_schema_rebuild( + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + validator: Callable[..., Callable[[dict], dict]], + option_status: str, + preprocess: Callable[[dict], dict], +) -> None: + """Test the location prefix survives a schema the config was passed through. + + The config is built with the real YAML loader so the annotation is the one + production gets, not a hand-set attribute. + """ + # Note: Unlike find_annotation, which reads the location off the mapping key, + # _deprecated_or_removed reads it off the container itself, so a schema that + # rebuilds the container silently drops the "near :" prefix. That is + # what check_config.py does when it hands the already validated p_validated to + # the platform's own PLATFORM_SCHEMA, where the cv.deprecated calls of broadlink, + # canary, mvglive and integration live. + + config_file = tmp_path / "configuration.yaml" + config_file.write_text("platform: test_platform\nmars: blah\n", encoding="utf8") + config = load_yaml_dict(str(config_file)) + + validator("mars", default=False)(preprocess(config)) + + assert len(caplog.records) == 1 + assert f"The 'mars' option near {config_file}:1 {option_status}" in caplog.text + + def test_key_dependency() -> None: """Test key_dependency validator.""" schema = probatio.Schema(cv.key_dependency("beer", "soda")) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index deafe501c74fe7..161bd176063b53 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -3,13 +3,13 @@ 'Unable to import test_domain: bla' # --- # name: test_component_config_error_processing[exception_info_list1-bla-messages1-True-config_validation_err] - 'Invalid config for integration test_domain at configuration.yaml, line 140: bla' + 'Invalid config for integration test_domain at error_processing.yaml, line 6: bla' # --- # name: test_component_config_error_processing[exception_info_list2-bla at 'path'-messages2-False-config_validation_err] - "Invalid config for integration test_domain at configuration.yaml, line 140: bla at 'path'" + "Invalid config for integration test_domain at error_processing.yaml, line 5: bla at 'path'" # --- # name: test_component_config_error_processing[exception_info_list3-bla at 'path'-messages3-False-platform_config_validation_err] - "Invalid config for test_domain from integration test_domain at file configuration.yaml, line 140: bla at 'path'. Check the logs for more information" + "Invalid config for test_domain from integration test_domain at file error_processing.yaml, line 5: bla at 'path'. Check the logs for more information" # --- # name: test_component_config_error_processing[exception_info_list4-bla-messages4-False-platform_component_load_err] 'Platform error: test_domain - bla' diff --git a/tests/test_config.py b/tests/test_config.py index 6c4d2332998d79..27d28405989ca0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,8 +22,7 @@ from homeassistant.helpers import check_config, config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration -from homeassistant.util.yaml import SECRET_YAML -from homeassistant.util.yaml.objects import NodeDictClass +from homeassistant.util.yaml import SECRET_YAML, load_yaml_dict from .common import ( MockModule, @@ -41,6 +40,13 @@ SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) SAFE_MODE_PATH = os.path.join(CONFIG_DIR, config_util.SAFE_MODE_FILENAME) +COMPONENT_EXCEPTIONS_DIR = os.path.join( + os.path.dirname(__file__), "fixtures", "core", "config", "component_exceptions" +) +COMPONENT_EXCEPTIONS_YAML = os.path.join(COMPONENT_EXCEPTIONS_DIR, "configuration.yaml") +ERROR_PROCESSING_CONFIG = load_yaml_dict( + os.path.join(COMPONENT_EXCEPTIONS_DIR, "error_processing.yaml") +) def create_file(path): @@ -349,13 +355,6 @@ async def async_validate_config( ) -class ConfigTestClass(NodeDictClass): - """Test class for config with wrapper.""" - - __line__ = 140 - __config_file__ = "configuration.yaml" - - async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" assert not os.path.isfile(YAML_PATH) @@ -839,20 +838,19 @@ async def test_merge_split_component_definition(hass: HomeAssistant) -> None: async def test_component_config_exceptions( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Test unexpected exceptions validating component config.""" + """Test unexpected exceptions validating component config. - # Create test config with embedded info - test_config = ConfigTestClass({"test_domain": {}}) - test_platform_config = ConfigTestClass( - {"test_domain": {"platform": "test_platform"}} + The fixture puts the root mapping on line 4 and the 'test_domain' key on line 5, + so asserting line 5 pins that find_annotation answered from the key. + """ + + hass.config.config_dir = COMPONENT_EXCEPTIONS_DIR + test_config = load_yaml_dict(COMPONENT_EXCEPTIONS_YAML) + test_platform_config = load_yaml_dict( + os.path.join(COMPONENT_EXCEPTIONS_DIR, "platform.yaml") ) - test_multi_platform_config = ConfigTestClass( - { - "test_domain": [ - {"platform": "test_platform1"}, - {"platform": "test_platform2"}, - ] - }, + test_multi_platform_config = load_yaml_dict( + os.path.join(COMPONENT_EXCEPTIONS_DIR, "multi_platform.yaml") ) test_integration = Mock( @@ -901,8 +899,8 @@ async def test_component_config_exceptions( is None ) assert ( - "Invalid config for 'test_domain' at ../../configuration.yaml, " - "line 140: broken, please check the docs at" in caplog.text + "Invalid config for 'test_domain' at configuration.yaml, " + "line 5: broken, please check the docs at" in caplog.text ) with pytest.raises(HomeAssistantError) as ex: await config_util.async_process_component_and_handle_errors( @@ -911,7 +909,7 @@ async def test_component_config_exceptions( assert ( str(ex.value) == "Invalid config for integration test_domain at configuration.yaml, " - "line 140: broken" + "line 5: broken" ) # component.CONFIG_SCHEMA caplog.clear() @@ -1191,7 +1189,7 @@ async def test_component_config_exceptions( ImportError("bla"), "component_import_err", "test_domain", - ConfigTestClass({"test_domain": []}), + ERROR_PROCESSING_CONFIG, "https://example.com", ) ], @@ -1206,14 +1204,14 @@ async def test_component_config_exceptions( HomeAssistantError("bla"), "config_validation_err", "test_domain", - ConfigTestClass({"test_domain": []}), + ERROR_PROCESSING_CONFIG, "https://example.com", ) ], "bla", [ "Invalid config for 'test_domain' at " - "../../configuration.yaml, line 140: bla, " + "error_processing.yaml, line 6: bla, " "please check the docs at https://example.com", "bla", ], @@ -1226,14 +1224,14 @@ async def test_component_config_exceptions( probatio.Invalid("bla", ["path"]), "config_validation_err", "test_domain", - ConfigTestClass({"test_domain": []}), + ERROR_PROCESSING_CONFIG, "https://example.com", ) ], "bla at 'path'", [ "Invalid config for 'test_domain' at " - "../../configuration.yaml, line 140: bla 'path', " + "error_processing.yaml, line 5: bla 'path', " "got None, please check the docs at https://example.com", "bla", ], @@ -1246,14 +1244,14 @@ async def test_component_config_exceptions( probatio.Invalid("bla", ["path"]), "platform_config_validation_err", "test_domain", - ConfigTestClass({"test_domain": []}), + ERROR_PROCESSING_CONFIG, "https://alt.example.com", ) ], "bla at 'path'", [ "Invalid config for 'test_domain' at " - "../../configuration.yaml, line 140: bla 'path', " + "error_processing.yaml, line 5: bla 'path', " "got None, please check the docs at https://alt.example.com", "bla", ], @@ -1266,7 +1264,7 @@ async def test_component_config_exceptions( ImportError("bla"), "platform_component_load_err", "test_domain", - ConfigTestClass({"test_domain": []}), + ERROR_PROCESSING_CONFIG, "https://example.com", ) ], @@ -1287,8 +1285,15 @@ async def test_component_config_error_processing( show_stack_trace: bool, translation_key: str, ) -> None: - """Test component config error processing.""" + """Test component config error processing. + + The fixture puts the root mapping on line 5 and the 'test_domain' key on line 6. + An expected message naming line 6 therefore pins that find_annotation answered + from the key, and one naming line 5 that it fell back to the container, which is + what happens when the reported path is absent from the config. + """ + hass.config.config_dir = COMPONENT_EXCEPTIONS_DIR test_integration = Mock( domain="test_domain", documentation="https://example.com", @@ -1323,7 +1328,7 @@ async def test_component_config_error_processing( return_value=config_util.IntegrationConfigInfo(None, exception_info_list), ): await config_util.async_process_component_and_handle_errors( - hass, ConfigTestClass({}), test_integration + hass, {}, test_integration ) assert all(message in caplog.text for message in messages) diff --git a/tests/test_config_annotations.py b/tests/test_config_annotations.py new file mode 100644 index 00000000000000..b2b2bed87aae77 --- /dev/null +++ b/tests/test_config_annotations.py @@ -0,0 +1,282 @@ +"""Test that YAML source annotations survive Home Assistant's config pipeline. + +The loader (annotatedyaml), the validation engine (probatio) and the readers in +homeassistant.config are three separate packages, and what breaks in production +is their composition: a location the loader recorded is read back after a schema +has run. No single package's test suite covers that, so it lives here rather +than next to any one helper. +""" + +from collections.abc import Callable, Generator +import os +from typing import Any + +from probatio import ( + Coerce, + CompilePolicy, + Schema, + get_compile_policy, + set_compile_policy, +) +import pytest + +from homeassistant import config as config_util +from homeassistant.config import _get_annotation, find_annotation +from homeassistant.core import HomeAssistant +from homeassistant.util.yaml.objects import NodeDictClass + +from .common import get_fixture_path + +CONFIG_DIR = str(get_fixture_path("core/config/annotations/basic")) +CONFIGURATION_YAML = os.path.join(CONFIG_DIR, "configuration.yaml") +INCLUDED_YAML = os.path.join(CONFIG_DIR, "included.yaml") + +CONTAINER_XFAIL = pytest.mark.xfail( + strict=True, + reason=( + "probatio's mapping and sequence engines rebuild the container as a fresh " + "instance of the input's class, so the __config_file__/__line__ slots " + "annotatedyaml set on the original are never copied." + ), +) + + +@pytest.fixture( + params=[CompilePolicy.OFF, CompilePolicy.ON], + ids=["interpreted", "generated"], +) +def probatio_compile_policy(request: pytest.FixtureRequest) -> Generator[None]: + """Run the test against the interpreted and the generated validator. + + The generated validator bails back to the interpreted engine for anything + that is not exactly a dict or a list, so a node class takes the same + interpreted rebuild under either policy today. Running both is what guards + that bail-out: if generated code ever handled dict subclasses inline without + copying the slots, only the generated case would move. + + OFF and ON are pinned rather than the AUTO default because AUTO switches from + one to the other after a call count probatio keeps internal and does not + export. + """ + original = get_compile_policy() + set_compile_policy(request.param) + yield + set_compile_policy(original) + + +@pytest.fixture +async def annotated_config(hass: HomeAssistant) -> dict: + """Load the annotations fixture directory the way Home Assistant loads it.""" + hass.config.config_dir = CONFIG_DIR + return await config_util.async_hass_config_yaml(hass) + + +def _key(mapping: dict, name: str) -> Any: + """Return the key object of mapping equal to name.""" + return next(key for key in mapping if key == name) + + +def _schema() -> Schema: + """Return a schema for the fixture config, built fresh so the policy applies.""" + domain = {"mars": str, "servers": [{"port": int, "name": str}]} + return Schema({"test_domain": domain, "included_domain": domain}) + + +async def test_loader_annotates_every_container(annotated_config: dict) -> None: + """Test the loader records a distinct file and line at each nesting depth. + + Precondition for the rest of the module: if this fails the fixture config is + broken rather than the carry. + """ + assert _get_annotation(annotated_config) == (CONFIGURATION_YAML, 1) + assert _get_annotation(annotated_config["test_domain"]) == (CONFIGURATION_YAML, 2) + assert _get_annotation(annotated_config["test_domain"]["servers"]) == ( + CONFIGURATION_YAML, + 4, + ) + assert _get_annotation(annotated_config["test_domain"]["servers"][1]) == ( + CONFIGURATION_YAML, + 6, + ) + assert _get_annotation(annotated_config["included_domain"]["servers"]) == ( + INCLUDED_YAML, + 3, + ) + + +@pytest.mark.usefixtures("probatio_compile_policy") +async def test_validation_keeps_the_node_class(annotated_config: dict) -> None: + """Test a rebuilt container is still a node class; only the location is dropped. + + Pinning the class separately keeps the two halves of the bug apart: an + annotation assertion that fails because the class changed would be a + different defect than the one the xfails below describe. + """ + validated = _schema()(annotated_config) + + assert type(validated) is NodeDictClass + assert type(validated["test_domain"]) is NodeDictClass + assert type(validated["test_domain"]["servers"][1]) is NodeDictClass + assert type(validated["included_domain"]) is NodeDictClass + + +@pytest.mark.parametrize( + ("select", "expected"), + [ + pytest.param( + lambda config: _key(config, "test_domain"), + (CONFIGURATION_YAML, 1), + id="key_at_root", + ), + pytest.param( + lambda config: _key(config["test_domain"], "servers"), + (CONFIGURATION_YAML, 3), + id="key_in_nested_dict", + ), + pytest.param( + lambda config: _key(config["test_domain"]["servers"][1], "name"), + (CONFIGURATION_YAML, 7), + id="key_in_list_element", + ), + pytest.param( + lambda config: _key(config["included_domain"], "servers"), + (INCLUDED_YAML, 2), + id="key_in_included_file", + ), + pytest.param( + lambda config: config["test_domain"]["mars"], + (CONFIGURATION_YAML, 2), + id="scalar_value", + ), + pytest.param( + lambda config: config, + (CONFIGURATION_YAML, 1), + marks=CONTAINER_XFAIL, + id="root_dict", + ), + pytest.param( + lambda config: config["test_domain"], + (CONFIGURATION_YAML, 2), + marks=CONTAINER_XFAIL, + id="dict_in_dict", + ), + pytest.param( + lambda config: config["test_domain"]["servers"], + (CONFIGURATION_YAML, 4), + marks=CONTAINER_XFAIL, + id="list_in_dict", + ), + pytest.param( + lambda config: config["test_domain"]["servers"][1], + (CONFIGURATION_YAML, 6), + marks=CONTAINER_XFAIL, + id="dict_in_list", + ), + pytest.param( + lambda config: config["included_domain"]["servers"], + (INCLUDED_YAML, 3), + marks=CONTAINER_XFAIL, + id="list_in_included_file", + ), + ], +) +@pytest.mark.usefixtures("probatio_compile_policy") +async def test_annotation_survives_validation( + annotated_config: dict, + select: Callable[[dict], Any], + expected: tuple[str, int], +) -> None: + """Test a schema rebuild keeps the file and line the loader recorded. + + The key and scalar cases pass today because probatio stores the original key + and value objects in the rebuilt mapping, which is why config error messages + still carry locations at all; the container cases are the bug. The two + included-file cases pin the file as well as the line, so an annotation that + survives pointing at the including file would still fail. + """ + validated = _schema()(annotated_config) + + assert _get_annotation(select(validated)) == expected + + +async def test_type_check_schema_returns_the_same_object( + annotated_config: dict, +) -> None: + """Test Schema(dict) checks the type and rebuilds nothing, so nothing is lost. + + This separates "the rebuild dropped the location" from "going through a + schema dropped the location". + """ + validated = Schema(dict)(annotated_config) + + assert validated is annotated_config + assert _get_annotation(validated) == (CONFIGURATION_YAML, 1) + + +async def test_coerce_loses_the_annotation(annotated_config: dict) -> None: + """Test Coerce builds a plain dict, which has no slot to hold a location. + + Asserted as it behaves rather than xfailed: this loss is inherent to the + target type, so an upstream change that started preserving it is a surprise + worth being told about. + """ + validated = Schema(Coerce(dict))(annotated_config) + + assert type(validated) is dict + assert _get_annotation(validated) is None + + +async def test_rebuilding_validator_loses_the_annotation( + annotated_config: dict, +) -> None: + """Test a validator rebuilding via type(value)(...) keeps the class, not the location. + + Asserted as it behaves rather than xfailed: preserving the class is not + preserving the annotation, and a validator that rebuilds has to carry the + location itself. + """ + + def rebuild(value: NodeDictClass) -> NodeDictClass: + return type(value)((key, item) for key, item in value.items()) + + validated = Schema(rebuild)(annotated_config) + + assert type(validated) is NodeDictClass + assert _get_annotation(validated) is None + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + pytest.param(["test_domain", "mars"], (CONFIGURATION_YAML, 2), id="own_key"), + pytest.param( + ["test_domain", "servers"], (CONFIGURATION_YAML, 3), id="parent_key" + ), + pytest.param( + ["included_domain", "servers"], + (INCLUDED_YAML, 2), + id="key_in_included_file", + ), + pytest.param( + [], (CONFIGURATION_YAML, 1), marks=CONTAINER_XFAIL, id="root_has_no_key" + ), + pytest.param( + ["test_domain", "servers", 0], + (CONFIGURATION_YAML, 4), + marks=CONTAINER_XFAIL, + id="list_element_has_no_key", + ), + ], +) +@pytest.mark.usefixtures("probatio_compile_policy") +async def test_find_annotation_after_validation( + annotated_config: dict, path: list[str | int], expected: tuple[str, int] +) -> None: + """Test find_annotation reads the key first, so it only fails where no key is reachable. + + The list element case reports the line of the "servers:" key today instead of + the line of the element itself. + """ + validated = _schema()(annotated_config) + + assert find_annotation(validated, path) == expected diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 389c0ef962f990..1872393f801a8e 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -12,13 +12,21 @@ import pytest import yaml as pyyaml -from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file +from homeassistant.config import ( + YAML_CONFIG_FILE, + _get_annotation, + load_yaml_config_file, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util import yaml as yaml_util from homeassistant.util.yaml import loader as yaml_loader +from homeassistant.util.yaml.objects import NodeDictClass, NodeListClass + +from tests.common import extract_stack_to_frame, get_fixture_path -from tests.common import extract_stack_to_frame +INCLUDE_DIRS_FIXTURE = str(get_fixture_path("core/config/annotations/include_dirs")) +INCLUDE_DIRS_CONFIG = os.path.join(INCLUDE_DIRS_FIXTURE, "configuration.yaml") @pytest.fixture(params=["enable_c_loader", "disable_c_loader"]) @@ -374,6 +382,55 @@ def test_include_dir_merge_named_recursive(mock_walk: Mock) -> None: } +@pytest.mark.parametrize( + ("key", "expected_type", "expected_line"), + [ + pytest.param( + "dir_list", + NodeListClass, + 1, + marks=pytest.mark.xfail( + strict=True, + reason=( + "annotatedyaml _include_dir_list_yaml returns a bare list " + "comprehension and never calls _add_reference, unlike the " + "three sibling include_dir tags" + ), + ), + id="include_dir_list", + ), + pytest.param("dir_merge_list", NodeListClass, 2, id="include_dir_merge_list"), + pytest.param("dir_named", NodeDictClass, 3, id="include_dir_named"), + pytest.param("dir_merge_named", NodeDictClass, 4, id="include_dir_merge_named"), + ], +) +@pytest.mark.usefixtures("try_both_loaders") +def test_include_dir_annotates_the_container( + key: str, expected_type: type, expected_line: int +) -> None: + """Test each include_dir tag annotates the container it returns.""" + doc = yaml_loader.load_yaml_dict(INCLUDE_DIRS_CONFIG) + + assert type(doc[key]) is expected_type + assert _get_annotation(doc[key]) == (INCLUDE_DIRS_CONFIG, expected_line) + + +@pytest.mark.usefixtures("try_both_loaders") +def test_include_dir_list_annotates_its_elements() -> None: + """Test each element of an include_dir_list is annotated with its own file. + + The elements are what masks the unannotated list: find_annotation recurses + into them, so an error reported at or below an element still gets a location + even though the list itself carries none. + """ + doc = yaml_loader.load_yaml_dict(INCLUDE_DIRS_CONFIG) + + assert [_get_annotation(element) for element in doc["dir_list"]] == [ + (os.path.join(INCLUDE_DIRS_FIXTURE, "entries", "one.yaml"), 1), + (os.path.join(INCLUDE_DIRS_FIXTURE, "entries", "two.yaml"), 1), + ] + + @patch("annotatedyaml.loader.open", create=True) @pytest.mark.usefixtures("try_both_loaders") def test_load_yaml_encoding_error(mock_open: Mock) -> None: