Filter duplicate bed objects from SleepIQ API before entity setup (#178682)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
derekcentrico
2026-08-23 22:50:19 +02:00
committed by GitHub
co-authored by Joost Lekkerkerker
parent 2aa09bc9d4
commit 40ec3f7a1e
2 changed files with 198 additions and 1 deletions
@@ -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:
+143 -1
View File
@@ -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