mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 14:17:45 +00:00
Add CameraCapabilities (#128455)
This commit is contained in:
parent
46ceccfbb3
commit
963829712d
@ -6,7 +6,7 @@ import asyncio
|
|||||||
import collections
|
import collections
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
from collections.abc import Awaitable, Callable, Coroutine
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import IntFlag
|
from enum import IntFlag
|
||||||
from functools import partial
|
from functools import partial
|
||||||
@ -18,7 +18,7 @@ from typing import Any, Final, final
|
|||||||
|
|
||||||
from aiohttp import hdrs, web
|
from aiohttp import hdrs, web
|
||||||
import attr
|
import attr
|
||||||
from propcache import cached_property
|
from propcache import cached_property, under_cached_property
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from webrtc_models import RTCIceServer
|
from webrtc_models import RTCIceServer
|
||||||
|
|
||||||
@ -177,6 +177,13 @@ class Image:
|
|||||||
content: bytes = attr.ib()
|
content: bytes = attr.ib()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraCapabilities:
|
||||||
|
"""Camera capabilities."""
|
||||||
|
|
||||||
|
frontend_stream_types: set[StreamType]
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
||||||
"""Request a stream for a camera entity."""
|
"""Request a stream for a camera entity."""
|
||||||
@ -352,6 +359,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
websocket_api.async_register_command(hass, ws_camera_stream)
|
websocket_api.async_register_command(hass, ws_camera_stream)
|
||||||
websocket_api.async_register_command(hass, websocket_get_prefs)
|
websocket_api.async_register_command(hass, websocket_get_prefs)
|
||||||
websocket_api.async_register_command(hass, websocket_update_prefs)
|
websocket_api.async_register_command(hass, websocket_update_prefs)
|
||||||
|
websocket_api.async_register_command(hass, ws_camera_capabilities)
|
||||||
async_register_ws(hass)
|
async_register_ws(hass)
|
||||||
|
|
||||||
await component.async_setup(config)
|
await component.async_setup(config)
|
||||||
@ -463,6 +471,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize a camera."""
|
"""Initialize a camera."""
|
||||||
|
self._cache: dict[str, Any] = {}
|
||||||
self.stream: Stream | None = None
|
self.stream: Stream | None = None
|
||||||
self.stream_options: dict[str, str | bool | float] = {}
|
self.stream_options: dict[str, str | bool | float] = {}
|
||||||
self.content_type: str = DEFAULT_CONTENT_TYPE
|
self.content_type: str = DEFAULT_CONTENT_TYPE
|
||||||
@ -791,6 +800,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
|
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
|
||||||
self._webrtc_provider = new_provider
|
self._webrtc_provider = new_provider
|
||||||
self._legacy_webrtc_provider = new_legacy_provider
|
self._legacy_webrtc_provider = new_legacy_provider
|
||||||
|
self._invalidate_camera_capabilities_cache()
|
||||||
if write_state:
|
if write_state:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@ -840,6 +850,31 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
if self._webrtc_provider:
|
if self._webrtc_provider:
|
||||||
self._webrtc_provider.async_close_session(session_id)
|
self._webrtc_provider.async_close_session(session_id)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _invalidate_camera_capabilities_cache(self) -> None:
|
||||||
|
"""Invalidate the camera capabilities cache."""
|
||||||
|
self._cache.pop("camera_capabilities", None)
|
||||||
|
|
||||||
|
@final
|
||||||
|
@under_cached_property
|
||||||
|
def camera_capabilities(self) -> CameraCapabilities:
|
||||||
|
"""Return the camera capabilities."""
|
||||||
|
frontend_stream_types = set()
|
||||||
|
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||||
|
if (
|
||||||
|
type(self).async_handle_web_rtc_offer
|
||||||
|
!= Camera.async_handle_web_rtc_offer
|
||||||
|
):
|
||||||
|
# The camera has a native WebRTC implementation
|
||||||
|
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||||
|
else:
|
||||||
|
frontend_stream_types.add(StreamType.HLS)
|
||||||
|
|
||||||
|
if self._webrtc_provider:
|
||||||
|
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||||
|
|
||||||
|
return CameraCapabilities(frontend_stream_types)
|
||||||
|
|
||||||
|
|
||||||
class CameraView(HomeAssistantView):
|
class CameraView(HomeAssistantView):
|
||||||
"""Base CameraView."""
|
"""Base CameraView."""
|
||||||
@ -930,6 +965,24 @@ class CameraMjpegStream(CameraView):
|
|||||||
raise web.HTTPBadRequest from err
|
raise web.HTTPBadRequest from err
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "camera/capabilities",
|
||||||
|
vol.Required("entity_id"): cv.entity_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_camera_capabilities(
|
||||||
|
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Handle get camera capabilities websocket command.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
camera = get_camera_from_entity_id(hass, msg["entity_id"])
|
||||||
|
connection.send_result(msg["id"], asdict(camera.camera_capabilities))
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "camera/stream",
|
vol.Required("type"): "camera/stream",
|
||||||
|
@ -6,6 +6,13 @@ components. Instead call the service directly.
|
|||||||
|
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from homeassistant.components.camera import Camera
|
||||||
|
from homeassistant.components.camera.webrtc import (
|
||||||
|
CameraWebRTCProvider,
|
||||||
|
async_register_webrtc_provider,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
EMPTY_8_6_JPEG = b"empty_8_6"
|
EMPTY_8_6_JPEG = b"empty_8_6"
|
||||||
WEBRTC_ANSWER = "a=sendonly"
|
WEBRTC_ANSWER = "a=sendonly"
|
||||||
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
||||||
@ -23,3 +30,25 @@ def mock_turbo_jpeg(
|
|||||||
mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG
|
mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG
|
||||||
mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG
|
mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG
|
||||||
return mocked_turbo_jpeg
|
return mocked_turbo_jpeg
|
||||||
|
|
||||||
|
|
||||||
|
async def add_webrtc_provider(hass: HomeAssistant) -> CameraWebRTCProvider:
|
||||||
|
"""Add test WebRTC provider."""
|
||||||
|
|
||||||
|
class SomeTestProvider(CameraWebRTCProvider):
|
||||||
|
"""Test provider."""
|
||||||
|
|
||||||
|
async def async_is_supported(self, stream_source: str) -> bool:
|
||||||
|
"""Determine if the provider supports the stream source."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_handle_web_rtc_offer(
|
||||||
|
self, camera: Camera, offer_sdp: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Handle the WebRTC offer and return an answer."""
|
||||||
|
return "answer"
|
||||||
|
|
||||||
|
provider = SomeTestProvider()
|
||||||
|
async_register_webrtc_provider(hass, provider)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
return provider
|
||||||
|
@ -13,8 +13,11 @@ from homeassistant.components.camera.const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PREF_ORIENTATION,
|
PREF_ORIENTATION,
|
||||||
PREF_PRELOAD_STREAM,
|
PREF_PRELOAD_STREAM,
|
||||||
|
StreamType,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.camera.helper import get_camera_from_entity_id
|
||||||
from homeassistant.components.websocket_api import TYPE_RESULT
|
from homeassistant.components.websocket_api import TYPE_RESULT
|
||||||
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
EVENT_HOMEASSISTANT_STARTED,
|
EVENT_HOMEASSISTANT_STARTED,
|
||||||
@ -27,12 +30,24 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
|||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg
|
from .common import (
|
||||||
|
EMPTY_8_6_JPEG,
|
||||||
|
STREAM_SOURCE,
|
||||||
|
WEBRTC_ANSWER,
|
||||||
|
add_webrtc_provider,
|
||||||
|
mock_turbo_jpeg,
|
||||||
|
)
|
||||||
|
|
||||||
from tests.common import (
|
from tests.common import (
|
||||||
|
MockConfigEntry,
|
||||||
|
MockModule,
|
||||||
async_fire_time_changed,
|
async_fire_time_changed,
|
||||||
help_test_all,
|
help_test_all,
|
||||||
import_and_test_deprecated_constant_enum,
|
import_and_test_deprecated_constant_enum,
|
||||||
|
mock_config_flow,
|
||||||
|
mock_integration,
|
||||||
|
mock_platform,
|
||||||
|
setup_test_component_platform,
|
||||||
)
|
)
|
||||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||||
|
|
||||||
@ -885,3 +900,114 @@ async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -
|
|||||||
new_entity_picture = camera_state.attributes["entity_picture"]
|
new_entity_picture = camera_state.attributes["entity_picture"]
|
||||||
assert new_entity_picture != original_picture
|
assert new_entity_picture != original_picture
|
||||||
assert "token=" in new_entity_picture
|
assert "token=" in new_entity_picture
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_capabilities(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: WebSocketGenerator,
|
||||||
|
entity_id: str,
|
||||||
|
expected_stream_types: set[StreamType],
|
||||||
|
expected_stream_types_with_webrtc_provider: set[StreamType],
|
||||||
|
) -> None:
|
||||||
|
"""Test camera capabilities."""
|
||||||
|
await async_setup_component(hass, "camera", {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
async def test(expected_types: set[StreamType]) -> None:
|
||||||
|
camera_obj = get_camera_from_entity_id(hass, entity_id)
|
||||||
|
capabilities = camera_obj.camera_capabilities
|
||||||
|
assert capabilities == camera.CameraCapabilities(expected_types)
|
||||||
|
|
||||||
|
# Request capabilities through WebSocket
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "camera/capabilities", "entity_id": entity_id}
|
||||||
|
)
|
||||||
|
msg = await client.receive_json()
|
||||||
|
|
||||||
|
# Assert WebSocket response
|
||||||
|
assert msg["type"] == TYPE_RESULT
|
||||||
|
assert msg["success"]
|
||||||
|
assert msg["result"] == {"frontend_stream_types": list(expected_types)}
|
||||||
|
|
||||||
|
await test(expected_stream_types)
|
||||||
|
|
||||||
|
# Test with WebRTC provider
|
||||||
|
await add_webrtc_provider(hass)
|
||||||
|
await test(expected_stream_types_with_webrtc_provider)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
|
||||||
|
async def test_camera_capabilities_hls(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: WebSocketGenerator,
|
||||||
|
) -> None:
|
||||||
|
"""Test HLS camera capabilities."""
|
||||||
|
await _test_capabilities(
|
||||||
|
hass,
|
||||||
|
hass_ws_client,
|
||||||
|
"camera.demo_camera",
|
||||||
|
{StreamType.HLS},
|
||||||
|
{StreamType.HLS, StreamType.WEB_RTC},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_capabilities_webrtc(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: WebSocketGenerator,
|
||||||
|
) -> None:
|
||||||
|
"""Test WebRTC camera capabilities."""
|
||||||
|
|
||||||
|
# Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer
|
||||||
|
# Camera capabilities are determined by by checking if the function was overwritten(implemented) or not
|
||||||
|
class MockCamera(camera.Camera):
|
||||||
|
"""Mock Camera Entity."""
|
||||||
|
|
||||||
|
_attr_name = "Test"
|
||||||
|
_attr_supported_features: camera.CameraEntityFeature = (
|
||||||
|
camera.CameraEntityFeature.STREAM
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream_source(self) -> str | None:
|
||||||
|
return STREAM_SOURCE
|
||||||
|
|
||||||
|
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
|
||||||
|
return WEBRTC_ANSWER
|
||||||
|
|
||||||
|
domain = "test"
|
||||||
|
|
||||||
|
entry = MockConfigEntry(domain=domain)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
async def async_setup_entry_init(
|
||||||
|
hass: HomeAssistant, config_entry: ConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Set up test config entry."""
|
||||||
|
await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN])
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_unload_entry_init(
|
||||||
|
hass: HomeAssistant, config_entry: ConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Unload test config entry."""
|
||||||
|
await hass.config_entries.async_forward_entry_unload(config_entry, DOMAIN)
|
||||||
|
return True
|
||||||
|
|
||||||
|
mock_integration(
|
||||||
|
hass,
|
||||||
|
MockModule(
|
||||||
|
domain,
|
||||||
|
async_setup_entry=async_setup_entry_init,
|
||||||
|
async_unload_entry=async_unload_entry_init,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setup_test_component_platform(hass, DOMAIN, [MockCamera()], from_config_entry=True)
|
||||||
|
mock_platform(hass, f"{domain}.config_flow", Mock())
|
||||||
|
|
||||||
|
with mock_config_flow(domain, ConfigFlow):
|
||||||
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
await _test_capabilities(
|
||||||
|
hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user