Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions homeassistant/components/motion_blinds/gateway.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Code to handle a Motion Gateway."""

import asyncio
import contextlib
import logging
import socket
Expand Down Expand Up @@ -79,7 +80,7 @@ async def async_get_interfaces(self):
interfaces = [DEFAULT_INTERFACE, "0.0.0.0"]
enabled_interfaces = []
default_interface = DEFAULT_INTERFACE

adapters = await network.async_get_adapters(self._hass)
for adapter in adapters:
if ipv4s := adapter["ipv4"]:
Expand All @@ -89,16 +90,19 @@ async def async_get_interfaces(self):
enabled_interfaces.append(ip4)
if adapter["default"]:
default_interface = ip4

if len(enabled_interfaces) == 1:
default_interface = enabled_interfaces[0]

# Prioritize default interface regardless of how many NICs are present
if default_interface != DEFAULT_INTERFACE:
interfaces.remove(default_interface)
interfaces.insert(0, default_interface)

if self._interface is not None:
interfaces.remove(self._interface)
interfaces.insert(0, self._interface)

return interfaces

async def async_check_interface(self, host, key):
Expand All @@ -108,36 +112,38 @@ async def async_check_interface(self, host, key):
_LOGGER.debug(
"Checking Motionblinds interface '%s' with host %s", interface, host
)
# initialize multicast listener
check_multicast = AsyncMotionMulticast(interface=interface)
try:
await check_multicast.Start_listen()
except socket.gaierror:
continue
except OSError:
continue

# trigger test multicast

self._gateway_device = MotionGateway(
ip=host, key=key, multicast=check_multicast
)
result = await self._hass.async_add_executor_job(self.check_interface)

# close multicast listener again

# Fail fast per interface instead of waiting for full socket timeout
try:
async with asyncio.timeout(5):
result = await self._hass.async_add_executor_job(self.check_interface)
except TimeoutError:
result = False

try:
check_multicast.Stop_listen()
Comment on lines +127 to 135
except socket.gaierror:
continue

if result:
# successfully received multicast
_LOGGER.debug(
"Success using Motionblinds interface '%s' with host %s",
interface,
host,
)
return interface

_LOGGER.error(
(
"Could not find working interface for Motionblinds host %s, using"
Expand Down
150 changes: 136 additions & 14 deletions tests/components/motion_blinds/test_gateway.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,143 @@
"""Test the Motionblinds config flow."""
"""Tests for Motion Gateway interface detection."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

from unittest.mock import Mock
import pytest

from motionblinds import DEVICE_TYPES_WIFI, BlindType
from homeassistant.components.motion_blindds.gateway import ConnectMotionGateway

Check warning on line 7 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7424: Import should be using the component root (hass-component-root-import)

Check warning on line 7 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7424: Import should be using the component root (hass-component-root-import)

Check failure on line 7 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

E0611: No name 'motion_blindds' in module 'homeassistant.components' (no-name-in-module)
from homeassistant.components.motion_blinds.const import DEFAULT_INTERFACE

from homeassistant.components.motion_blinds.gateway import device_name
from homeassistant.core import HomeAssistant

TEST_BLIND_MAC = "abcdefghujkl0001"
MOCK_ADAPTERS_DUAL_NIC = [
{
"ipv4": [{"address": "192.168.1.10"}],
"enabled": True,
"default": True,
},
{
"ipv4": [{"address": "192.168.20.10"}],
"enabled": True,
"default": False,
},
]

MOCK_ADAPTERS_SINGLE_NIC = [
{
"ipv4": [{"address": "192.168.1.10"}],
"enabled": True,
"default": True,
},
]

async def test_device_name(hass: HomeAssistant) -> None:
"""test_device_name."""
blind = Mock()
blind.blind_type = BlindType.RollerBlind.name
blind.mac = TEST_BLIND_MAC
assert device_name(blind) == "RollerBlind 0001"

blind.device_type = DEVICE_TYPES_WIFI[0]
assert device_name(blind) == "RollerBlind"
async def test_get_interfaces_dual_nic_default_prioritized(hass):

Check warning on line 33 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7431: Argument hass should be of type HomeAssistant in test_get_interfaces_dual_nic_default_prioritized (hass-argument-type)

Check warning on line 33 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7432: Return type should be None in test_get_interfaces_dual_nic_default_prioritized (hass-return-type)

Check warning on line 33 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7431: Argument hass should be of type HomeAssistant in test_get_interfaces_dual_nic_default_prioritized (hass-argument-type)

Check warning on line 33 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7432: Return type should be None in test_get_interfaces_dual_nic_default_prioritized (hass-return-type)
"""Test that default interface is first in list with multiple NICs."""
gateway = ConnectMotionGateway(hass)

with patch(
"homeassistant.components.motion_blinds.gateway.network.async_get_adapters",
return_value=MOCK_ADAPTERS_DUAL_NIC,
):
interfaces = await gateway.async_get_interfaces()

# Default interface (192.168.1.10) must be first
assert interfaces[0] == "192.168.1.10"


async def test_get_interfaces_single_nic(hass):

Check warning on line 47 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7431: Argument hass should be of type HomeAssistant in test_get_interfaces_single_nic (hass-argument-type)

Check warning on line 47 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7432: Return type should be None in test_get_interfaces_single_nic (hass-return-type)

Check warning on line 47 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7431: Argument hass should be of type HomeAssistant in test_get_interfaces_single_nic (hass-argument-type)

Check warning on line 47 in tests/components/motion_blinds/test_gateway.py

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7432: Return type should be None in test_get_interfaces_single_nic (hass-return-type)
"""Test that single NIC setup still works correctly."""
gateway = ConnectMotionGateway(hass)

with patch(
"homeassistant.components.motion_blinds.gateway.network.async_get_adapters",
return_value=MOCK_ADAPTERS_SINGLE_NIC,
):
interfaces = await gateway.async_get_interfaces()

assert interfaces[0] == "192.168.1.10"


async def test_check_interface_timeout_moves_to_next(hass):
"""Test that a timed out interface check moves to the next interface."""
gateway = ConnectMotionGateway(hass)
successful_interface = "192.168.1.10"

with patch(
"homeassistant.components.motion_blinds.gateway.network.async_get_adapters",
return_value=MOCK_ADAPTERS_DUAL_NIC,
), patch(
"homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast"
) as mock_multicast, patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway"
):
mock_instance = MagicMock()
mock_instance.Start_listen = AsyncMock()
mock_instance.Stop_listen = MagicMock()
mock_multicast.return_value = mock_instance

call_count = 0

async def check_interface_side_effect():
nonlocal call_count
call_count += 1
# First interface times out, second succeeds
if call_count == 1:
await asyncio.sleep(10) # Will be cancelled by timeout
return True

with patch.object(
gateway,
"check_interface",
side_effect=check_interface_side_effect,
):
Comment on lines +80 to +92
result = await gateway.async_check_interface("192.168.1.1", "testkey")

assert result == successful_interface


async def test_check_interface_found_on_first_try(hass):
"""Test that correct interface on first try returns immediately."""
gateway = ConnectMotionGateway(hass)

with patch(
"homeassistant.components.motion_blinds.gateway.network.async_get_adapters",
return_value=MOCK_ADAPTERS_DUAL_NIC,
), patch(
"homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast"
) as mock_multicast, patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway"
):
mock_instance = MagicMock()
mock_instance.Start_listen = AsyncMock()
mock_instance.Stop_listen = MagicMock()
mock_multicast.return_value = mock_instance

with patch.object(gateway, "check_interface", return_value=True):
result = await gateway.async_check_interface("192.168.1.1", "testkey")

# Should return the first (default) interface immediately
assert result == "192.168.1.10"


async def test_check_interface_none_working_falls_back(hass):
"""Test fallback to stored interface when none work."""
gateway = ConnectMotionGateway(hass, interface="0.0.0.0")

with patch(
"homeassistant.components.motion_blinds.gateway.network.async_get_adapters",
return_value=MOCK_ADAPTERS_DUAL_NIC,
), patch(
"homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast"
) as mock_multicast, patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway"
):
mock_instance = MagicMock()
mock_instance.Start_listen = AsyncMock()
mock_instance.Stop_listen = MagicMock()
mock_multicast.return_value = mock_instance

with patch.object(gateway, "check_interface", return_value=False):
result = await gateway.async_check_interface("192.168.1.1", "testkey")

# Should fall back to stored interface
assert result == "0.0.0.0"
Loading