Support reverse proxying of motionEye streams (#53440)
parent
6e7fe13d51
commit
a2102deb64
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue