diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 57e2d536eb0f..9662efb4ca57 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -6,7 +6,6 @@ from typing import Any 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/tests/components/sleepiq/test_init.py b/tests/components/sleepiq/test_init.py index 2120ce0e1d07..ec7ee449bab7 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