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 ( from asyncsleepiq import (
AsyncSleepIQ, AsyncSleepIQ,
SleepIQAPIException, SleepIQAPIException,
SleepIQBed,
SleepIQLoginException, SleepIQLoginException,
SleepIQTimeoutException, SleepIQTimeoutException,
) )
@@ -92,6 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> b
except SleepIQAPIException as err: except SleepIQAPIException as err:
raise ConfigEntryNotReady(str(err) or "Error reading from SleepIQ API") from 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) await _async_migrate_unique_ids(hass, entry, gateway)
coordinator = SleepIQDataUpdateCoordinator(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) 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( async def _async_migrate_unique_ids(
hass: HomeAssistant, entry: ConfigEntry, gateway: AsyncSleepIQ hass: HomeAssistant, entry: ConfigEntry, gateway: AsyncSleepIQ
) -> None: ) -> None:
+143 -1
View File
@@ -3,11 +3,16 @@
from collections.abc import Callable from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import MagicMock from unittest.mock import MagicMock, create_autospec
from asyncsleepiq import ( from asyncsleepiq import (
Side,
SleepData,
SleepIQAPIException, SleepIQAPIException,
SleepIQBed,
SleepIQFoundation,
SleepIQLoginException, SleepIQLoginException,
SleepIQSleeper,
SleepIQTimeoutException, SleepIQTimeoutException,
) )
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
@@ -31,6 +36,8 @@ from .conftest import (
SLEEPER_L_ID, SLEEPER_L_ID,
SLEEPER_L_NAME, SLEEPER_L_NAME,
SLEEPER_L_NAME_LOWER, SLEEPER_L_NAME_LOWER,
SLEEPER_R_ID,
SLEEPER_R_NAME,
SLEEPIQ_CONFIG, SLEEPIQ_CONFIG,
setup_platform, 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) sensor_sleep_number = ent_reg.async_get(ENTITY_SLEEP_NUMBER)
assert sensor_sleep_number.unique_id == f"{SLEEPER_L_ID}_{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