From 15ed1096df0ceb7807f86c15bafc19d58208bf32 Mon Sep 17 00:00:00 2001 From: Martin <32802427+mstu01@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:30:02 +0200 Subject: [PATCH] Fix swallowed exceptions in action handlers for MJPEG IP Camera (#180353) --- homeassistant/components/mjpeg/camera.py | 36 ++++--- homeassistant/components/mjpeg/strings.json | 8 ++ tests/components/mjpeg/test_camera.py | 114 ++++++++++++++++++++ 3 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/components/mjpeg/test_camera.py diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index cd0c13f549a2..81e41971c1e1 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -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 diff --git a/homeassistant/components/mjpeg/strings.json b/homeassistant/components/mjpeg/strings.json index fa7861a8263d..e8ab9d50bde1 100644 --- a/homeassistant/components/mjpeg/strings.json +++ b/homeassistant/components/mjpeg/strings.json @@ -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%]", diff --git a/tests/components/mjpeg/test_camera.py b/tests/components/mjpeg/test_camera.py new file mode 100644 index 000000000000..d07b3fa48e24 --- /dev/null +++ b/tests/components/mjpeg/test_camera.py @@ -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