Fix via_device race in point (#177744)

This commit is contained in:
Erik Montnemery
2026-08-03 15:56:53 +02:00
committed by GitHub
parent 8976c7cca0
commit 132e0bf011
3 changed files with 195 additions and 2 deletions
@@ -9,6 +9,7 @@ from pypoint import PointSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.dt import parse_datetime
@@ -50,7 +51,16 @@ class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
if new_homes := set(self.point.homes) - self._known_homes:
_LOGGER.debug("Found new homes: %s", new_homes)
device_registry = dr.async_get(self.hass)
for home_id in new_homes:
# Register the home before its child devices so they can resolve
# it as their via_device parent when their entities are created.
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, home_id)},
manufacturer="Minut",
name=self.point.homes[home_id]["name"],
)
if self.new_home_callback:
self.new_home_callback(home_id)
self._known_homes.update(new_homes)
+4 -1
View File
@@ -33,8 +33,11 @@ class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]):
name=device["description"],
hw_version=device["hardware_version"],
sw_version=device["firmware"]["installed"],
via_device=(DOMAIN, device["home"]),
)
if parent := dr.async_get(coordinator.hass).async_get_device_by_identifier(
(DOMAIN, device["home"]), coordinator.config_entry.entry_id
):
self._attr_device_info["via_device_id"] = parent.id
if self.device_class:
self._attr_name = f"{self._name} {self.device_class.capitalize()}"
+181 -1
View File
@@ -1,16 +1,147 @@
"""Tests for the Point component."""
from unittest.mock import patch
import time
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.application_credentials import (
DOMAIN as APPLICATION_CREDENTIALS_DOMAIN,
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.point import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
HOME_ID = "home_1"
DEVICE_ID = "device_1"
DEVICE_RAW: dict[str, Any] = {
"device_id": DEVICE_ID,
"device_mac": "00:11:22:33:44:55",
"description": "Kitchen",
"hardware_version": "1",
"firmware": {"installed": "2.0.0"},
"home": HOME_ID,
"last_heard_from_at": "2023-01-01T00:00:00+00:00",
"active": True,
"offline": False,
"battery": {"percent": 100},
"ongoing_events": [],
}
HOME_RAW: dict[str, Any] = {
"home_id": HOME_ID,
"name": "My Home",
"devices": [DEVICE_ID],
"alarm_status": "off",
}
class FakeDevice:
"""Minimal fake of pypoint.Device."""
def __init__(self, raw: dict[str, Any]) -> None:
"""Initialize."""
self._raw = raw
@property
def device_id(self) -> str:
"""Return the device id."""
return self._raw["device_id"]
@property
def device(self) -> dict[str, Any]:
"""Return the raw representation."""
return self._raw
@property
def name(self) -> str:
"""Return the device name."""
return self._raw["description"]
@property
def last_update(self) -> str:
"""Return the last update timestamp."""
return self._raw["last_heard_from_at"]
@property
def ongoing_events(self) -> list[str]:
"""Return ongoing events."""
return self._raw["ongoing_events"]
@property
def webhook(self) -> str:
"""Return the webhook."""
return "webhook"
@property
def device_status(self) -> dict[str, Any]:
"""Return the device status."""
return {
"active": self._raw["active"],
"offline": self._raw["offline"],
"last_update": self.last_update,
"battery_level": self._raw["battery"]["percent"],
}
async def sensor(self, sensor_type: str) -> float:
"""Return a sensor value."""
return 1.0
class FakePointSession:
"""Minimal fake of pypoint.PointSession."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize."""
self._devices = {DEVICE_ID: DEVICE_RAW}
self._homes = {HOME_ID: HOME_RAW}
self.webhook = "webhook"
async def update(self) -> bool:
"""Update the session."""
return True
@property
def homes(self) -> dict[str, dict[str, Any]]:
"""Return known homes."""
return self._homes
@property
def device_ids(self) -> Any:
"""Return known device ids."""
return self._devices.keys()
@property
def devices(self) -> Any:
"""Return device representations."""
return (FakeDevice(raw) for raw in self._devices.values())
def device(self, device_id: str) -> FakeDevice:
"""Return a single device."""
return FakeDevice(self._devices[device_id])
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to set up application credentials."""
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("1234", "5678"),
)
async def test_oauth_implementation_not_available(
hass: HomeAssistant,
@@ -38,3 +169,52 @@ async def test_oauth_implementation_not_available(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("setup_credentials")
async def test_device_via_device_link(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that a Point device is linked to its home via_device."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="abcd",
data={
"auth_implementation": DOMAIN,
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time.time() + 3600,
},
},
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.point.PointSession",
FakePointSession,
),
patch(
"homeassistant.components.point.async_setup_webhook",
new=AsyncMock(),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
home_device = device_registry.async_get_device_by_identifier(
(DOMAIN, HOME_ID), config_entry.entry_id
)
assert home_device is not None
point_device = device_registry.async_get_device_by_identifier(
(DOMAIN, DEVICE_ID), config_entry.entry_id
)
assert point_device is not None
assert point_device.via_device_id == home_device.id