Add camera media source (#65977)
This commit is contained in:
parent
b216f6f448
commit
716a1e2a64
10 changed files with 432 additions and 109 deletions
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import collections
|
import collections
|
||||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
@ -26,7 +26,6 @@ from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
ATTR_MEDIA_CONTENT_ID,
|
ATTR_MEDIA_CONTENT_ID,
|
||||||
ATTR_MEDIA_CONTENT_TYPE,
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
ATTR_MEDIA_EXTRA,
|
|
||||||
DOMAIN as DOMAIN_MP,
|
DOMAIN as DOMAIN_MP,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
)
|
)
|
||||||
|
@ -49,7 +48,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
|
||||||
PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA,
|
||||||
PLATFORM_SCHEMA_BASE,
|
PLATFORM_SCHEMA_BASE,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.entity import Entity, EntityDescription, entity_sources
|
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.network import get_url
|
from homeassistant.helpers.network import get_url
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
@ -970,52 +969,18 @@ async def async_handle_play_stream_service(
|
||||||
camera: Camera, service_call: ServiceCall
|
camera: Camera, service_call: ServiceCall
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle play stream services calls."""
|
"""Handle play stream services calls."""
|
||||||
|
hass = camera.hass
|
||||||
fmt = service_call.data[ATTR_FORMAT]
|
fmt = service_call.data[ATTR_FORMAT]
|
||||||
url = await _async_stream_endpoint_url(camera.hass, camera, fmt)
|
url = await _async_stream_endpoint_url(camera.hass, camera, fmt)
|
||||||
|
url = f"{get_url(hass)}{url}"
|
||||||
|
|
||||||
hass = camera.hass
|
await hass.services.async_call(
|
||||||
data: Mapping[str, str] = {
|
DOMAIN_MP,
|
||||||
ATTR_MEDIA_CONTENT_ID: f"{get_url(hass)}{url}",
|
SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: service_call.data[ATTR_MEDIA_PLAYER],
|
||||||
|
ATTR_MEDIA_CONTENT_ID: url,
|
||||||
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt],
|
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt],
|
||||||
}
|
|
||||||
|
|
||||||
# It is required to send a different payload for cast media players
|
|
||||||
entity_ids = service_call.data[ATTR_MEDIA_PLAYER]
|
|
||||||
sources = entity_sources(hass)
|
|
||||||
cast_entity_ids = [
|
|
||||||
entity
|
|
||||||
for entity in entity_ids
|
|
||||||
# All entities should be in sources. This extra guard is to
|
|
||||||
# avoid people writing to the state machine and breaking it.
|
|
||||||
if entity in sources and sources[entity]["domain"] == "cast"
|
|
||||||
]
|
|
||||||
other_entity_ids = list(set(entity_ids) - set(cast_entity_ids))
|
|
||||||
|
|
||||||
if cast_entity_ids:
|
|
||||||
await hass.services.async_call(
|
|
||||||
DOMAIN_MP,
|
|
||||||
SERVICE_PLAY_MEDIA,
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: cast_entity_ids,
|
|
||||||
**data,
|
|
||||||
ATTR_MEDIA_EXTRA: {
|
|
||||||
"stream_type": "LIVE",
|
|
||||||
"media_info": {
|
|
||||||
"hlsVideoSegmentFormat": "fmp4",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
blocking=True,
|
|
||||||
context=service_call.context,
|
|
||||||
)
|
|
||||||
|
|
||||||
if other_entity_ids:
|
|
||||||
await hass.services.async_call(
|
|
||||||
DOMAIN_MP,
|
|
||||||
SERVICE_PLAY_MEDIA,
|
|
||||||
{
|
|
||||||
ATTR_ENTITY_ID: other_entity_ids,
|
|
||||||
**data,
|
|
||||||
},
|
},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
context=service_call.context,
|
context=service_call.context,
|
||||||
|
|
103
homeassistant/components/camera/media_source.py
Normal file
103
homeassistant/components/camera/media_source.py
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
"""Expose cameras as media sources."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, cast
|
||||||
|
|
||||||
|
from homeassistant.components.media_player.const import (
|
||||||
|
MEDIA_CLASS_APP,
|
||||||
|
MEDIA_CLASS_VIDEO,
|
||||||
|
)
|
||||||
|
from homeassistant.components.media_player.errors import BrowseError
|
||||||
|
from homeassistant.components.media_source.error import Unresolvable
|
||||||
|
from homeassistant.components.media_source.models import (
|
||||||
|
BrowseMediaSource,
|
||||||
|
MediaSource,
|
||||||
|
MediaSourceItem,
|
||||||
|
PlayMedia,
|
||||||
|
)
|
||||||
|
from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE, HLS_PROVIDER
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
|
||||||
|
from . import Camera, _async_stream_endpoint_url
|
||||||
|
from .const import DOMAIN, STREAM_TYPE_HLS
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource:
|
||||||
|
"""Set up camera media source."""
|
||||||
|
return CameraMediaSource(hass)
|
||||||
|
|
||||||
|
|
||||||
|
class CameraMediaSource(MediaSource):
|
||||||
|
"""Provide camera feeds as media sources."""
|
||||||
|
|
||||||
|
name: str = "Camera"
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize CameraMediaSource."""
|
||||||
|
super().__init__(DOMAIN)
|
||||||
|
self.hass = hass
|
||||||
|
|
||||||
|
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||||
|
"""Resolve media to a url."""
|
||||||
|
component: EntityComponent = self.hass.data[DOMAIN]
|
||||||
|
camera = cast(Optional[Camera], component.get_entity(item.identifier))
|
||||||
|
|
||||||
|
if not camera:
|
||||||
|
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
|
||||||
|
|
||||||
|
if camera.frontend_stream_type != STREAM_TYPE_HLS:
|
||||||
|
raise Unresolvable("Camera does not support HLS streaming.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER)
|
||||||
|
except HomeAssistantError as err:
|
||||||
|
raise Unresolvable(str(err)) from err
|
||||||
|
|
||||||
|
return PlayMedia(url, FORMAT_CONTENT_TYPE[HLS_PROVIDER])
|
||||||
|
|
||||||
|
async def async_browse_media(
|
||||||
|
self,
|
||||||
|
item: MediaSourceItem,
|
||||||
|
) -> BrowseMediaSource:
|
||||||
|
"""Return media."""
|
||||||
|
if item.identifier:
|
||||||
|
raise BrowseError("Unknown item")
|
||||||
|
|
||||||
|
if "stream" not in self.hass.config.components:
|
||||||
|
raise BrowseError("Stream integration is not loaded")
|
||||||
|
|
||||||
|
# Root. List cameras.
|
||||||
|
component: EntityComponent = self.hass.data[DOMAIN]
|
||||||
|
children = []
|
||||||
|
for camera in component.entities:
|
||||||
|
camera = cast(Camera, camera)
|
||||||
|
|
||||||
|
if camera.frontend_stream_type != STREAM_TYPE_HLS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
children.append(
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=camera.entity_id,
|
||||||
|
media_class=MEDIA_CLASS_VIDEO,
|
||||||
|
media_content_type=FORMAT_CONTENT_TYPE[HLS_PROVIDER],
|
||||||
|
title=camera.name,
|
||||||
|
thumbnail=f"/api/camera_proxy/{camera.entity_id}",
|
||||||
|
can_play=True,
|
||||||
|
can_expand=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=None,
|
||||||
|
media_class=MEDIA_CLASS_APP,
|
||||||
|
media_content_type="",
|
||||||
|
title="Camera",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
children_media_class=MEDIA_CLASS_VIDEO,
|
||||||
|
children=children,
|
||||||
|
)
|
|
@ -18,6 +18,7 @@ from pychromecast.socket_client import (
|
||||||
CONNECTION_STATUS_DISCONNECTED,
|
CONNECTION_STATUS_DISCONNECTED,
|
||||||
)
|
)
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
import yarl
|
||||||
|
|
||||||
from homeassistant.components import media_source, zeroconf
|
from homeassistant.components import media_source, zeroconf
|
||||||
from homeassistant.components.http.auth import async_sign_path
|
from homeassistant.components.http.auth import async_sign_path
|
||||||
|
@ -59,7 +60,7 @@ from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
from homeassistant.util.logging import async_create_catching_coro
|
from homeassistant.util.logging import async_create_catching_coro
|
||||||
|
|
||||||
|
@ -535,19 +536,6 @@ class CastDevice(MediaPlayerEntity):
|
||||||
media_type = sourced_media.mime_type
|
media_type = sourced_media.mime_type
|
||||||
media_id = sourced_media.url
|
media_id = sourced_media.url
|
||||||
|
|
||||||
# If media ID is a relative URL, we serve it from HA.
|
|
||||||
# Create a signed path.
|
|
||||||
if media_id[0] == "/":
|
|
||||||
media_id = async_sign_path(
|
|
||||||
self.hass,
|
|
||||||
quote(media_id),
|
|
||||||
timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME),
|
|
||||||
)
|
|
||||||
|
|
||||||
# prepend external URL
|
|
||||||
hass_url = get_url(self.hass, prefer_external=True)
|
|
||||||
media_id = f"{hass_url}{media_id}"
|
|
||||||
|
|
||||||
extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
|
extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
|
||||||
metadata = extra.get("metadata")
|
metadata = extra.get("metadata")
|
||||||
|
|
||||||
|
@ -593,6 +581,33 @@ class CastDevice(MediaPlayerEntity):
|
||||||
if result:
|
if result:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# If media ID is a relative URL, we serve it from HA.
|
||||||
|
# Create a signed path.
|
||||||
|
if media_id[0] == "/" or is_hass_url(self.hass, media_id):
|
||||||
|
parsed = yarl.URL(media_id)
|
||||||
|
# Configure play command for when playing a HLS stream
|
||||||
|
if parsed.path.startswith("/api/hls/"):
|
||||||
|
extra = {
|
||||||
|
**extra,
|
||||||
|
"stream_type": "LIVE",
|
||||||
|
"media_info": {
|
||||||
|
"hlsVideoSegmentFormat": "fmp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.query:
|
||||||
|
_LOGGER.debug("Not signing path for content with query param")
|
||||||
|
else:
|
||||||
|
media_id = async_sign_path(
|
||||||
|
self.hass,
|
||||||
|
quote(media_id),
|
||||||
|
timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME),
|
||||||
|
)
|
||||||
|
|
||||||
|
if media_id[0] == "/":
|
||||||
|
# prepend URL
|
||||||
|
media_id = f"{get_url(self.hass)}{media_id}"
|
||||||
|
|
||||||
# Default to play with the default media receiver
|
# Default to play with the default media receiver
|
||||||
app_data = {"media_id": media_id, "media_type": media_type, **extra}
|
app_data = {"media_id": media_id, "media_type": media_type, **extra}
|
||||||
await self.hass.async_add_executor_job(
|
await self.hass.async_add_executor_job(
|
||||||
|
|
|
@ -31,6 +31,48 @@ def is_internal_request(hass: HomeAssistant) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_hass_url(hass: HomeAssistant, url: str) -> bool:
|
||||||
|
"""Return if the URL points at this Home Assistant instance."""
|
||||||
|
parsed = yarl.URL(normalize_url(url))
|
||||||
|
|
||||||
|
def host_ip() -> str | None:
|
||||||
|
if hass.config.api is None or is_loopback(ip_address(hass.config.api.local_ip)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return str(
|
||||||
|
yarl.URL.build(
|
||||||
|
scheme="http", host=hass.config.api.local_ip, port=hass.config.api.port
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def cloud_url() -> str | None:
|
||||||
|
try:
|
||||||
|
return _get_cloud_url(hass)
|
||||||
|
except NoURLAvailableError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for potential_base_factory in (
|
||||||
|
lambda: hass.config.internal_url,
|
||||||
|
lambda: hass.config.external_url,
|
||||||
|
cloud_url,
|
||||||
|
host_ip,
|
||||||
|
):
|
||||||
|
potential_base = potential_base_factory()
|
||||||
|
|
||||||
|
if potential_base is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
potential_parsed = yarl.URL(normalize_url(potential_base))
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.scheme == potential_parsed.scheme
|
||||||
|
and parsed.authority == potential_parsed.authority
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
def get_url(
|
def get_url(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|
|
@ -8,6 +8,7 @@ from unittest.mock import Mock
|
||||||
from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM
|
from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM
|
||||||
|
|
||||||
EMPTY_8_6_JPEG = b"empty_8_6"
|
EMPTY_8_6_JPEG = b"empty_8_6"
|
||||||
|
WEBRTC_ANSWER = "a=sendonly"
|
||||||
|
|
||||||
|
|
||||||
def mock_camera_prefs(hass, entity_id, prefs=None):
|
def mock_camera_prefs(hass, entity_id, prefs=None):
|
||||||
|
|
53
tests/components/camera/conftest.py
Normal file
53
tests/components/camera/conftest.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
"""Test helpers for camera."""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components import camera
|
||||||
|
from homeassistant.components.camera.const import STREAM_TYPE_HLS, STREAM_TYPE_WEB_RTC
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from .common import WEBRTC_ANSWER
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="mock_camera")
|
||||||
|
async def mock_camera_fixture(hass):
|
||||||
|
"""Initialize a demo camera platform."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.demo.camera.Path.read_bytes",
|
||||||
|
return_value=b"Test",
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="mock_camera_hls")
|
||||||
|
async def mock_camera_hls_fixture(mock_camera):
|
||||||
|
"""Initialize a demo camera platform with HLS."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.camera.Camera.frontend_stream_type",
|
||||||
|
new_callable=PropertyMock(return_value=STREAM_TYPE_HLS),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="mock_camera_web_rtc")
|
||||||
|
async def mock_camera_web_rtc_fixture(hass):
|
||||||
|
"""Initialize a demo camera platform with WebRTC."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.camera.Camera.frontend_stream_type",
|
||||||
|
new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC),
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.camera.Camera.async_handle_web_rtc_offer",
|
||||||
|
return_value=WEBRTC_ANSWER,
|
||||||
|
):
|
||||||
|
yield
|
|
@ -8,11 +8,7 @@ from unittest.mock import Mock, PropertyMock, mock_open, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components import camera
|
from homeassistant.components import camera
|
||||||
from homeassistant.components.camera.const import (
|
from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM
|
||||||
DOMAIN,
|
|
||||||
PREF_PRELOAD_STREAM,
|
|
||||||
STREAM_TYPE_WEB_RTC,
|
|
||||||
)
|
|
||||||
from homeassistant.components.camera.prefs import CameraEntityPreferences
|
from homeassistant.components.camera.prefs import CameraEntityPreferences
|
||||||
from homeassistant.components.websocket_api.const import TYPE_RESULT
|
from homeassistant.components.websocket_api.const import TYPE_RESULT
|
||||||
from homeassistant.config import async_process_ha_core_config
|
from homeassistant.config import async_process_ha_core_config
|
||||||
|
@ -24,47 +20,11 @@ from homeassistant.const import (
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg
|
from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_camera_prefs, mock_turbo_jpeg
|
||||||
|
|
||||||
from tests.components.camera import common
|
|
||||||
|
|
||||||
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
||||||
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
|
HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
|
||||||
WEBRTC_OFFER = "v=0\r\n"
|
WEBRTC_OFFER = "v=0\r\n"
|
||||||
WEBRTC_ANSWER = "a=sendonly"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="mock_camera")
|
|
||||||
async def mock_camera_fixture(hass):
|
|
||||||
"""Initialize a demo camera platform."""
|
|
||||||
assert await async_setup_component(
|
|
||||||
hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
|
|
||||||
)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.demo.camera.Path.read_bytes",
|
|
||||||
return_value=b"Test",
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="mock_camera_web_rtc")
|
|
||||||
async def mock_camera_web_rtc_fixture(hass):
|
|
||||||
"""Initialize a demo camera platform."""
|
|
||||||
assert await async_setup_component(
|
|
||||||
hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
|
|
||||||
)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.camera.Camera.frontend_stream_type",
|
|
||||||
new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC),
|
|
||||||
), patch(
|
|
||||||
"homeassistant.components.camera.Camera.async_handle_web_rtc_offer",
|
|
||||||
return_value=WEBRTC_ANSWER,
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="mock_stream")
|
@pytest.fixture(name="mock_stream")
|
||||||
|
@ -78,7 +38,7 @@ def mock_stream_fixture(hass):
|
||||||
@pytest.fixture(name="setup_camera_prefs")
|
@pytest.fixture(name="setup_camera_prefs")
|
||||||
def setup_camera_prefs_fixture(hass):
|
def setup_camera_prefs_fixture(hass):
|
||||||
"""Initialize HTTP API."""
|
"""Initialize HTTP API."""
|
||||||
return common.mock_camera_prefs(hass, "camera.demo_camera")
|
return mock_camera_prefs(hass, "camera.demo_camera")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="image_mock_url")
|
@pytest.fixture(name="image_mock_url")
|
||||||
|
|
72
tests/components/camera/test_media_source.py
Normal file
72
tests/components/camera/test_media_source.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
"""Test camera media source."""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components import media_source
|
||||||
|
from homeassistant.components.camera.const import STREAM_TYPE_WEB_RTC
|
||||||
|
from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def setup_media_source(hass):
|
||||||
|
"""Set up media source."""
|
||||||
|
assert await async_setup_component(hass, "media_source", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def mock_stream(hass):
|
||||||
|
"""Mock stream."""
|
||||||
|
hass.config.components.add("stream")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browsing(hass, mock_camera_hls):
|
||||||
|
"""Test browsing camera media source."""
|
||||||
|
item = await media_source.async_browse_media(hass, "media-source://camera")
|
||||||
|
assert item is not None
|
||||||
|
assert item.title == "Camera"
|
||||||
|
assert len(item.children) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browsing_filter_non_hls(hass, mock_camera_web_rtc):
|
||||||
|
"""Test browsing camera media source hides non-HLS cameras."""
|
||||||
|
item = await media_source.async_browse_media(hass, "media-source://camera")
|
||||||
|
assert item is not None
|
||||||
|
assert item.title == "Camera"
|
||||||
|
assert len(item.children) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolving(hass, mock_camera_hls):
|
||||||
|
"""Test resolving."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.camera.media_source._async_stream_endpoint_url",
|
||||||
|
return_value="http://example.com/stream",
|
||||||
|
):
|
||||||
|
item = await media_source.async_resolve_media(
|
||||||
|
hass, "media-source://camera/camera.demo_camera"
|
||||||
|
)
|
||||||
|
assert item is not None
|
||||||
|
assert item.url == "http://example.com/stream"
|
||||||
|
assert item.mime_type == FORMAT_CONTENT_TYPE["hls"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolving_errors(hass, mock_camera_hls):
|
||||||
|
"""Test resolving."""
|
||||||
|
with pytest.raises(media_source.Unresolvable):
|
||||||
|
await media_source.async_resolve_media(
|
||||||
|
hass, "media-source://camera/camera.non_existing"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(media_source.Unresolvable), patch(
|
||||||
|
"homeassistant.components.camera.Camera.frontend_stream_type",
|
||||||
|
new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC),
|
||||||
|
):
|
||||||
|
await media_source.async_resolve_media(
|
||||||
|
hass, "media-source://camera/camera.demo_camera"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(media_source.Unresolvable):
|
||||||
|
await media_source.async_resolve_media(
|
||||||
|
hass, "media-source://camera/camera.demo_camera"
|
||||||
|
)
|
|
@ -10,6 +10,7 @@ import attr
|
||||||
import pychromecast
|
import pychromecast
|
||||||
from pychromecast.const import CAST_TYPE_CHROMECAST, CAST_TYPE_GROUP
|
from pychromecast.const import CAST_TYPE_CHROMECAST, CAST_TYPE_GROUP
|
||||||
import pytest
|
import pytest
|
||||||
|
import yarl
|
||||||
|
|
||||||
from homeassistant.components import media_player, tts
|
from homeassistant.components import media_player, tts
|
||||||
from homeassistant.components.cast import media_player as cast
|
from homeassistant.components.cast import media_player as cast
|
||||||
|
@ -37,7 +38,7 @@ from homeassistant.const import (
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er, network
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
@ -1001,7 +1002,7 @@ async def test_entity_play_media_sign_URL(hass: HomeAssistant, quick_play_mock):
|
||||||
|
|
||||||
await async_process_ha_core_config(
|
await async_process_ha_core_config(
|
||||||
hass,
|
hass,
|
||||||
{"external_url": "http://example.com:8123"},
|
{"internal_url": "http://example.com:8123"},
|
||||||
)
|
)
|
||||||
|
|
||||||
info = get_fake_chromecast_info()
|
info = get_fake_chromecast_info()
|
||||||
|
@ -1824,3 +1825,69 @@ async def test_cast_platform_browse_media(hass: HomeAssistant, hass_ws_client):
|
||||||
"children": [],
|
"children": [],
|
||||||
}
|
}
|
||||||
assert response["result"] == expected_response
|
assert response["result"] == expected_response
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cast_platform_play_media_local_media(
|
||||||
|
hass: HomeAssistant, quick_play_mock, caplog
|
||||||
|
):
|
||||||
|
"""Test we process data when playing local media."""
|
||||||
|
entity_id = "media_player.speaker"
|
||||||
|
info = get_fake_chromecast_info()
|
||||||
|
|
||||||
|
chromecast, _ = await async_setup_media_player_cast(hass, info)
|
||||||
|
_, conn_status_cb, _ = get_status_callbacks(chromecast)
|
||||||
|
|
||||||
|
# Bring Chromecast online
|
||||||
|
connection_status = MagicMock()
|
||||||
|
connection_status.status = "CONNECTED"
|
||||||
|
conn_status_cb(connection_status)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# This will play using the cast platform
|
||||||
|
await hass.services.async_call(
|
||||||
|
media_player.DOMAIN,
|
||||||
|
media_player.SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: entity_id,
|
||||||
|
media_player.ATTR_MEDIA_CONTENT_TYPE: "application/vnd.apple.mpegurl",
|
||||||
|
media_player.ATTR_MEDIA_CONTENT_ID: "/api/hls/bla/master_playlist.m3u8",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Assert we added extra play information
|
||||||
|
quick_play_mock.assert_called()
|
||||||
|
app_data = quick_play_mock.call_args[0][2]
|
||||||
|
|
||||||
|
assert not app_data["media_id"].startswith("/")
|
||||||
|
assert "authSig" in yarl.URL(app_data["media_id"]).query
|
||||||
|
assert app_data["media_type"] == "application/vnd.apple.mpegurl"
|
||||||
|
assert app_data["stream_type"] == "LIVE"
|
||||||
|
assert app_data["media_info"] == {
|
||||||
|
"hlsVideoSegmentFormat": "fmp4",
|
||||||
|
}
|
||||||
|
|
||||||
|
quick_play_mock.reset_mock()
|
||||||
|
|
||||||
|
# Test not appending if we have a signature
|
||||||
|
await hass.services.async_call(
|
||||||
|
media_player.DOMAIN,
|
||||||
|
media_player.SERVICE_PLAY_MEDIA,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: entity_id,
|
||||||
|
media_player.ATTR_MEDIA_CONTENT_TYPE: "application/vnd.apple.mpegurl",
|
||||||
|
media_player.ATTR_MEDIA_CONTENT_ID: f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Assert we added extra play information
|
||||||
|
quick_play_mock.assert_called()
|
||||||
|
app_data = quick_play_mock.call_args[0][2]
|
||||||
|
# No authSig appended
|
||||||
|
assert (
|
||||||
|
app_data["media_id"]
|
||||||
|
== f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla"
|
||||||
|
)
|
||||||
|
|
|
@ -13,6 +13,7 @@ from homeassistant.helpers.network import (
|
||||||
_get_internal_url,
|
_get_internal_url,
|
||||||
_get_request_host,
|
_get_request_host,
|
||||||
get_url,
|
get_url,
|
||||||
|
is_hass_url,
|
||||||
is_internal_request,
|
is_internal_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -645,3 +646,47 @@ async def test_is_internal_request(hass: HomeAssistant):
|
||||||
"homeassistant.helpers.network._get_request_host", return_value="192.168.0.1"
|
"homeassistant.helpers.network._get_request_host", return_value="192.168.0.1"
|
||||||
):
|
):
|
||||||
assert is_internal_request(hass)
|
assert is_internal_request(hass)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_is_hass_url(hass):
|
||||||
|
"""Test is_hass_url."""
|
||||||
|
assert hass.config.api is None
|
||||||
|
assert hass.config.internal_url is None
|
||||||
|
assert hass.config.external_url is None
|
||||||
|
|
||||||
|
assert is_hass_url(hass, "http://example.com") is False
|
||||||
|
|
||||||
|
hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123")
|
||||||
|
assert is_hass_url(hass, "http://192.168.123.123:8123") is True
|
||||||
|
assert is_hass_url(hass, "https://192.168.123.123:8123") is False
|
||||||
|
assert is_hass_url(hass, "http://192.168.123.123") is False
|
||||||
|
|
||||||
|
await async_process_ha_core_config(
|
||||||
|
hass,
|
||||||
|
{"internal_url": "http://example.local:8123"},
|
||||||
|
)
|
||||||
|
assert is_hass_url(hass, "http://example.local:8123") is True
|
||||||
|
assert is_hass_url(hass, "https://example.local:8123") is False
|
||||||
|
assert is_hass_url(hass, "http://example.local") is False
|
||||||
|
|
||||||
|
await async_process_ha_core_config(
|
||||||
|
hass,
|
||||||
|
{"external_url": "https://example.com:443"},
|
||||||
|
)
|
||||||
|
assert is_hass_url(hass, "https://example.com:443") is True
|
||||||
|
assert is_hass_url(hass, "https://example.com") is True
|
||||||
|
assert is_hass_url(hass, "http://example.com:443") is False
|
||||||
|
assert is_hass_url(hass, "http://example.com") is False
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
hass.components.cloud,
|
||||||
|
"async_remote_ui_url",
|
||||||
|
return_value="https://example.nabu.casa",
|
||||||
|
):
|
||||||
|
assert is_hass_url(hass, "https://example.nabu.casa") is False
|
||||||
|
|
||||||
|
hass.config.components.add("cloud")
|
||||||
|
assert is_hass_url(hass, "https://example.nabu.casa:443") is True
|
||||||
|
assert is_hass_url(hass, "https://example.nabu.casa") is True
|
||||||
|
assert is_hass_url(hass, "http://example.nabu.casa:443") is False
|
||||||
|
assert is_hass_url(hass, "http://example.nabu.casa") is False
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue