Support reverse proxying of motionEye streams (#53440)
parent
6e7fe13d51
commit
a2102deb64
|
@ -5,7 +5,8 @@ from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from motioneye_client.client import MotionEyeClient
|
from jinja2 import Template
|
||||||
|
from motioneye_client.client import MotionEyeClient, MotionEyeClientURLParseError
|
||||||
from motioneye_client.const import (
|
from motioneye_client.const import (
|
||||||
DEFAULT_SURVEILLANCE_USERNAME,
|
DEFAULT_SURVEILLANCE_USERNAME,
|
||||||
KEY_MOTION_DETECTION,
|
KEY_MOTION_DETECTION,
|
||||||
|
@ -41,6 +42,7 @@ from . import (
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_CLIENT,
|
CONF_CLIENT,
|
||||||
CONF_COORDINATOR,
|
CONF_COORDINATOR,
|
||||||
|
CONF_STREAM_URL_TEMPLATE,
|
||||||
CONF_SURVEILLANCE_PASSWORD,
|
CONF_SURVEILLANCE_PASSWORD,
|
||||||
CONF_SURVEILLANCE_USERNAME,
|
CONF_SURVEILLANCE_USERNAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
@ -129,11 +131,24 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera):
|
||||||
):
|
):
|
||||||
auth = camera[KEY_STREAMING_AUTH_MODE]
|
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 {
|
return {
|
||||||
CONF_NAME: camera[KEY_NAME],
|
CONF_NAME: camera[KEY_NAME],
|
||||||
CONF_USERNAME: self._surveillance_username if auth is not None else None,
|
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_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_STILL_IMAGE_URL: self._client.get_camera_snapshot_url(camera),
|
||||||
CONF_AUTHENTICATION: auth,
|
CONF_AUTHENTICATION: auth,
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ from . import create_motioneye_client
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_PASSWORD,
|
CONF_ADMIN_PASSWORD,
|
||||||
CONF_ADMIN_USERNAME,
|
CONF_ADMIN_USERNAME,
|
||||||
|
CONF_STREAM_URL_TEMPLATE,
|
||||||
CONF_SURVEILLANCE_PASSWORD,
|
CONF_SURVEILLANCE_PASSWORD,
|
||||||
CONF_SURVEILLANCE_USERNAME,
|
CONF_SURVEILLANCE_USERNAME,
|
||||||
CONF_WEBHOOK_SET,
|
CONF_WEBHOOK_SET,
|
||||||
|
@ -218,4 +219,19 @@ class MotionEyeOptionsFlow(OptionsFlow):
|
||||||
): bool,
|
): 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))
|
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_COORDINATOR: Final = "coordinator"
|
||||||
CONF_ADMIN_PASSWORD: Final = "admin_password"
|
CONF_ADMIN_PASSWORD: Final = "admin_password"
|
||||||
CONF_ADMIN_USERNAME: Final = "admin_username"
|
CONF_ADMIN_USERNAME: Final = "admin_username"
|
||||||
|
CONF_STREAM_URL_TEMPLATE: Final = "stream_url_template"
|
||||||
CONF_SURVEILLANCE_USERNAME: Final = "surveillance_username"
|
CONF_SURVEILLANCE_USERNAME: Final = "surveillance_username"
|
||||||
CONF_SURVEILLANCE_PASSWORD: Final = "surveillance_password"
|
CONF_SURVEILLANCE_PASSWORD: Final = "surveillance_password"
|
||||||
CONF_WEBHOOK_SET: Final = "webhook_set"
|
CONF_WEBHOOK_SET: Final = "webhook_set"
|
||||||
|
|
|
@ -31,7 +31,8 @@
|
||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"webhook_set": "Configure motionEye webhooks to report events to Home Assistant",
|
"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 (
|
from motioneye_client.client import (
|
||||||
MotionEyeClientError,
|
MotionEyeClientError,
|
||||||
MotionEyeClientInvalidAuthError,
|
MotionEyeClientInvalidAuthError,
|
||||||
|
MotionEyeClientURLParseError,
|
||||||
)
|
)
|
||||||
from motioneye_client.const import (
|
from motioneye_client.const import (
|
||||||
KEY_CAMERAS,
|
KEY_CAMERAS,
|
||||||
|
@ -20,6 +21,7 @@ import pytest
|
||||||
from homeassistant.components.camera import async_get_image, async_get_mjpeg_stream
|
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 import get_motioneye_device_identifier
|
||||||
from homeassistant.components.motioneye.const import (
|
from homeassistant.components.motioneye.const import (
|
||||||
|
CONF_STREAM_URL_TEMPLATE,
|
||||||
CONF_SURVEILLANCE_USERNAME,
|
CONF_SURVEILLANCE_USERNAME,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
DEFAULT_SCAN_INTERVAL,
|
||||||
DOMAIN,
|
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)
|
for entry in er.async_entries_for_device(entity_registry, device.id)
|
||||||
]
|
]
|
||||||
assert TEST_CAMERA_ENTITY_ID in entities_from_device
|
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 (
|
from homeassistant.components.motioneye.const import (
|
||||||
CONF_ADMIN_PASSWORD,
|
CONF_ADMIN_PASSWORD,
|
||||||
CONF_ADMIN_USERNAME,
|
CONF_ADMIN_USERNAME,
|
||||||
|
CONF_STREAM_URL_TEMPLATE,
|
||||||
CONF_SURVEILLANCE_PASSWORD,
|
CONF_SURVEILLANCE_PASSWORD,
|
||||||
CONF_SURVEILLANCE_USERNAME,
|
CONF_SURVEILLANCE_USERNAME,
|
||||||
CONF_WEBHOOK_SET,
|
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["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
assert result["data"][CONF_WEBHOOK_SET]
|
assert result["data"][CONF_WEBHOOK_SET]
|
||||||
assert result["data"][CONF_WEBHOOK_SET_OVERWRITE]
|
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