From 40ec3f7a1ed06abb2c91cc2b0836579d70199865 Mon Sep 17 00:00:00 2001 From: derekcentrico <1930094+derekcentrico@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:50:19 -0400 Subject: [PATCH] Filter duplicate bed objects from SleepIQ API before entity setup (#178682) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sleepiq/__init__.py | 55 +++++++ tests/components/sleepiq/test_init.py | 144 ++++++++++++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 742980259bfe..1e7c5f38eeee 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -6,6 +6,7 @@ from typing import Any from asyncsleepiq import ( AsyncSleepIQ, SleepIQAPIException, + SleepIQBed, SleepIQLoginException, SleepIQTimeoutException, ) @@ -92,6 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> b except SleepIQAPIException as err: raise ConfigEntryNotReady(str(err) or "Error reading from SleepIQ API") from err + _filter_duplicate_beds(gateway) await _async_migrate_unique_ids(hass, entry, gateway) coordinator = SleepIQDataUpdateCoordinator(hass, entry, gateway) @@ -120,6 +122,59 @@ 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) + ) + + +def _filter_duplicate_beds(gateway: AsyncSleepIQ) -> None: + """Remove duplicate bed objects that share sleeper IDs. + + 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. + """ + groups: dict[frozenset[str], 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]) + ) + 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] + + async def _async_migrate_unique_ids( hass: HomeAssistant, entry: ConfigEntry, gateway: AsyncSleepIQ ) -> None: diff --git a/tests/components/sleepiq/test_init.py b/tests/components/sleepiq/test_init.py index 798eb20414a9..51e6c303718b 100644 --- a/tests/components/sleepiq/test_init.py +++ b/tests/components/sleepiq/test_init.py @@ -3,11 +3,16 @@ from collections.abc import Callable from datetime import timedelta from http import HTTPStatus -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec from asyncsleepiq import ( + Side, + SleepData, SleepIQAPIException, + SleepIQBed, + SleepIQFoundation, SleepIQLoginException, + SleepIQSleeper, SleepIQTimeoutException, ) from freezegun.api import FrozenDateTimeFactory @@ -31,6 +36,8 @@ from .conftest import ( SLEEPER_L_ID, SLEEPER_L_NAME, SLEEPER_L_NAME_LOWER, + SLEEPER_R_ID, + SLEEPER_R_NAME, SLEEPIQ_CONFIG, setup_platform, ) @@ -214,3 +221,138 @@ async def test_unique_id_migration(hass: HomeAssistant, mock_asyncsleepiq) -> No sensor_sleep_number = ent_reg.async_get(ENTITY_SLEEP_NUMBER) assert sensor_sleep_number.unique_id == f"{SLEEPER_L_ID}_{SLEEP_NUMBER}" + + +def _make_controller() -> MagicMock: + """Build a bare controller bed with no foundation features.""" + controller = create_autospec(SleepIQBed) + controller.name = "Firmness Control" + controller.id = "ctrl_001" + controller.mac_addr = "AA:BB:CC:DD:EE:01" + controller.model = "Firmness Control, 360, Dual,Boxed" + controller.paused = False + + ctrl_sleeper_l = create_autospec(SleepIQSleeper) + ctrl_sleeper_l.side = Side.LEFT + ctrl_sleeper_l.name = SLEEPER_L_NAME + ctrl_sleeper_l.sleeper_id = SLEEPER_L_ID + ctrl_sleeper_l.in_bed = True + ctrl_sleeper_l.sleep_number = 40 + ctrl_sleeper_l.pressure = 1000 + ctrl_sleeper_l.sleep_data = SleepData( + duration=28800, + sleep_score=85, + heart_rate=60, + respiratory_rate=14, + hrv=68, + ) + + ctrl_sleeper_r = create_autospec(SleepIQSleeper) + ctrl_sleeper_r.side = Side.RIGHT + ctrl_sleeper_r.name = SLEEPER_R_NAME + ctrl_sleeper_r.sleeper_id = SLEEPER_R_ID + ctrl_sleeper_r.in_bed = False + ctrl_sleeper_r.sleep_number = 80 + ctrl_sleeper_r.pressure = 1400 + ctrl_sleeper_r.sleep_data = SleepData( + duration=25200, + sleep_score=78, + heart_rate=65, + respiratory_rate=15, + hrv=72, + ) + + controller.sleepers = [ctrl_sleeper_l, ctrl_sleeper_r] + controller.foundation = create_autospec(SleepIQFoundation) + controller.foundation.lights = [] + controller.foundation.actuators = [] + controller.foundation.presets = [] + controller.foundation.foot_warmers = [] + controller.foundation.core_climates = [] + return controller + + +async def test_duplicate_beds_filtered( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that duplicate bed objects sharing sleeper IDs are filtered.""" + mock_asyncsleepiq.beds["ctrl_001"] = _make_controller() + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + + sleeper_l_entities = [ + e + for e in er.async_entries_for_config_entry(entity_registry, entry.entry_id) + if SLEEPER_L_ID in e.unique_id + ] + sleeper_r_entities = [ + e + for e in er.async_entries_for_config_entry(entity_registry, entry.entry_id) + if SLEEPER_R_ID in e.unique_id + ] + + assert len(sleeper_l_entities) > 0 + assert len(sleeper_r_entities) > 0 + + bed_ids = list(mock_asyncsleepiq.beds) + assert "ctrl_001" not in bed_ids + assert BED_ID in bed_ids + + +async def test_duplicate_beds_controller_first( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that the real bed survives even when the controller appears first.""" + real_bed = mock_asyncsleepiq.beds.pop(BED_ID) + mock_asyncsleepiq.beds["ctrl_001"] = _make_controller() + mock_asyncsleepiq.beds[BED_ID] = real_bed + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + + bed_ids = list(mock_asyncsleepiq.beds) + assert BED_ID in bed_ids + assert "ctrl_001" not in bed_ids + + +async def test_duplicate_beds_none_sleeper_ids_not_filtered( + hass: HomeAssistant, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test that beds with None sleeper IDs are not falsely treated as duplicates.""" + ghost_bed = create_autospec(SleepIQBed) + ghost_bed.name = "Guest Bed" + ghost_bed.id = "ghost_001" + ghost_bed.mac_addr = "AA:BB:CC:DD:EE:02" + ghost_bed.model = "Guest" + ghost_bed.paused = False + + ghost_sleeper = create_autospec(SleepIQSleeper) + ghost_sleeper.side = Side.LEFT + ghost_sleeper.name = "Guest" + ghost_sleeper.sleeper_id = None + ghost_sleeper.in_bed = False + ghost_sleeper.sleep_number = 50 + ghost_sleeper.pressure = 1200 + ghost_sleeper.sleep_data = SleepData( + duration=0, sleep_score=0, heart_rate=0, respiratory_rate=0, hrv=0 + ) + + ghost_bed.sleepers = [ghost_sleeper] + ghost_bed.foundation = create_autospec(SleepIQFoundation) + ghost_bed.foundation.lights = [] + ghost_bed.foundation.actuators = [] + ghost_bed.foundation.presets = [] + ghost_bed.foundation.foot_warmers = [] + ghost_bed.foundation.core_climates = [] + + mock_asyncsleepiq.beds["ghost_001"] = ghost_bed + + entry = await setup_platform(hass, "sensor") + assert entry.state is ConfigEntryState.LOADED + assert "ghost_001" in mock_asyncsleepiq.beds