From 97615b11c678fc00a14ee2522d338ebe48ba692e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 1 Aug 2026 18:48:56 +0200 Subject: [PATCH] Fix via_device race in motion_blinds (#177735) --- .../components/motion_blinds/__init__.py | 18 ++- .../components/motion_blinds/entity.py | 78 +++++++------ tests/components/motion_blinds/test_init.py | 109 ++++++++++++++++++ 3 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 tests/components/motion_blinds/test_init.py diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 79380be47d31..aeb96c5a2659 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -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 diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 3d843cc373c8..2fabb4a06a2a 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -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 diff --git a/tests/components/motion_blinds/test_init.py b/tests/components/motion_blinds/test_init.py new file mode 100644 index 000000000000..8a49ad305f5d --- /dev/null +++ b/tests/components/motion_blinds/test_init.py @@ -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