core/tests/components/generic/test_camera.py

602 lines
20 KiB
Python
Raw Normal View History

"""The tests for generic camera component."""
import asyncio
from datetime import timedelta
from http import HTTPStatus
from typing import Any
2021-01-01 21:31:56 +00:00
from unittest.mock import patch
2021-11-04 15:07:50 +00:00
import aiohttp
from freezegun.api import FrozenDateTimeFactory
import httpx
2021-11-04 15:07:50 +00:00
import pytest
import respx
from homeassistant.components.camera import (
DEFAULT_CONTENT_TYPE,
async_get_mjpeg_stream,
async_get_stream_source,
)
from homeassistant.components.generic.const import (
CONF_CONTENT_TYPE,
CONF_FRAMERATE,
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
CONF_STILL_IMAGE_URL,
CONF_STREAM_SOURCE,
DOMAIN,
)
from homeassistant.components.stream import CONF_RTSP_TRANSPORT
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import Mock, MockConfigEntry
from tests.typing import ClientSessionGenerator, WebSocketGenerator
2021-11-02 03:47:05 +00:00
async def help_setup_mock_config_entry(
hass: HomeAssistant, options: dict[str, Any], unique_id: Any | None = None
) -> MockConfigEntry:
"""Help setting up a generic camera config entry."""
entry_options = {
CONF_STILL_IMAGE_URL: options.get(CONF_STILL_IMAGE_URL),
CONF_STREAM_SOURCE: options.get(CONF_STREAM_SOURCE),
CONF_AUTHENTICATION: options.get(CONF_AUTHENTICATION),
CONF_USERNAME: options.get(CONF_USERNAME),
CONF_PASSWORD: options.get(CONF_PASSWORD),
CONF_LIMIT_REFETCH_TO_URL_CHANGE: options.get(
CONF_LIMIT_REFETCH_TO_URL_CHANGE, False
),
CONF_CONTENT_TYPE: options.get(CONF_CONTENT_TYPE, DEFAULT_CONTENT_TYPE),
CONF_FRAMERATE: options.get(CONF_FRAMERATE, 2),
CONF_VERIFY_SSL: options.get(CONF_VERIFY_SSL),
}
entry = MockConfigEntry(
domain="generic",
title=options[CONF_NAME],
options=entry_options,
unique_id=unique_id,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
return entry
@respx.mock
async def test_fetching_url(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
fakeimgbytes_png: bytes,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that it fetches the given url."""
hass.states.async_set("sensor.temp", "http://example.com/0a")
respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)
respx.get("http://example.com/1a").respond(stream=fakeimgbytes_png)
2019-07-31 19:25:30 +00:00
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "{{ states.sensor.temp.state }}",
"username": "user",
"password": "pass",
"authentication": "basic",
"framerate": 20,
}
await help_setup_mock_config_entry(hass, options)
client = await hass_client()
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
assert respx.calls.call_count == 1
body = await resp.read()
assert body == fakeimgbytes_png
# sleep .1 seconds to make cached image expire
await asyncio.sleep(0.1)
resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == 2
# If the template renders to an invalid URL we return the last image from cache
hass.states.async_set("sensor.temp", "invalid url")
# sleep another .1 seconds to make cached image expire
await asyncio.sleep(0.1)
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
assert respx.calls.call_count == 2
assert (
"Invalid URL 'invalid url': expected a URL, returning last image" in caplog.text
)
# Restore a valid URL
hass.states.async_set("sensor.temp", "http://example.com/1a")
await asyncio.sleep(0.1)
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
assert respx.calls.call_count == 3
@respx.mock
async def test_image_caching(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
freezer: FrozenDateTimeFactory,
fakeimgbytes_png: bytes,
) -> None:
"""Test that the image is cached and not fetched more often than the framerate indicates."""
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
framerate = 5
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "http://example.com",
"username": "user",
"password": "pass",
"authentication": "basic",
"framerate": framerate,
}
await help_setup_mock_config_entry(hass, options)
client = await hass_client()
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_png
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_png
# time is frozen, image should have come from cache
assert respx.calls.call_count == 1
# advance time by 150ms
freezer.tick(timedelta(seconds=0.150))
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_png
# Only 150ms have passed, image should still have come from cache
assert respx.calls.call_count == 1
# advance time by another 150ms
freezer.tick(timedelta(seconds=0.150))
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_png
# 300ms have passed, now we should have fetched a new image
assert respx.calls.call_count == 2
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_png
# Still only 300ms have passed, should have returned the cached image
assert respx.calls.call_count == 2
@respx.mock
async def test_fetching_without_verify_ssl(
hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png: bytes
) -> None:
"""Test that it fetches the given url when ssl verify is off."""
respx.get("https://example.com").respond(stream=fakeimgbytes_png)
2019-07-31 19:25:30 +00:00
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "https://example.com",
"username": "user",
"password": "pass",
"verify_ssl": "false",
}
await help_setup_mock_config_entry(hass, options)
client = await hass_client()
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
@respx.mock
async def test_fetching_url_with_verify_ssl(
hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png: bytes
) -> None:
"""Test that it fetches the given url when ssl verify is explicitly on."""
respx.get("https://example.com").respond(stream=fakeimgbytes_png)
2019-07-31 19:25:30 +00:00
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "https://example.com",
"username": "user",
"password": "pass",
"verify_ssl": True,
}
await help_setup_mock_config_entry(hass, options)
client = await hass_client()
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
@respx.mock
async def test_limit_refetch(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
fakeimgbytes_png: bytes,
fakeimgbytes_jpg: bytes,
) -> None:
"""Test that it fetches the given url."""
respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)
respx.get("http://example.com/5a").respond(stream=fakeimgbytes_png)
respx.get("http://example.com/10a").respond(stream=fakeimgbytes_png)
respx.get("http://example.com/15a").respond(stream=fakeimgbytes_jpg)
respx.get("http://example.com/20a").respond(status_code=HTTPStatus.NOT_FOUND)
2019-07-31 19:25:30 +00:00
hass.states.async_set("sensor.temp", "0")
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
"limit_refetch_to_url_change": True,
}
await help_setup_mock_config_entry(hass, options)
client = await hass_client()
resp = await client.get("/api/camera_proxy/camera.config_test")
2019-07-31 19:25:30 +00:00
hass.states.async_set("sensor.temp", "5")
with (
pytest.raises(aiohttp.ServerTimeoutError),
patch("asyncio.timeout", side_effect=TimeoutError()),
2023-11-29 15:13:54 +00:00
):
resp = await client.get("/api/camera_proxy/camera.config_test")
2021-11-04 15:07:50 +00:00
assert respx.calls.call_count == 1
assert resp.status == HTTPStatus.OK
2019-07-31 19:25:30 +00:00
hass.states.async_set("sensor.temp", "10")
resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == 2
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_png
resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == 2
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_png
2019-07-31 19:25:30 +00:00
hass.states.async_set("sensor.temp", "15")
# Url change = fetch new image
resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == 3
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_jpg
# Cause a template render error
2019-07-31 19:25:30 +00:00
hass.states.async_remove("sensor.temp")
resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == 3
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == fakeimgbytes_jpg
@respx.mock
async def test_stream_source(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
fakeimgbytes_png: bytes,
) -> None:
"""Test that the stream source is rendered."""
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)
hass.states.async_set("sensor.temp", "0")
mock_entry = MockConfigEntry(
title="config_test",
domain=DOMAIN,
data={},
options={
CONF_STILL_IMAGE_URL: "http://example.com",
CONF_STREAM_SOURCE: 'http://example.com/{{ states.sensor.temp.state + "a" }}',
CONF_LIMIT_REFETCH_TO_URL_CHANGE: True,
CONF_FRAMERATE: 2,
CONF_CONTENT_TYPE: "image/png",
CONF_VERIFY_SSL: False,
CONF_USERNAME: "barney",
CONF_PASSWORD: "betty",
CONF_RTSP_TRANSPORT: "http",
},
)
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
assert await async_setup_component(hass, "stream", {})
await hass.async_block_till_done()
hass.states.async_set("sensor.temp", "5")
stream_source = await async_get_stream_source(hass, "camera.config_test")
assert stream_source == "http://barney:betty@example.com/5a"
with patch(
"homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8",
) as mock_stream_url:
# Request playlist through WebSocket
client = await hass_ws_client(hass)
await client.send_json(
{"id": 1, "type": "camera/stream", "entity_id": "camera.config_test"}
)
msg = await client.receive_json()
# Assert WebSocket response
assert mock_stream_url.call_count == 1
assert msg["id"] == 1
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"]["url"][-13:] == "playlist.m3u8"
@respx.mock
async def test_stream_source_error(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
fakeimgbytes_png: bytes,
) -> None:
"""Test that the stream source has an error."""
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "http://example.com",
# Does not exist
"stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
"limit_refetch_to_url_change": True,
}
await help_setup_mock_config_entry(hass, options)
assert await async_setup_component(hass, "stream", {})
await hass.async_block_till_done()
with patch(
"homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8",
) as mock_stream_url:
# Request playlist through WebSocket
client = await hass_ws_client(hass)
await client.send_json(
{"id": 1, "type": "camera/stream", "entity_id": "camera.config_test"}
)
msg = await client.receive_json()
# Assert WebSocket response
assert mock_stream_url.call_count == 0
assert msg["id"] == 1
assert msg["type"] == TYPE_RESULT
assert msg["success"] is False
assert msg["error"] == {
"code": "start_stream_failed",
"message": "camera.config_test does not support play stream service",
}
@respx.mock
async def test_setup_alternative_options(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, fakeimgbytes_png: bytes
) -> None:
"""Test that the stream source is setup with different config options."""
respx.get("https://example.com").respond(stream=fakeimgbytes_png)
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "https://example.com",
"authentication": "digest",
"username": "user",
"password": "pass",
"stream_source": "rtsp://example.com:554/rtsp/",
"rtsp_transport": "udp",
}
await help_setup_mock_config_entry(hass, options)
assert hass.states.get("camera.config_test")
@respx.mock
async def test_no_stream_source(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
fakeimgbytes_png: bytes,
) -> None:
"""Test a stream request without stream source option set."""
respx.get("https://example.com").respond(stream=fakeimgbytes_png)
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "https://example.com",
"limit_refetch_to_url_change": True,
}
await help_setup_mock_config_entry(hass, options)
with patch(
"homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8",
) as mock_request_stream:
# Request playlist through WebSocket
client = await hass_ws_client(hass)
await client.send_json(
{"id": 3, "type": "camera/stream", "entity_id": "camera.config_test"}
)
msg = await client.receive_json()
# Assert the websocket error message
assert mock_request_stream.call_count == 0
assert msg["id"] == 3
assert msg["type"] == TYPE_RESULT
assert msg["success"] is False
assert msg["error"] == {
"code": "start_stream_failed",
"message": "camera.config_test does not support play stream service",
}
@respx.mock
async def test_camera_content_type(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
fakeimgbytes_svg: bytes,
fakeimgbytes_jpg: bytes,
) -> None:
"""Test generic camera with custom content_type."""
2019-07-31 19:25:30 +00:00
urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg"
respx.get(urlsvg).respond(stream=fakeimgbytes_svg)
urljpg = "https://upload.wikimedia.org/wikipedia/commons/0/0e/Felis_silvestris_silvestris.jpg"
respx.get(urljpg).respond(stream=fakeimgbytes_jpg)
cam_config_svg = {
2019-07-31 19:25:30 +00:00
"name": "config_test_svg",
"platform": "generic",
"still_image_url": urlsvg,
"content_type": "image/svg+xml",
"limit_refetch_to_url_change": False,
"framerate": 2,
"verify_ssl": True,
}
cam_config_jpg = {
"name": "config_test_jpg",
"platform": "generic",
"still_image_url": urljpg,
"content_type": "image/jpeg",
"limit_refetch_to_url_change": False,
"framerate": 2,
"verify_ssl": True,
}
await help_setup_mock_config_entry(hass, cam_config_jpg, unique_id=12345)
await help_setup_mock_config_entry(hass, cam_config_svg, unique_id=54321)
client = await hass_client()
resp_1 = await client.get("/api/camera_proxy/camera.config_test_svg")
assert respx.calls.call_count == 1
assert resp_1.status == HTTPStatus.OK
2019-07-31 19:25:30 +00:00
assert resp_1.content_type == "image/svg+xml"
body = await resp_1.read()
assert body == fakeimgbytes_svg
resp_2 = await client.get("/api/camera_proxy/camera.config_test_jpg")
assert respx.calls.call_count == 2
assert resp_2.status == HTTPStatus.OK
2019-07-31 19:25:30 +00:00
assert resp_2.content_type == "image/jpeg"
body = await resp_2.read()
assert body == fakeimgbytes_jpg
@respx.mock
async def test_timeout_cancelled(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
fakeimgbytes_png: bytes,
fakeimgbytes_jpg: bytes,
) -> None:
"""Test that timeouts and cancellations return last image."""
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
options = {
"name": "config_test",
"platform": "generic",
"still_image_url": "http://example.com",
"username": "user",
"password": "pass",
"framerate": 20,
}
await help_setup_mock_config_entry(hass, options)
client = await hass_client()
resp = await client.get("/api/camera_proxy/camera.config_test")
assert resp.status == HTTPStatus.OK
assert respx.calls.call_count == 1
assert await resp.read() == fakeimgbytes_png
respx.get("http://example.com").respond(stream=fakeimgbytes_jpg)
with patch(
"homeassistant.components.generic.camera.GenericCamera.async_camera_image",
side_effect=asyncio.CancelledError(),
):
resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == 1
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
respx.get("http://example.com").side_effect = [
httpx.RequestError,
httpx.TimeoutException,
]
for total_calls in range(2, 4):
# sleep .1 seconds to make cached image expire
await asyncio.sleep(0.1)
resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == total_calls
assert resp.status == HTTPStatus.OK
assert await resp.read() == fakeimgbytes_png
async def test_frame_interval_property(hass: HomeAssistant) -> None:
"""Test that the frame interval is calculated and returned correctly."""
options = {
"name": "config_test",
"platform": "generic",
"stream_source": "rtsp://example.com:554/rtsp/",
"framerate": 5,
}
await help_setup_mock_config_entry(hass, options)
request = Mock()
with patch(
"homeassistant.components.camera.async_get_still_stream"
) as mock_get_stream:
await async_get_mjpeg_stream(hass, request, "camera.config_test")
assert mock_get_stream.call_args_list[0][0][3] == pytest.approx(0.2)