Support reverse proxying of motionEye streams (#53440)

pull/58718/head
Dermot Duffy 2021-10-29 13:24:30 -07:00 committed by GitHub
parent 6e7fe13d51
commit a2102deb64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 133 additions and 4 deletions

View File

@ -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,
}

View File

@ -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))

View File

@ -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"

View File

@ -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"
}
}
}
}
}
}

View File

@ -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)

View File

@ -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