diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e8b5781f18bc3..1bb6694b28602 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1375,7 +1375,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: fail_ci_if_error: true flags: full-suite @@ -1528,7 +1528,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] @@ -1556,7 +1556,7 @@ jobs: with: pattern: test-results-* - name: Upload test results to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: report_type: test_results fail_ci_if_error: true diff --git a/homeassistant/components/openrgb/manifest.json b/homeassistant/components/openrgb/manifest.json index bb2677285904c..795b1319f0bb9 100644 --- a/homeassistant/components/openrgb/manifest.json +++ b/homeassistant/components/openrgb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["openrgb-python==0.3.6"] + "requirements": ["openrgb-python==0.3.7"] } diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index d7f84b967d991..17d839bc34414 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -16,6 +16,7 @@ Portainer, PortainerAuthenticationError, PortainerConnectionError, + PortainerError, PortainerEventListener, PortainerEventListenerResult, PortainerTimeoutError, @@ -217,6 +218,9 @@ def __init__( self._image_cache: dict[ tuple[int, str], tuple[float, LocalImageInformation] ] = {} + self._image_status_cache: dict[ + tuple[int, str], tuple[float, PortainerImageUpdateStatus] + ] = {} self._event_listeners: dict[int, PortainerEventListener] = {} self._event_listeners_enabled = False self._container_ids_by_endpoint: dict[int, dict[str, str]] = {} @@ -341,19 +345,7 @@ async def update_data(self) -> dict[int, PortainerCoordinatorData]: container_inspect = container_inspects[container_name] local_image = local_images[container_name] - image_status = ( - ( - result.status - if ( - result := self.watcher.results.get( - (endpoint.id, container.id) - ) - ) - else None - ) - if self.watcher - else None - ) + image_status = await self._get_image_status(endpoint.id, container) # Check if container belongs to a stack via docker compose label stack_name: str | None = ( @@ -618,6 +610,47 @@ async def _get_local_image( ) return local_image + async def _get_image_status( + self, endpoint_id: int, container: DockerContainer + ) -> PortainerImageUpdateStatus | None: + """Return the image update status, checking containers the watcher has not seen.""" + if self.watcher is None: + return None + + if result := self.watcher.results.get((endpoint_id, container.id)): + return result.status + + # A recreated container gets a new ID, which the watcher only picks up on + # its next run. Check its image now instead of reporting unknown until then. + if ( + self.watcher.last_check is None + or container.state != DockerContainerState.RUNNING + or not container.image + ): + return None + + cache_key = (endpoint_id, container.image) + if cached := self._image_status_cache.get(cache_key): + cached_at, image_status = cached + if cached_at >= self.watcher.last_check: + return image_status + + try: + image_status = await self.portainer.container_image_status( + endpoint_id, container.image + ) + except PortainerError as err: + _LOGGER.debug( + "Failed to check image %s on endpoint %d: %s", + container.image, + endpoint_id, + err, + ) + return None + + self._image_status_cache[cache_key] = (time.time(), image_status) + return image_status + def _async_sync_event_listeners( self, mapped_endpoints: dict[int, PortainerCoordinatorData] ) -> None: diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index c2fcbe6682ab9..6797b755e3560 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.52", + "SQLAlchemy==2.0.53", "fnv-hash-fast==2.0.3", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sftp_storage/manifest.json b/homeassistant/components/sftp_storage/manifest.json index 2c98c3a723866..92d6eeb875260 100644 --- a/homeassistant/components/sftp_storage/manifest.json +++ b/homeassistant/components/sftp_storage/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["asyncssh==2.23.1"] + "requirements": ["asyncssh==2.24.0"] } diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 57e2d536eb0fe..9662efb4ca577 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -6,7 +6,6 @@ from asyncsleepiq import ( AsyncSleepIQ, SleepIQAPIException, - SleepIQBed, SleepIQConnectionException, SleepIQLoginException, SleepIQTimeoutException, @@ -127,57 +126,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def _foundation_feature_count(bed: SleepIQBed) -> int: - """Count foundation features on a bed.""" - f = bed.foundation - return ( - len(f.lights) - + len(f.actuators) - + len(f.presets) - + len(f.foot_warmers) - + len(f.core_climates) - ) +_FIRMNESS_CONTROL = "firmness control" def _filter_duplicate_beds(gateway: AsyncSleepIQ) -> None: - """Remove duplicate bed objects that share sleeper IDs. + """Remove "Firmness Control" controller objects that duplicate a real bed. - Groups beds whose sleeper-ID sets overlap and keeps the one with - the most foundation features so the real bed survives regardless - of API ordering. + The SleepIQ API can return a controller object alongside the real bed, + both carrying the same sleeper IDs. The controller is identified by + "Firmness Control" in its model string. """ - groups: dict[frozenset[str], list[str]] = {} + to_remove: list[str] = [] for bed_id, bed in gateway.beds.items(): - bed_sleeper_ids = frozenset(s.sleeper_id for s in bed.sleepers if s.sleeper_id) - if not bed_sleeper_ids: - continue - matched = None - for key in groups: - if key & bed_sleeper_ids: - matched = key - break - if matched is not None: - groups[matched].append(bed_id) - else: - groups[bed_sleeper_ids] = [bed_id] - - for bed_ids in groups.values(): - if len(bed_ids) < 2: - continue - best = max( - bed_ids, key=lambda bid: _foundation_feature_count(gateway.beds[bid]) + if _FIRMNESS_CONTROL in (bed.model or "").lower(): + to_remove.append(bed_id) + for bed_id in to_remove: + _LOGGER.debug( + "Removing controller duplicate '%s' (id=%s, model=%s)", + gateway.beds[bed_id].name, + bed_id, + gateway.beds[bed_id].model, ) - for bed_id in bed_ids: - if bed_id != best: - _LOGGER.debug( - "Removing duplicate bed '%s' (id=%s), keeping '%s' (id=%s)" - " which has more foundation features", - gateway.beds[bed_id].name, - bed_id, - gateway.beds[best].name, - best, - ) - del gateway.beds[bed_id] + del gateway.beds[bed_id] async def _async_migrate_unique_ids( diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 3df302e6edca5..e7efd8eb0275d 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.52", "sqlparse==0.6.0"] + "requirements": ["SQLAlchemy==2.0.53", "sqlparse==0.6.0"] } diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index e4150b8c624df..f19b3c719d86d 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -155,6 +155,18 @@ def log_missing(self, missing_entities: set[str], logger: Logger) -> None: ) +@callback +def _add_referenced_device( + dev_reg: dr.DeviceRegistry, device_id: str, selected: SelectedEntities +) -> None: + """Add a device and its child devices.""" + selected.referenced_devices.add(device_id) + selected.referenced_devices.update( + child_device.id + for child_device in dr.async_entries_for_parent_device(dev_reg, device_id) + ) + + @callback def _resolve_referenced_devices( dev_reg: dr.DeviceRegistry, device_ids: set[str], selected: SelectedEntities @@ -165,8 +177,6 @@ def _resolve_referenced_devices( if device is None: selected.missing_devices.add(device_id) selected.referenced_devices.add(device_id) - elif isinstance(device, dr.ChildDeviceEntry): - selected.referenced_devices.add(device_id) elif split_devices := dev_reg.async_get_devices_for_composite_device_id( device_id ): @@ -174,23 +184,10 @@ def _resolve_referenced_devices( # it resolves to the devices it was split into so actions targeting it # still trickle down. Only the splits are referenced, not the composite id, # so a device-id consumer does not act on the same underlying device twice. - # Each split's children are included too, matching the direct-device branch. for split_device in split_devices: - selected.referenced_devices.add(split_device.id) - selected.referenced_devices.update( - child_device.id - for child_device in dr.async_entries_for_parent_device( - dev_reg, split_device.id - ) - ) + _add_referenced_device(dev_reg, split_device.id, selected) else: - selected.referenced_devices.add(device_id) - selected.referenced_devices.update( - child_device.id - for child_device in dr.async_entries_for_parent_device( - dev_reg, device_id - ) - ) + _add_referenced_device(dev_reg, device_id, selected) def async_extract_referenced_entity_ids( @@ -255,12 +252,9 @@ def async_extract_referenced_entity_ids( if entity_entry.hidden_by is None: selected.indirectly_referenced.add(entity_entry.entity_id) - # Labels are never inherited by child devices (see - # dr.async_entries_for_label): a labeled parent is not expanded into its - # children. Only devices that carry the label themselves are targeted, - # which is consistent with template label_devices() and search. + # Labeled devices expand like directly targeted devices, children included. for device_entry in dr.async_entries_for_label(dev_reg, label_id): - selected.referenced_devices.add(device_entry.id) + _add_referenced_device(dev_reg, device_entry.id, selected) for area_entry in area_reg.areas.get_areas_for_label(label_id): selected.referenced_areas.add(area_entry.id) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 980e5f4f7be7a..0e8e1f040e13a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -66,13 +66,13 @@ PyYAML==6.0.3 requests==2.34.2 securetar==2026.4.1 serialx==1.10.0 -SQLAlchemy==2.0.52 +SQLAlchemy==2.0.53 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.13 +uv==0.12.14 webrtc-models==0.3.0 yarl==1.25.1 zeroconf==0.151.3 diff --git a/pyproject.toml b/pyproject.toml index 0d7a504f43c13..d03df59627541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,13 +71,13 @@ dependencies = [ "PyYAML==6.0.3", "requests==2.34.2", "securetar==2026.4.1", - "SQLAlchemy==2.0.52", + "SQLAlchemy==2.0.53", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.13", + "uv==0.12.14", "probatio==0.12.1", "yarl==1.25.1", "webrtc-models==0.3.0", diff --git a/requirements.txt b/requirements.txt index 89b6f4c9710a2..f5322d85d6a39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,13 +51,13 @@ PyYAML==6.0.3 requests==2.34.2 rf-protocols==4.3.0 securetar==2026.4.1 -SQLAlchemy==2.0.52 +SQLAlchemy==2.0.53 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.13 +uv==0.12.14 webrtc-models==0.3.0 yarl==1.25.1 zeroconf==0.151.3 diff --git a/requirements_all.txt b/requirements_all.txt index 04426585ac607..71920e3d80ade 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -115,7 +115,7 @@ RestrictedPython==8.5 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.52 +SQLAlchemy==2.0.53 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -615,7 +615,7 @@ asyncpysupla==0.0.5 asyncsleepiq==1.7.2 # homeassistant.components.sftp_storage -asyncssh==2.23.1 +asyncssh==2.24.0 # homeassistant.components.aurora auroranoaa==0.0.5 @@ -1845,7 +1845,7 @@ openerz-api==0.3.0 openhomedevice==2.5 # homeassistant.components.openrgb -openrgb-python==0.3.6 +openrgb-python==0.3.7 # homeassistant.components.opensensemap opensensemap-api==0.4.1 diff --git a/requirements_test.txt b/requirements_test.txt index 399f3c5a99119..c595e8607dae1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -55,4 +55,4 @@ types-pytz==2026.3.1.20260727 types-PyYAML==6.0.12.20260724 types-requests==2.33.0.20260712 types-xmltodict==1.0.1.20260518 -unidiff==1.0.0 +unidiff==1.0.1 diff --git a/script/check_requirements/requirements.txt b/script/check_requirements/requirements.txt index 9d2db10613483..8e49d23cc07b3 100644 --- a/script/check_requirements/requirements.txt +++ b/script/check_requirements/requirements.txt @@ -1,3 +1,3 @@ PyGithub==2.9.1 requests==2.34.2 -unidiff==1.0.0 +unidiff==1.0.1 diff --git a/tests/components/portainer/test_update.py b/tests/components/portainer/test_update.py index 6bcf621f76729..832a7be5e0a99 100644 --- a/tests/components/portainer/test_update.py +++ b/tests/components/portainer/test_update.py @@ -1,25 +1,44 @@ """Tests for the Portainer update platform.""" +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from pyportainer.exceptions import ( PortainerAuthenticationError, PortainerConnectionError, ) +from pyportainer.models.docker import DockerContainer, PortainerImageUpdateStatus +from pyportainer.watcher import PortainerImageWatcherResult import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.update import ATTR_INSTALLED_VERSION -from homeassistant.const import Platform +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.components.portainer.coordinator import DEFAULT_SCAN_INTERVAL +from homeassistant.components.update import ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) ENTITY_ID = "update.funny_chatelet_image_update_available" +CONTAINER_IMAGE = "docker.io/library/ubuntu:latest" +INSTALLED_DIGEST = ( + "sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb" +) +RECREATED_CONTAINER_ID = ( + "0011facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf" +) @pytest.fixture(autouse=True) @@ -158,3 +177,152 @@ async def test_update_using_cache( ) mock_portainer_client.get_image.assert_not_called() + + +async def _watch_all_containers(hass: HomeAssistant, watcher: MagicMock) -> None: + """Give the watcher a result for every container, as after its first run.""" + containers = cast( + list[dict[str, Any]], + await async_load_json_array_fixture(hass, "containers.json", DOMAIN), + ) + watcher.results = { + (1, container["Id"]): PortainerImageWatcherResult( + endpoint_id=1, + container_id=container["Id"], + status=PortainerImageUpdateStatus( + update_available=True, + local_digest=INSTALLED_DIGEST, + registry_digest="sha256:newdigest123456789", + ), + ) + for container in containers + } + watcher.last_check = 1234 + + +async def _recreate_container(hass: HomeAssistant, client: AsyncMock) -> None: + """Give the funny_chatelet container a new ID, as a recreate does.""" + containers = cast( + list[dict[str, Any]], + await async_load_json_array_fixture(hass, "containers.json", DOMAIN), + ) + recreated = next( + container for container in containers if "/funny_chatelet" in container["Names"] + ) + recreated["Id"] = RECREATED_CONTAINER_ID + client.get_containers.return_value = [ + DockerContainer.from_dict(container) for container in containers + ] + + +async def test_update_recreated_container( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a recreated container gets its image checked instead of staying unknown.""" + await _watch_all_containers(hass, mock_portainer_watcher) + + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + # The watcher only has a result for the old container ID + await _recreate_container(hass, mock_portainer_client) + mock_portainer_client.container_image_status.return_value = ( + PortainerImageUpdateStatus( + update_available=False, + local_digest=INSTALLED_DIGEST, + registry_digest=INSTALLED_DIGEST, + ) + ) + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_portainer_client.container_image_status.assert_called_once_with( + 1, CONTAINER_IMAGE + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_LATEST_VERSION] == "sha256:afcc7f1ac1b4" + + # The result is kept until the watcher runs again, not fetched every poll + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_portainer_client.container_image_status.assert_called_once() + + +async def test_update_recreated_container_before_watcher_ran( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a container is left to the watcher's first run.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + await _recreate_container(hass, mock_portainer_client) + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_portainer_client.container_image_status.assert_not_called() + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param(PortainerConnectionError("conn"), id="connection"), + pytest.param(PortainerAuthenticationError("auth"), id="authentication"), + ], +) +async def test_update_recreated_container_check_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test a failed image check leaves the update unknown without failing the refresh.""" + await _watch_all_containers(hass, mock_portainer_watcher) + + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + await _recreate_container(hass, mock_portainer_client) + mock_portainer_client.container_image_status.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_config_entry.runtime_data.last_update_success + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/sleepiq/test_init.py b/tests/components/sleepiq/test_init.py index 2120ce0e1d074..ec7ee449bab76 100644 --- a/tests/components/sleepiq/test_init.py +++ b/tests/components/sleepiq/test_init.py @@ -369,3 +369,103 @@ async def test_duplicate_beds_none_sleeper_ids_not_filtered( entry = await setup_platform(hass, "sensor") assert entry.state is ConfigEntryState.LOADED assert "ghost_001" in mock_asyncsleepiq.beds + + +async def test_real_beds_sharing_sleeper_not_filtered( + hass: HomeAssistant, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that two real beds sharing a sleeper profile are both preserved.""" + second_bed = create_autospec(SleepIQBed) + second_bed.name = "Bedroom 2" + second_bed.id = "bed_002" + second_bed.mac_addr = "AA:BB:CC:DD:EE:03" + second_bed.model = "C10" + second_bed.paused = False + + shared_sleeper = create_autospec(SleepIQSleeper) + shared_sleeper.side = Side.LEFT + shared_sleeper.name = SLEEPER_L_NAME + shared_sleeper.sleeper_id = SLEEPER_L_ID + shared_sleeper.in_bed = False + shared_sleeper.sleep_number = 50 + shared_sleeper.pressure = 1200 + shared_sleeper.sleep_data = SleepData( + duration=0, sleep_score=0, heart_rate=0, respiratory_rate=0, hrv=0 + ) + + other_sleeper = create_autospec(SleepIQSleeper) + other_sleeper.side = Side.RIGHT + other_sleeper.name = "Other Person" + other_sleeper.sleeper_id = "99999" + other_sleeper.in_bed = False + other_sleeper.sleep_number = 60 + other_sleeper.pressure = 1100 + other_sleeper.sleep_data = SleepData( + duration=0, sleep_score=0, heart_rate=0, respiratory_rate=0, hrv=0 + ) + + second_bed.sleepers = [shared_sleeper, other_sleeper] + second_bed.foundation = create_autospec(SleepIQFoundation) + second_bed.foundation.lights = [MagicMock()] + second_bed.foundation.actuators = [] + second_bed.foundation.presets = [] + second_bed.foundation.foot_warmers = [] + second_bed.foundation.core_climates = [] + + mock_asyncsleepiq.beds["bed_002"] = second_bed + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + assert BED_ID in mock_asyncsleepiq.beds + assert "bed_002" in mock_asyncsleepiq.beds + + +async def test_identical_sleepers_both_with_features_preserved( + hass: HomeAssistant, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that two feature-bearing beds with identical sleeper sets are both kept.""" + duplicate_bed = create_autospec(SleepIQBed) + duplicate_bed.name = "Living Room Bed" + duplicate_bed.id = "bed_003" + duplicate_bed.mac_addr = "AA:BB:CC:DD:EE:04" + duplicate_bed.model = "C10" + duplicate_bed.paused = False + + dup_sleeper_l = create_autospec(SleepIQSleeper) + dup_sleeper_l.side = Side.LEFT + dup_sleeper_l.name = SLEEPER_L_NAME + dup_sleeper_l.sleeper_id = SLEEPER_L_ID + dup_sleeper_l.in_bed = False + dup_sleeper_l.sleep_number = 50 + dup_sleeper_l.pressure = 1200 + dup_sleeper_l.sleep_data = SleepData( + duration=0, sleep_score=0, heart_rate=0, respiratory_rate=0, hrv=0 + ) + + dup_sleeper_r = create_autospec(SleepIQSleeper) + dup_sleeper_r.side = Side.RIGHT + dup_sleeper_r.name = SLEEPER_R_NAME + dup_sleeper_r.sleeper_id = SLEEPER_R_ID + dup_sleeper_r.in_bed = False + dup_sleeper_r.sleep_number = 60 + dup_sleeper_r.pressure = 1100 + dup_sleeper_r.sleep_data = SleepData( + duration=0, sleep_score=0, heart_rate=0, respiratory_rate=0, hrv=0 + ) + + duplicate_bed.sleepers = [dup_sleeper_l, dup_sleeper_r] + duplicate_bed.foundation = create_autospec(SleepIQFoundation) + duplicate_bed.foundation.lights = [MagicMock()] + duplicate_bed.foundation.actuators = [] + duplicate_bed.foundation.presets = [] + duplicate_bed.foundation.foot_warmers = [] + duplicate_bed.foundation.core_climates = [] + + mock_asyncsleepiq.beds["bed_003"] = duplicate_bed + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + assert BED_ID in mock_asyncsleepiq.beds + assert "bed_003" in mock_asyncsleepiq.beds diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index c4d9050717c12..a11f0a7765596 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -1262,21 +1262,28 @@ async def test_extract_label_with_child_devices( hass: HomeAssistant, child_device_setup: dict[str, str], ) -> None: - """Test labels are not inherited by child devices when targeting. + """Test a labeled parent device expands to its child devices when targeting. - A labeled parent targets only the parent and its own entities; a labeled child - targets only that child. This mirrors dr.async_entries_for_label, which - documents that labels are never inherited from the parent. + Labels are not inherited by child devices, but a labeled parent is targeted like + a directly targeted device, so its children are included. A labeled child + targets only that child. """ ids = child_device_setup - # The label is on the parent; it does not expand to the child devices, and only - # the parent's own entities are indirectly referenced. selected = target.async_extract_referenced_entity_ids( hass, target.TargetSelection({"label_id": "strip-label"}) ) - assert selected.referenced_devices == {ids["parent"]} - assert selected.indirectly_referenced == {ids["strip_switch"]} + assert selected.referenced_devices == { + ids["parent"], + ids["outlet_1"], + ids["outlet_2"], + } + assert selected.indirectly_referenced == { + ids["strip_switch"], + ids["outlet_1_switch"], + ids["outlet_2_switch"], + ids["outlet_1_energy"], + } # A label on a child device targets only the child device selected = target.async_extract_referenced_entity_ids(