Fix via_device race in motion_blinds (#177735)

This commit is contained in:
Erik Montnemery
2026-08-01 18:48:56 +02:00
committed by GitHub
parent 3a39b775a9
commit 97615b11c6
3 changed files with 171 additions and 34 deletions
@@ -4,11 +4,12 @@
import asyncio
import logging
from motionblinds import AsyncMotionMulticast
from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, AsyncMotionMulticast
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import (
CONF_BLIND_TYPE_LIST,
@@ -21,6 +22,7 @@ from .const import (
PLATFORMS,
)
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
from .entity import gateway_device_info
from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__)
@@ -101,6 +103,20 @@ async def async_setup_entry(
entry.runtime_data = coordinator
# Register the gateway device up front so child blinds can resolve it as their
# via_device parent regardless of the order platforms are set up in. The any()
# is the exact complement of the children's linking condition, so the gateway is
# still registered if it self-reports an unexpected device_type while RF (non
# Wi-Fi) blinds depend on it.
if motion_gateway.device_type in DEVICE_TYPES_GATEWAY or any(
blind.device_type not in DEVICE_TYPES_WIFI
for blind in motion_gateway.device_list.values()
):
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id,
**gateway_device_info(motion_gateway),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -24,6 +24,23 @@ from .coordinator import DataUpdateCoordinatorMotionBlinds
from .gateway import device_name
def gateway_device_info(gateway: MotionGateway) -> DeviceInfo:
"""Return the device info of a Motionblinds gateway."""
if gateway.firmware is not None:
sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}"
else:
sw_version = f"Protocol: {gateway.protocol}"
return DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, gateway.mac)},
identifiers={(DOMAIN, gateway.mac)},
manufacturer=MANUFACTURER,
name=DEFAULT_GATEWAY_NAME,
model="Wi-Fi bridge",
sw_version=sw_version,
)
class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]):
"""Representation of a Motionblind entity."""
@@ -50,42 +67,37 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
self._update_interval_moving = UPDATE_INTERVAL_MOVING
if blind.device_type in DEVICE_TYPES_GATEWAY:
gateway = blind
self._attr_device_info = gateway_device_info(blind)
else:
gateway = blind._gateway # noqa: SLF001
if gateway.firmware is not None:
sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}"
else:
sw_version = f"Protocol: {gateway.protocol}"
if gateway.firmware is not None:
sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}"
else:
sw_version = f"Protocol: {gateway.protocol}"
if blind.device_type in DEVICE_TYPES_GATEWAY:
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)},
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
name=DEFAULT_GATEWAY_NAME,
model="Wi-Fi bridge",
sw_version=sw_version,
)
elif blind.device_type in DEVICE_TYPES_WIFI:
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)},
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
sw_version=sw_version,
hw_version=blind.wireless_name,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
via_device=(DOMAIN, blind._gateway.mac), # noqa: SLF001
hw_version=blind.wireless_name,
)
if blind.device_type in DEVICE_TYPES_WIFI:
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)},
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
sw_version=sw_version,
hw_version=blind.wireless_name,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, gateway.mac),
config_entry_id=coordinator.config_entry.entry_id,
),
hw_version=blind.wireless_name,
)
@property
@override
+109
View File
@@ -0,0 +1,109 @@
"""Test the Motionblinds setup."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, BlindType
from motionblinds.motion_blinds import DEVICE_TYPE_BLIND
import pytest
from homeassistant.components.motion_blinds.const import DEFAULT_INTERFACE, DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
TEST_HOST = "1.2.3.4"
TEST_API_KEY = "12ab345c-d67e-8f"
TEST_GATEWAY_MAC = "abcdefghijkl"
TEST_BLIND_MAC = "abcdefghijkl0001"
@pytest.fixture(name="mock_gateway")
def mock_gateway_fixture() -> Mock:
"""Return a mocked gateway with a single sub-blind."""
blind = Mock()
blind.mac = TEST_BLIND_MAC
blind.device_type = DEVICE_TYPE_BLIND
blind.type = BlindType.RollerBlind
blind.blind_type = BlindType.RollerBlind.name
blind.wireless_name = "RF"
blind.battery_voltage = 0
blind.limit_status = "Limit2Detected"
blind.position = 0
blind.angle = 0
blind.RSSI = -50
gateway = Mock()
gateway.mac = TEST_GATEWAY_MAC
gateway.device_type = DEVICE_TYPES_GATEWAY[0]
gateway.firmware = "1.0.0"
gateway.protocol = "1.0"
gateway.device_list = {TEST_BLIND_MAC: blind}
gateway.blind_type_list = {TEST_BLIND_MAC: BlindType.RollerBlind.value}
blind._gateway = gateway
return gateway
@pytest.fixture(name="mock_connect", autouse=True)
def mock_connect_fixture(mock_gateway: Mock) -> Generator[None]:
"""Mock the connection to the Motion gateway."""
with (
patch(
"homeassistant.components.motion_blinds.AsyncMotionMulticast"
) as multicast_class,
patch(
"homeassistant.components.motion_blinds.ConnectMotionGateway"
) as connect_class,
):
multicast_class.return_value.Start_listen = AsyncMock()
connect = connect_class.return_value
connect.async_check_interface = AsyncMock(return_value=DEFAULT_INTERFACE)
connect.async_connect_gateway = AsyncMock(return_value=True)
connect.gateway_device = mock_gateway
yield
@pytest.mark.parametrize(
"gateway_device_type",
[
pytest.param(DEVICE_TYPES_GATEWAY[0], id="reported-gateway-type"),
pytest.param(DEVICE_TYPES_WIFI[0], id="unexpected-non-gateway-type"),
],
)
async def test_sub_blind_links_to_gateway_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_gateway: Mock,
gateway_device_type: str,
) -> None:
"""Test that a sub-blind device links to the gateway device as its parent.
The gateway device must be registered up front even when the gateway
self-reports a device_type outside DEVICE_TYPES_GATEWAY, so RF (non-Wi-Fi)
blinds can still resolve it as their via_device parent.
"""
mock_gateway.device_type = gateway_device_type
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_GATEWAY_MAC,
data={CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
gateway_device = device_registry.async_get_device_by_identifier(
(DOMAIN, TEST_GATEWAY_MAC), entry.entry_id
)
blind_device = device_registry.async_get_device_by_identifier(
(DOMAIN, TEST_BLIND_MAC), entry.entry_id
)
assert gateway_device is not None
assert blind_device is not None
assert blind_device.via_device_id == gateway_device.id