Split timeout in lutron_caseta to increase configure timeout (#138875)

This commit is contained in:
J. Nick Koston 2025-03-12 00:59:36 -10:00 committed by GitHub
parent 2f1ff5ab95
commit 06019e7995
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 143 additions and 70 deletions

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
from itertools import chain from itertools import chain
import logging import logging
import ssl import ssl
@ -37,11 +36,12 @@ from .const import (
ATTR_SERIAL, ATTR_SERIAL,
ATTR_TYPE, ATTR_TYPE,
BRIDGE_DEVICE_ID, BRIDGE_DEVICE_ID,
BRIDGE_TIMEOUT,
CONF_CA_CERTS, CONF_CA_CERTS,
CONF_CERTFILE, CONF_CERTFILE,
CONF_KEYFILE, CONF_KEYFILE,
CONF_SUBTYPE, CONF_SUBTYPE,
CONFIGURE_TIMEOUT,
CONNECT_TIMEOUT,
DOMAIN, DOMAIN,
LUTRON_CASETA_BUTTON_EVENT, LUTRON_CASETA_BUTTON_EVENT,
MANUFACTURER, MANUFACTURER,
@ -161,28 +161,40 @@ async def async_setup_entry(
keyfile = hass.config.path(entry.data[CONF_KEYFILE]) keyfile = hass.config.path(entry.data[CONF_KEYFILE])
certfile = hass.config.path(entry.data[CONF_CERTFILE]) certfile = hass.config.path(entry.data[CONF_CERTFILE])
ca_certs = hass.config.path(entry.data[CONF_CA_CERTS]) ca_certs = hass.config.path(entry.data[CONF_CA_CERTS])
bridge = None connected_future: asyncio.Future[None] = hass.loop.create_future()
def _on_connect() -> None:
nonlocal connected_future
if not connected_future.done():
connected_future.set_result(None)
try: try:
bridge = Smartbridge.create_tls( bridge = Smartbridge.create_tls(
hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs hostname=host,
keyfile=keyfile,
certfile=certfile,
ca_certs=ca_certs,
on_connect_callback=_on_connect,
) )
except ssl.SSLError: except ssl.SSLError:
_LOGGER.error("Invalid certificate used to connect to bridge at %s", host) _LOGGER.error("Invalid certificate used to connect to bridge at %s", host)
return False return False
timed_out = True connect_task = hass.async_create_task(bridge.connect())
with contextlib.suppress(TimeoutError): for future, name, timeout in (
async with asyncio.timeout(BRIDGE_TIMEOUT): (connected_future, "connect", CONNECT_TIMEOUT),
await bridge.connect() (connect_task, "configure", CONFIGURE_TIMEOUT),
timed_out = False ):
try:
if timed_out or not bridge.is_connected(): async with asyncio.timeout(timeout):
await future
except TimeoutError as ex:
connect_task.cancel()
await bridge.close() await bridge.close()
if timed_out: raise ConfigEntryNotReady(f"Timed out on {name} for {host}") from ex
raise ConfigEntryNotReady(f"Timed out while trying to connect to {host}")
if not bridge.is_connected(): if not bridge.is_connected():
raise ConfigEntryNotReady(f"Cannot connect to {host}") raise ConfigEntryNotReady(f"Connection failed to {host}")
_LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host) _LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host)
await _async_migrate_unique_ids(hass, entry) await _async_migrate_unique_ids(hass, entry)

View File

@ -20,10 +20,11 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import ( from .const import (
ABORT_REASON_CANNOT_CONNECT, ABORT_REASON_CANNOT_CONNECT,
BRIDGE_DEVICE_ID, BRIDGE_DEVICE_ID,
BRIDGE_TIMEOUT,
CONF_CA_CERTS, CONF_CA_CERTS,
CONF_CERTFILE, CONF_CERTFILE,
CONF_KEYFILE, CONF_KEYFILE,
CONFIGURE_TIMEOUT,
CONNECT_TIMEOUT,
DOMAIN, DOMAIN,
ERROR_CANNOT_CONNECT, ERROR_CANNOT_CONNECT,
STEP_IMPORT_FAILED, STEP_IMPORT_FAILED,
@ -232,7 +233,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN):
return None return None
try: try:
async with asyncio.timeout(BRIDGE_TIMEOUT): async with asyncio.timeout(CONNECT_TIMEOUT + CONFIGURE_TIMEOUT):
await bridge.connect() await bridge.connect()
except TimeoutError: except TimeoutError:
_LOGGER.error( _LOGGER.error(

View File

@ -34,7 +34,8 @@ ACTION_RELEASE = "release"
CONF_SUBTYPE = "subtype" CONF_SUBTYPE = "subtype"
BRIDGE_TIMEOUT = 35 CONNECT_TIMEOUT = 9
CONFIGURE_TIMEOUT = 50
UNASSIGNED_AREA = "Unassigned" UNASSIGNED_AREA = "Unassigned"

View File

@ -1,5 +1,8 @@
"""Tests for the Lutron Caseta integration.""" """Tests for the Lutron Caseta integration."""
import asyncio
from collections.abc import Callable
from typing import Any
from unittest.mock import patch from unittest.mock import patch
from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta import DOMAIN
@ -84,25 +87,12 @@ _LEAP_DEVICE_TYPES = {
} }
async def async_setup_integration(hass: HomeAssistant, mock_bridge) -> MockConfigEntry:
"""Set up a mock bridge."""
mock_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA)
mock_entry.add_to_hass(hass)
with patch(
"homeassistant.components.lutron_caseta.Smartbridge.create_tls"
) as create_tls:
create_tls.return_value = mock_bridge(can_connect=True)
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
return mock_entry
class MockBridge: class MockBridge:
"""Mock Lutron bridge that emulates configured connected status.""" """Mock Lutron bridge that emulates configured connected status."""
def __init__(self, can_connect=True) -> None: def __init__(self, can_connect=True, timeout_on_connect=False) -> None:
"""Initialize MockBridge instance with configured mock connectivity.""" """Initialize MockBridge instance with configured mock connectivity."""
self.timeout_on_connect = timeout_on_connect
self.can_connect = can_connect self.can_connect = can_connect
self.is_currently_connected = False self.is_currently_connected = False
self.areas = self.load_areas() self.areas = self.load_areas()
@ -113,6 +103,8 @@ class MockBridge:
async def connect(self): async def connect(self):
"""Connect the mock bridge.""" """Connect the mock bridge."""
if self.timeout_on_connect:
await asyncio.Event().wait() # wait forever
if self.can_connect: if self.can_connect:
self.is_currently_connected = True self.is_currently_connected = True
@ -320,3 +312,43 @@ class MockBridge:
async def close(self): async def close(self):
"""Close the mock bridge connection.""" """Close the mock bridge connection."""
self.is_currently_connected = False self.is_currently_connected = False
def make_mock_entry() -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA)
async def async_setup_integration(
hass: HomeAssistant,
mock_bridge: MockBridge,
config_entry_id: str | None = None,
can_connect: bool = True,
timeout_during_connect: bool = False,
timeout_during_configure: bool = False,
) -> MockConfigEntry:
"""Set up a mock bridge."""
if config_entry_id is None:
mock_entry = make_mock_entry()
mock_entry.add_to_hass(hass)
config_entry_id = mock_entry.entry_id
else:
mock_entry = hass.config_entries.async_get_entry(config_entry_id)
def create_tls_factory(
*args: Any, on_connect_callback: Callable[[], None], **kwargs: Any
) -> None:
"""Return a mock bridge."""
if not timeout_during_connect:
on_connect_callback()
return mock_bridge(
can_connect=can_connect, timeout_on_connect=timeout_during_configure
)
with patch(
"homeassistant.components.lutron_caseta.Smartbridge.create_tls",
create_tls_factory,
):
await hass.config_entries.async_setup(config_entry_id)
await hass.async_block_till_done()
return mock_entry

View File

@ -1,7 +1,5 @@
"""The tests for Lutron Caséta device triggers.""" """The tests for Lutron Caséta device triggers."""
from unittest.mock import patch
import pytest import pytest
from pytest_unordered import unordered from pytest_unordered import unordered
@ -37,7 +35,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import MockBridge from . import MockBridge, async_setup_integration
from tests.common import MockConfigEntry, async_get_device_automations from tests.common import MockConfigEntry, async_get_device_automations
@ -112,12 +110,7 @@ async def _async_setup_lutron_with_picos(hass: HomeAssistant) -> str:
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
with patch( await async_setup_integration(hass, MockBridge, config_entry.entry_id)
"homeassistant.components.lutron_caseta.Smartbridge.create_tls",
return_value=MockBridge(can_connect=True),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry.entry_id return config_entry.entry_id
@ -487,9 +480,7 @@ async def test_if_fires_on_button_event_late_setup(
}, },
) )
with patch("homeassistant.components.lutron_caseta.Smartbridge.create_tls"): await async_setup_integration(hass, MockBridge, config_entry_id)
await hass.config_entries.async_setup(config_entry_id)
await hass.async_block_till_done()
message = { message = {
ATTR_SERIAL: device.get("serial"), ATTR_SERIAL: device.get("serial"),

View File

@ -1,6 +1,6 @@
"""Test the Lutron Caseta diagnostics.""" """Test the Lutron Caseta diagnostics."""
from unittest.mock import ANY, patch from unittest.mock import ANY
from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta import DOMAIN
from homeassistant.components.lutron_caseta.const import ( from homeassistant.components.lutron_caseta.const import (
@ -11,7 +11,7 @@ from homeassistant.components.lutron_caseta.const import (
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import MockBridge from . import MockBridge, async_setup_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
@ -34,12 +34,7 @@ async def test_diagnostics(
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
with patch( await async_setup_integration(hass, MockBridge, config_entry.entry_id)
"homeassistant.components.lutron_caseta.Smartbridge.create_tls",
return_value=MockBridge(can_connect=True),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert diag == { assert diag == {

View File

@ -0,0 +1,54 @@
"""Tests for the Lutron Caseta integration."""
from unittest.mock import patch
import pytest
from homeassistant.components import lutron_caseta
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import MockBridge, async_setup_integration, make_mock_entry
@pytest.mark.parametrize(
("constant", "message", "timeout_during_connect", "timeout_during_configure"),
[
("CONNECT_TIMEOUT", "Timed out on connect", True, False),
("CONFIGURE_TIMEOUT", "Timed out on configure", False, True),
],
)
async def test_timeout_during_setup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
constant: str,
message: str,
timeout_during_connect: bool,
timeout_during_configure: bool,
) -> None:
"""Test a timeout during setup."""
mock_entry = make_mock_entry()
mock_entry.add_to_hass(hass)
with patch.object(lutron_caseta, constant, 0.001):
await async_setup_integration(
hass,
MockBridge,
config_entry_id=mock_entry.entry_id,
timeout_during_connect=timeout_during_connect,
timeout_during_configure=timeout_during_configure,
)
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert f"{message} for 1.1.1.1" in caplog.text
async def test_cannot_connect(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test failing to connect."""
mock_entry = make_mock_entry()
mock_entry.add_to_hass(hass)
await async_setup_integration(
hass, MockBridge, config_entry_id=mock_entry.entry_id, can_connect=False
)
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert "Connection failed to 1.1.1.1" in caplog.text

View File

@ -1,7 +1,5 @@
"""The tests for lutron caseta logbook.""" """The tests for lutron caseta logbook."""
from unittest.mock import patch
from homeassistant.components.lutron_caseta.const import ( from homeassistant.components.lutron_caseta.const import (
ATTR_ACTION, ATTR_ACTION,
ATTR_AREA_NAME, ATTR_AREA_NAME,
@ -43,13 +41,7 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None:
unique_id="abc", unique_id="abc",
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await async_setup_integration(hass, MockBridge, config_entry.entry_id)
with patch(
"homeassistant.components.lutron_caseta.Smartbridge.create_tls",
return_value=MockBridge(can_connect=True),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
await hass.async_block_till_done() await hass.async_block_till_done()
@ -104,12 +96,7 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded(
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
with patch( await async_setup_integration(hass, MockBridge, config_entry.entry_id)
"homeassistant.components.lutron_caseta.Smartbridge.create_tls",
return_value=MockBridge(can_connect=True),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id) await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()