mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Filter duplicate bed objects from SleepIQ API before entity setup (#178682)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
co-authored by
Joost Lekkerkerker
parent
2aa09bc9d4
commit
40ec3f7a1e
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user