Fix swallowed exceptions in action handlers for MJPEG IP Camera (#180353)

This commit is contained in:
Martin
2026-08-29 20:14:00 +00:00
committed by Franck Nijhof
parent b80288542f
commit 15ed1096df
3 changed files with 146 additions and 12 deletions
+24 -12
View File
@@ -21,6 +21,7 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_web,
async_get_clientsession,
@@ -29,7 +30,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER
from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN
TIMEOUT = 10
BUFFER_SIZE = 102400
@@ -155,14 +156,19 @@ class MjpegCamera(Camera):
return await response.read()
# pylint: disable-next=home-assistant-action-swallowed-exception
except TimeoutError:
LOGGER.error("Timeout getting camera image from %s", self.name)
except TimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_getting_image",
translation_placeholders={"name": str(self.name)},
) from err
except aiohttp.ClientError as err:
LOGGER.error("Error getting new camera image from %s: %s", self.name, err)
return None
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_getting_image",
translation_placeholders={"name": str(self.name)},
) from err
def _get_httpx_auth(self) -> httpx.Auth:
"""Return a httpx auth object."""
@@ -192,13 +198,19 @@ class MjpegCamera(Camera):
stream.aiter_bytes(BUFFER_SIZE)
)
except TimeoutError:
LOGGER.error("Timeout getting camera image from %s", self.name)
except (TimeoutError, httpx.TimeoutException) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_getting_image",
translation_placeholders={"name": str(self.name)},
) from err
except httpx.HTTPError as err:
LOGGER.error("Error getting new camera image from %s: %s", self.name, err)
return None
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_getting_image",
translation_placeholders={"name": str(self.name)},
) from err
async def _handle_async_mjpeg_digest_stream(
self, request: web.Request
@@ -20,6 +20,14 @@
}
}
},
"exceptions": {
"error_getting_image": {
"message": "Error getting a new image from {name}."
},
"timeout_getting_image": {
"message": "Timeout getting an image from {name}."
}
},
"options": {
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+114
View File
@@ -0,0 +1,114 @@
"""Test the MJPEG IP Camera camera platform."""
import aiohttp
import httpx
import pytest
import respx
from homeassistant.components.camera import async_get_image
from homeassistant.components.mjpeg.const import (
CONF_MJPEG_URL,
CONF_STILL_IMAGE_URL,
DOMAIN,
)
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_PASSWORD,
CONF_USERNAME,
CONF_VERIFY_SSL,
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
ENTITY_CAMERA = "camera.my_mjpeg_camera"
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("exception", "translation_key"),
[
pytest.param(TimeoutError, "timeout_getting_image", id="timeout"),
pytest.param(aiohttp.ClientError, "error_getting_image", id="client_error"),
],
)
async def test_camera_image_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
exception: type[Exception],
translation_key: str,
) -> None:
"""Test that a failed still image request raises instead of returning nothing."""
aioclient_mock.get("http://example.com/still", exc=exception)
with pytest.raises(HomeAssistantError) as exc_info:
await async_get_image(hass, ENTITY_CAMERA)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == translation_key
@pytest.mark.usefixtures("init_integration")
async def test_camera_image(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that a still image is returned."""
aioclient_mock.get("http://example.com/still", content=b"image_bytes")
image = await async_get_image(hass, ENTITY_CAMERA)
assert image.content == b"image_bytes"
@pytest.fixture
def mock_digest_config_entry() -> MockConfigEntry:
"""Return a mocked config entry that uses digest authentication."""
return MockConfigEntry(
title="My MJPEG Camera",
domain=DOMAIN,
data={},
options={
CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION,
CONF_MJPEG_URL: "https://example.com/mjpeg",
CONF_PASSWORD: "supersecret",
CONF_STILL_IMAGE_URL: "http://example.com/still",
CONF_USERNAME: "frenck",
CONF_VERIFY_SSL: True,
},
)
@respx.mock
@pytest.mark.parametrize(
("exception", "translation_key"),
[
pytest.param(TimeoutError, "timeout_getting_image", id="timeout"),
pytest.param(
httpx.TimeoutException, "timeout_getting_image", id="httpx_timeout"
),
pytest.param(httpx.HTTPError, "error_getting_image", id="http_error"),
],
)
async def test_digest_camera_image_error(
hass: HomeAssistant,
mock_digest_config_entry: MockConfigEntry,
exception: type[Exception],
translation_key: str,
) -> None:
"""Test that a failed digest image request raises instead of returning nothing."""
mock_digest_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_digest_config_entry.entry_id)
await hass.async_block_till_done()
respx.get("http://example.com/still").mock(side_effect=exception("boom"))
respx.get("https://example.com/mjpeg").mock(side_effect=exception("boom"))
with pytest.raises(HomeAssistantError) as exc_info:
await async_get_image(hass, ENTITY_CAMERA)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == translation_key