diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 48eed28f4b2b..c42d242f8df7 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -26,6 +26,7 @@ from .const import ( CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DEFAULT_ENABLE_WEBHOOKS, + SNAPSHOT_TIMEOUT, ) from .device import ONVIFConfigEntry, ONVIFDevice @@ -131,7 +132,11 @@ async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: for basic_auth in (False, True): method = HTTP_BASIC_AUTHENTICATION if basic_auth else HTTP_DIGEST_AUTHENTICATION with suppress(ONVIFError): - if await device.device.get_snapshot(device.profiles[0].token, basic_auth): + if await device.device.get_snapshot( + device.profiles[0].token, + basic_auth, + timeout=SNAPSHOT_TIMEOUT.total_seconds(), + ): return method return None diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index 1de759785827..987d748cc0c1 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -1,6 +1,7 @@ """Constants for the onvif component.""" import asyncio +from datetime import timedelta import logging import aiohttp @@ -21,6 +22,10 @@ CONF_ENABLE_WEBHOOKS = "enable_webhooks" CONF_MORE_OPTIONS = "more_options" DEFAULT_ENABLE_WEBHOOKS = True +# Bounds each snapshot auth probe during setup, tighter than the library's +# default so a camera whose snapshot never completes does not stall setup +SNAPSHOT_TIMEOUT = timedelta(seconds=10) + ATTR_PAN = "pan" ATTR_TILT = "tilt" ATTR_ZOOM = "zoom" diff --git a/tests/components/onvif/test_init.py b/tests/components/onvif/test_init.py index 8f9b11d90f9b..c89e5f41a080 100644 --- a/tests/components/onvif/test_init.py +++ b/tests/components/onvif/test_init.py @@ -1,8 +1,11 @@ """Tests for the ONVIF integration __init__ module.""" +from types import SimpleNamespace from unittest.mock import MagicMock, patch -from homeassistant.components.onvif.const import DOMAIN +from onvif.exceptions import ONVIFTimeoutError + +from homeassistant.components.onvif.const import CONF_SNAPSHOT_AUTH, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -145,3 +148,39 @@ async def test_setup_entry(hass: HomeAssistant) -> None: mock_onvif_camera_cls.assert_called_once() host, port, username, password = mock_onvif_camera_cls.call_args.args[:4] assert (host, port, username, password) == (HOST, PORT, USERNAME, PASSWORD) + + +async def test_setup_entry_snapshot_probe_timeout(hass: HomeAssistant) -> None: + """Test a snapshot probe that times out does not hold up setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=NAME, + unique_id=MAC, + data={ + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.onvif.device.ONVIFCamera") as mock_camera_cls: + setup_mock_onvif_camera(mock_camera_cls, with_full_setup=True) + media_service = mock_camera_cls.create_media_service.return_value + media_service.GetServiceCapabilities.return_value = SimpleNamespace( + SnapshotUri=True + ) + mock_camera_cls.get_snapshot.side_effect = ONVIFTimeoutError("Timed out") + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + # both auth methods are probed, each with the tighter setup timeout + assert [ + call.kwargs["timeout"] for call in mock_camera_cls.get_snapshot.call_args_list + ] == [10, 10] + # nothing learned about the snapshot auth, so setup will probe again next time + assert CONF_SNAPSHOT_AUTH not in entry.data