diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 3e8df99e0aa..f21769b24f2 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -5,7 +5,8 @@ from types import MappingProxyType from typing import Any import aiohttp -from motioneye_client.client import MotionEyeClient +from jinja2 import Template +from motioneye_client.client import MotionEyeClient, MotionEyeClientURLParseError from motioneye_client.const import ( DEFAULT_SURVEILLANCE_USERNAME, KEY_MOTION_DETECTION, @@ -41,6 +42,7 @@ from . import ( from .const import ( CONF_CLIENT, CONF_COORDINATOR, + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, DOMAIN, @@ -129,11 +131,24 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): ): auth = camera[KEY_STREAMING_AUTH_MODE] + streaming_template = self._options.get(CONF_STREAM_URL_TEMPLATE, "").strip() + streaming_url = None + + if streaming_template: + # Note: Can't use homeassistant.helpers.template as it requires hass + # which is not available during entity construction. + streaming_url = Template(streaming_template).render(**camera) + else: + try: + streaming_url = self._client.get_camera_stream_url(camera) + except MotionEyeClientURLParseError: + pass + return { CONF_NAME: camera[KEY_NAME], CONF_USERNAME: self._surveillance_username if auth is not None else None, CONF_PASSWORD: self._surveillance_password if auth is not None else None, - CONF_MJPEG_URL: self._client.get_camera_stream_url(camera) or "", + CONF_MJPEG_URL: streaming_url or "", CONF_STILL_IMAGE_URL: self._client.get_camera_snapshot_url(camera), CONF_AUTHENTICATION: auth, } diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index a767cf7ecad..54b4a9f0b09 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -26,6 +26,7 @@ from . import create_motioneye_client from .const import ( CONF_ADMIN_PASSWORD, CONF_ADMIN_USERNAME, + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, CONF_WEBHOOK_SET, @@ -218,4 +219,19 @@ class MotionEyeOptionsFlow(OptionsFlow): ): bool, } + if self.show_advanced_options: + # The input URL is not validated as being a URL, to allow for the possibility + # the template input won't be a valid URL until after it's rendered. + schema.update( + { + vol.Required( + CONF_STREAM_URL_TEMPLATE, + default=self._config_entry.options.get( + CONF_STREAM_URL_TEMPLATE, + "", + ), + ): str + } + ) + return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) diff --git a/homeassistant/components/motioneye/const.py b/homeassistant/components/motioneye/const.py index 41fb2c18d63..f9f25a3b7ee 100644 --- a/homeassistant/components/motioneye/const.py +++ b/homeassistant/components/motioneye/const.py @@ -32,6 +32,7 @@ CONF_CLIENT: Final = "client" CONF_COORDINATOR: Final = "coordinator" CONF_ADMIN_PASSWORD: Final = "admin_password" CONF_ADMIN_USERNAME: Final = "admin_username" +CONF_STREAM_URL_TEMPLATE: Final = "stream_url_template" CONF_SURVEILLANCE_USERNAME: Final = "surveillance_username" CONF_SURVEILLANCE_PASSWORD: Final = "surveillance_password" CONF_WEBHOOK_SET: Final = "webhook_set" diff --git a/homeassistant/components/motioneye/strings.json b/homeassistant/components/motioneye/strings.json index 9763e1caf34..0f17699e652 100644 --- a/homeassistant/components/motioneye/strings.json +++ b/homeassistant/components/motioneye/strings.json @@ -31,9 +31,10 @@ "init": { "data": { "webhook_set": "Configure motionEye webhooks to report events to Home Assistant", - "webhook_set_overwrite": "Overwrite unrecognized webhooks" + "webhook_set_overwrite": "Overwrite unrecognized webhooks", + "stream_url_template": "Stream URL template" } } } } -} +} \ No newline at end of file diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index b2264e78556..c14290afdde 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -8,6 +8,7 @@ from aiohttp.web_exceptions import HTTPBadGateway from motioneye_client.client import ( MotionEyeClientError, MotionEyeClientInvalidAuthError, + MotionEyeClientURLParseError, ) from motioneye_client.const import ( KEY_CAMERAS, @@ -20,6 +21,7 @@ import pytest from homeassistant.components.camera import async_get_image, async_get_mjpeg_stream from homeassistant.components.motioneye import get_motioneye_device_identifier from homeassistant.components.motioneye.const import ( + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_USERNAME, DEFAULT_SCAN_INTERVAL, DOMAIN, @@ -320,3 +322,60 @@ async def test_device_info(hass: HomeAssistant) -> None: for entry in er.async_entries_for_device(entity_registry, device.id) ] assert TEST_CAMERA_ENTITY_ID in entities_from_device + + +async def test_camera_option_stream_url_template( + aiohttp_server: Any, hass: HomeAssistant +) -> None: + """Verify camera with a stream URL template option.""" + client = create_mock_motioneye_client() + + stream_handler = Mock(return_value="") + app = web.Application() + app.add_routes([web.get(f"/{TEST_CAMERA_NAME}/{TEST_CAMERA_ID}", stream_handler)]) + stream_server = await aiohttp_server(app) + + client = create_mock_motioneye_client() + + config_entry = create_mock_motioneye_config_entry( + hass, + data={ + CONF_URL: f"http://localhost:{stream_server.port}", + # The port won't be used as the client is a mock. + CONF_SURVEILLANCE_USERNAME: TEST_SURVEILLANCE_USERNAME, + }, + options={ + CONF_STREAM_URL_TEMPLATE: ( + f"http://localhost:{stream_server.port}/" "{{ name }}/{{ id }}" + ) + }, + ) + + await setup_mock_motioneye_config_entry( + hass, config_entry=config_entry, client=client + ) + await hass.async_block_till_done() + + # It won't actually get a stream from the dummy handler, so just catch + # the expected exception, then verify the right handler was called. + with pytest.raises(HTTPBadGateway): + await async_get_mjpeg_stream(hass, Mock(), TEST_CAMERA_ENTITY_ID) + assert stream_handler.called + assert not client.get_camera_stream_url.called + + +async def test_get_stream_from_camera_with_broken_host( + aiohttp_server: Any, hass: HomeAssistant +) -> None: + """Test getting a stream with a broken URL (no host).""" + + client = create_mock_motioneye_client() + config_entry = create_mock_motioneye_config_entry(hass, data={CONF_URL: "http://"}) + client.get_camera_stream_url = Mock(side_effect=MotionEyeClientURLParseError) + + await setup_mock_motioneye_config_entry( + hass, config_entry=config_entry, client=client + ) + await hass.async_block_till_done() + with pytest.raises(HTTPBadGateway): + await async_get_mjpeg_stream(hass, Mock(), TEST_CAMERA_ENTITY_ID) diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 591bbaa4c7d..878795a5a70 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.motioneye.const import ( CONF_ADMIN_PASSWORD, CONF_ADMIN_USERNAME, + CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, CONF_WEBHOOK_SET, @@ -460,3 +461,39 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"][CONF_WEBHOOK_SET] assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] + assert CONF_STREAM_URL_TEMPLATE not in result["data"] + + +async def test_advanced_options(hass: HomeAssistant) -> None: + """Check an options flow with advanced options.""" + + config_entry = create_mock_motioneye_config_entry(hass) + + mock_client = create_mock_motioneye_client() + with patch( + "homeassistant.components.motioneye.MotionEyeClient", + return_value=mock_client, + ) as mock_setup, patch( + "homeassistant.components.motioneye.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_WEBHOOK_SET: True, + CONF_WEBHOOK_SET_OVERWRITE: True, + CONF_STREAM_URL_TEMPLATE: "http://moo", + }, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_WEBHOOK_SET] + assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] + assert result["data"][CONF_STREAM_URL_TEMPLATE] == "http://moo" + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0