Fix via_device race in lyric (#177935)

This commit is contained in:
Erik Montnemery
2026-08-04 14:33:43 +02:00
committed by Bram Kragten
parent 1622d8c771
commit 7eaae601bd
3 changed files with 134 additions and 8 deletions
@@ -9,6 +9,7 @@ from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
device_registry as dr,
)
from .api import (
@@ -18,6 +19,7 @@ from .api import (
)
from .const import DOMAIN
from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator
from .entity import create_thermostat_device_info
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -59,6 +61,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> boo
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Register the thermostat devices up front so the accessory (room sensor)
# entities can resolve them as their via_device parent regardless of the
# order the platforms are set up in.
device_registry = dr.async_get(hass)
for location in coordinator.data.locations:
for device in location.devices:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
**create_thermostat_device_info(device),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+17 -8
View File
@@ -50,6 +50,17 @@ class LyricEntity(CoordinatorEntity[LyricDataUpdateCoordinator]):
return self.location.devices_dict[self._mac_id]
def create_thermostat_device_info(device: LyricDevice) -> DeviceInfo:
"""Return the device info for a Lyric thermostat."""
return DeviceInfo(
identifiers={(dr.CONNECTION_NETWORK_MAC, device.mac_id)},
connections={(dr.CONNECTION_NETWORK_MAC, device.mac_id)},
manufacturer="Honeywell",
model=device.device_model,
name=f"{device.name} Thermostat",
)
class LyricDeviceEntity(LyricEntity):
"""Defines a Honeywell Lyric device entity."""
@@ -57,13 +68,7 @@ class LyricDeviceEntity(LyricEntity):
@override
def device_info(self) -> DeviceInfo:
"""Return device information about this Honeywell Lyric instance."""
return DeviceInfo(
identifiers={(dr.CONNECTION_NETWORK_MAC, self._mac_id)},
connections={(dr.CONNECTION_NETWORK_MAC, self._mac_id)},
manufacturer="Honeywell",
model=self.device.device_model,
name=f"{self.device.name} Thermostat",
)
return create_thermostat_device_info(self.device)
class LyricAccessoryEntity(LyricDeviceEntity):
@@ -97,7 +102,11 @@ class LyricAccessoryEntity(LyricDeviceEntity):
manufacturer="Honeywell",
model="RCHTSENSOR",
name=f"{self.room.room_name} Sensor",
via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id),
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(dr.CONNECTION_NETWORK_MAC, self._mac_id),
config_entry_id=self.coordinator.config_entry.entry_id,
),
)
@property
+104
View File
@@ -1,8 +1,23 @@
"""Tests for the Honeywell Lyric sensor platform."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from aiolyric import Lyric
from aiolyric.objects.location import LyricLocation
from aiolyric.objects.priority import LyricRoom
from homeassistant.components.lyric.api import LyricLocalOAuth2Implementation
from homeassistant.components.lyric.const import DOMAIN
from homeassistant.components.lyric.sensor import get_datetime_from_future_time
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
_MAC = "AABBCCDDEEFF"
def test_get_datetime_from_future_time_none() -> None:
@@ -19,3 +34,92 @@ def test_get_datetime_from_future_time_valid() -> None:
"""Test that a valid time string returns a datetime."""
result = get_datetime_from_future_time("13:30:00")
assert isinstance(result, datetime)
def _mock_lyric() -> MagicMock:
"""Build a fake aiolyric Lyric client with one thermostat and one room accessory."""
client = MagicMock()
location = LyricLocation(
client,
{
"locationID": 1234,
"name": "Home",
"devices": [
# A thermostat with no device-level sensors: the sensor platform
# creates no thermostat entity, so the thermostat device can only
# come from the up-front registration in async_setup_entry.
{
"deviceID": f"LCC-{_MAC}",
"deviceClass": "Thermostat",
"macID": _MAC,
"name": "Thermostat",
"deviceModel": "T5-T6",
}
],
},
)
room = LyricRoom(
{
"id": 1,
"roomName": "Living Room",
"roomAvgTemp": 21,
"roomAvgHumidity": 40,
"accessories": [{"id": 1, "type": "IndoorAirSensor", "temperature": 21}],
}
)
lyric = MagicMock(spec=Lyric)
lyric.get_locations = AsyncMock()
lyric.get_thermostat_rooms = AsyncMock()
lyric.locations = [location]
lyric.locations_dict = {1234: location}
lyric.rooms_dict = {_MAC: {1: room}}
return lyric
async def test_accessory_links_to_thermostat_via_device(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test room accessory devices resolve the thermostat as their via_device."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": 9999999999,
"token_type": "Bearer",
},
},
)
entry.add_to_hass(hass)
implementation = MagicMock(spec=LyricLocalOAuth2Implementation)
implementation.client_id = "client-id"
with (
patch(
"homeassistant.helpers.config_entry_oauth2_flow."
"async_get_config_entry_implementation",
return_value=implementation,
),
patch("homeassistant.components.lyric.PLATFORMS", [Platform.SENSOR]),
patch("homeassistant.components.lyric.Lyric", return_value=_mock_lyric()),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
thermostat = device_registry.async_get_device_by_identifier(
(dr.CONNECTION_NETWORK_MAC, _MAC), entry.entry_id
)
assert thermostat is not None
accessory = device_registry.async_get_device_by_identifier(
(f"{dr.CONNECTION_NETWORK_MAC}_room_accessory", f"{_MAC}_room1_accessory1"),
entry.entry_id,
)
assert accessory is not None
assert accessory.via_device_id == thermostat.id