mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Reolink add media browser for playback of recordings (#103407)
This commit is contained in:
parent
91e0a53cb2
commit
aea15ee20c
330
homeassistant/components/reolink/media_source.py
Normal file
330
homeassistant/components/reolink/media_source.py
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
"""Expose Reolink IP camera VODs as media sources."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings
|
||||||
|
from homeassistant.components.media_player import MediaClass, MediaType
|
||||||
|
from homeassistant.components.media_source.error import Unresolvable
|
||||||
|
from homeassistant.components.media_source.models import (
|
||||||
|
BrowseMediaSource,
|
||||||
|
MediaSource,
|
||||||
|
MediaSourceItem,
|
||||||
|
PlayMedia,
|
||||||
|
)
|
||||||
|
from homeassistant.components.stream import create_stream
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
|
from . import ReolinkData
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_media_source(hass: HomeAssistant) -> ReolinkVODMediaSource:
|
||||||
|
"""Set up camera media source."""
|
||||||
|
return ReolinkVODMediaSource(hass)
|
||||||
|
|
||||||
|
|
||||||
|
def res_name(stream: str) -> str:
|
||||||
|
"""Return the user friendly name for a stream."""
|
||||||
|
return "High res." if stream == "main" else "Low res."
|
||||||
|
|
||||||
|
|
||||||
|
class ReolinkVODMediaSource(MediaSource):
|
||||||
|
"""Provide Reolink camera VODs as media sources."""
|
||||||
|
|
||||||
|
name: str = "Reolink"
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize ReolinkVODMediaSource."""
|
||||||
|
super().__init__(DOMAIN)
|
||||||
|
self.hass = hass
|
||||||
|
self.data: dict[str, ReolinkData] = hass.data[DOMAIN]
|
||||||
|
|
||||||
|
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||||
|
"""Resolve media to a url."""
|
||||||
|
identifier = item.identifier.split("|", 5)
|
||||||
|
if identifier[0] != "FILE":
|
||||||
|
raise Unresolvable(f"Unknown media item '{item.identifier}'.")
|
||||||
|
|
||||||
|
_, config_entry_id, channel_str, stream_res, filename = identifier
|
||||||
|
channel = int(channel_str)
|
||||||
|
|
||||||
|
host = self.data[config_entry_id].host
|
||||||
|
mime_type, url = await host.api.get_vod_source(channel, filename, stream_res)
|
||||||
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||||
|
url_log = f"{url.split('&user=')[0]}&user=xxxxx&password=xxxxx"
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log
|
||||||
|
)
|
||||||
|
|
||||||
|
stream = create_stream(self.hass, url, {}, DynamicStreamSettings())
|
||||||
|
stream.add_provider("hls", timeout=3600)
|
||||||
|
stream_url: str = stream.endpoint_url("hls")
|
||||||
|
stream_url = stream_url.replace("master_", "")
|
||||||
|
return PlayMedia(stream_url, mime_type)
|
||||||
|
|
||||||
|
async def async_browse_media(
|
||||||
|
self,
|
||||||
|
item: MediaSourceItem,
|
||||||
|
) -> BrowseMediaSource:
|
||||||
|
"""Return media."""
|
||||||
|
if item.identifier is None:
|
||||||
|
return await self._async_generate_root()
|
||||||
|
|
||||||
|
identifier = item.identifier.split("|", 7)
|
||||||
|
item_type = identifier[0]
|
||||||
|
|
||||||
|
if item_type == "CAM":
|
||||||
|
_, config_entry_id, channel_str = identifier
|
||||||
|
return await self._async_generate_resolution_select(
|
||||||
|
config_entry_id, int(channel_str)
|
||||||
|
)
|
||||||
|
if item_type == "RES":
|
||||||
|
_, config_entry_id, channel_str, stream = identifier
|
||||||
|
return await self._async_generate_camera_days(
|
||||||
|
config_entry_id, int(channel_str), stream
|
||||||
|
)
|
||||||
|
if item_type == "DAY":
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
config_entry_id,
|
||||||
|
channel_str,
|
||||||
|
stream,
|
||||||
|
year_str,
|
||||||
|
month_str,
|
||||||
|
day_str,
|
||||||
|
) = identifier
|
||||||
|
return await self._async_generate_camera_files(
|
||||||
|
config_entry_id,
|
||||||
|
int(channel_str),
|
||||||
|
stream,
|
||||||
|
int(year_str),
|
||||||
|
int(month_str),
|
||||||
|
int(day_str),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.")
|
||||||
|
|
||||||
|
async def _async_generate_root(self) -> BrowseMediaSource:
|
||||||
|
"""Return all available reolink cameras as root browsing structure."""
|
||||||
|
children: list[BrowseMediaSource] = []
|
||||||
|
|
||||||
|
entity_reg = er.async_get(self.hass)
|
||||||
|
device_reg = dr.async_get(self.hass)
|
||||||
|
for config_entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||||
|
if config_entry.state != ConfigEntryState.LOADED:
|
||||||
|
continue
|
||||||
|
channels: list[str] = []
|
||||||
|
host = self.data[config_entry.entry_id].host
|
||||||
|
entities = er.async_entries_for_config_entry(
|
||||||
|
entity_reg, config_entry.entry_id
|
||||||
|
)
|
||||||
|
for entity in entities:
|
||||||
|
if (
|
||||||
|
entity.disabled
|
||||||
|
or entity.device_id is None
|
||||||
|
or entity.domain != CAM_DOMAIN
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
device = device_reg.async_get(entity.device_id)
|
||||||
|
ch = entity.unique_id.split("_")[1]
|
||||||
|
if ch in channels or device is None:
|
||||||
|
continue
|
||||||
|
channels.append(ch)
|
||||||
|
|
||||||
|
if (
|
||||||
|
host.api.api_version("recReplay", int(ch)) < 1
|
||||||
|
or not host.api.hdd_info
|
||||||
|
):
|
||||||
|
# playback stream not supported by this camera or no storage installed
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_name = device.name
|
||||||
|
if device.name_by_user is not None:
|
||||||
|
device_name = device.name_by_user
|
||||||
|
|
||||||
|
children.append(
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"CAM|{config_entry.entry_id}|{ch}",
|
||||||
|
media_class=MediaClass.CHANNEL,
|
||||||
|
media_content_type=MediaType.PLAYLIST,
|
||||||
|
title=device_name,
|
||||||
|
thumbnail=f"/api/camera_proxy/{entity.entity_id}",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=None,
|
||||||
|
media_class=MediaClass.APP,
|
||||||
|
media_content_type="",
|
||||||
|
title="Reolink",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
children=children,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_generate_resolution_select(
|
||||||
|
self, config_entry_id: str, channel: int
|
||||||
|
) -> BrowseMediaSource:
|
||||||
|
"""Allow the user to select the high or low playback resolution, (low loads faster)."""
|
||||||
|
host = self.data[config_entry_id].host
|
||||||
|
|
||||||
|
main_enc = await host.api.get_encoding(channel, "main")
|
||||||
|
if main_enc == "h265":
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Reolink camera %s uses h265 encoding for main stream,"
|
||||||
|
"playback only possible using sub stream",
|
||||||
|
host.api.camera_name(channel),
|
||||||
|
)
|
||||||
|
return await self._async_generate_camera_days(
|
||||||
|
config_entry_id, channel, "sub"
|
||||||
|
)
|
||||||
|
|
||||||
|
children = [
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"RES|{config_entry_id}|{channel}|sub",
|
||||||
|
media_class=MediaClass.CHANNEL,
|
||||||
|
media_content_type=MediaType.PLAYLIST,
|
||||||
|
title="Low resolution",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
),
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"RES|{config_entry_id}|{channel}|main",
|
||||||
|
media_class=MediaClass.CHANNEL,
|
||||||
|
media_content_type=MediaType.PLAYLIST,
|
||||||
|
title="High resolution",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"RESs|{config_entry_id}|{channel}",
|
||||||
|
media_class=MediaClass.CHANNEL,
|
||||||
|
media_content_type=MediaType.PLAYLIST,
|
||||||
|
title=host.api.camera_name(channel),
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
children=children,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_generate_camera_days(
|
||||||
|
self, config_entry_id: str, channel: int, stream: str
|
||||||
|
) -> BrowseMediaSource:
|
||||||
|
"""Return all days on which recordings are available for a reolink camera."""
|
||||||
|
host = self.data[config_entry_id].host
|
||||||
|
|
||||||
|
# We want today of the camera, not necessarily today of the server
|
||||||
|
now = host.api.time() or await host.api.async_get_time()
|
||||||
|
start = now - dt.timedelta(days=31)
|
||||||
|
end = now
|
||||||
|
|
||||||
|
children: list[BrowseMediaSource] = []
|
||||||
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Requesting recording days of %s from %s to %s",
|
||||||
|
host.api.camera_name(channel),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
)
|
||||||
|
statuses, _ = await host.api.request_vod_files(
|
||||||
|
channel, start, end, status_only=True, stream=stream
|
||||||
|
)
|
||||||
|
for status in statuses:
|
||||||
|
for day in status.days:
|
||||||
|
children.append(
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"DAY|{config_entry_id}|{channel}|{stream}|{status.year}|{status.month}|{day}",
|
||||||
|
media_class=MediaClass.DIRECTORY,
|
||||||
|
media_content_type=MediaType.PLAYLIST,
|
||||||
|
title=f"{status.year}/{status.month}/{day}",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"DAYS|{config_entry_id}|{channel}|{stream}",
|
||||||
|
media_class=MediaClass.CHANNEL,
|
||||||
|
media_content_type=MediaType.PLAYLIST,
|
||||||
|
title=f"{host.api.camera_name(channel)} {res_name(stream)}",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
children=children,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_generate_camera_files(
|
||||||
|
self,
|
||||||
|
config_entry_id: str,
|
||||||
|
channel: int,
|
||||||
|
stream: str,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
day: int,
|
||||||
|
) -> BrowseMediaSource:
|
||||||
|
"""Return all recording files on a specific day of a Reolink camera."""
|
||||||
|
host = self.data[config_entry_id].host
|
||||||
|
|
||||||
|
start = dt.datetime(year, month, day, hour=0, minute=0, second=0)
|
||||||
|
end = dt.datetime(year, month, day, hour=23, minute=59, second=59)
|
||||||
|
|
||||||
|
children: list[BrowseMediaSource] = []
|
||||||
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Requesting VODs of %s on %s/%s/%s",
|
||||||
|
host.api.camera_name(channel),
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
)
|
||||||
|
_, vod_files = await host.api.request_vod_files(
|
||||||
|
channel, start, end, stream=stream
|
||||||
|
)
|
||||||
|
for file in vod_files:
|
||||||
|
file_name = f"{file.start_time.time()} {file.duration}"
|
||||||
|
if file.triggers != file.triggers.NONE:
|
||||||
|
file_name += " " + " ".join(
|
||||||
|
str(trigger.name).title()
|
||||||
|
for trigger in file.triggers
|
||||||
|
if trigger != trigger.NONE
|
||||||
|
)
|
||||||
|
|
||||||
|
children.append(
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"FILE|{config_entry_id}|{channel}|{stream}|{file.file_name}",
|
||||||
|
media_class=MediaClass.VIDEO,
|
||||||
|
media_content_type=MediaType.VIDEO,
|
||||||
|
title=file_name,
|
||||||
|
can_play=True,
|
||||||
|
can_expand=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"FILES|{config_entry_id}|{channel}|{stream}",
|
||||||
|
media_class=MediaClass.CHANNEL,
|
||||||
|
media_content_type=MediaType.PLAYLIST,
|
||||||
|
title=f"{host.api.camera_name(channel)} {res_name(stream)} {year}/{month}/{day}",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
children=children,
|
||||||
|
)
|
@ -19,8 +19,10 @@ TEST_USERNAME2 = "username"
|
|||||||
TEST_PASSWORD = "password"
|
TEST_PASSWORD = "password"
|
||||||
TEST_PASSWORD2 = "new_password"
|
TEST_PASSWORD2 = "new_password"
|
||||||
TEST_MAC = "ab:cd:ef:gh:ij:kl"
|
TEST_MAC = "ab:cd:ef:gh:ij:kl"
|
||||||
|
TEST_MAC2 = "12:34:56:78:9a:bc"
|
||||||
TEST_PORT = 1234
|
TEST_PORT = 1234
|
||||||
TEST_NVR_NAME = "test_reolink_name"
|
TEST_NVR_NAME = "test_reolink_name"
|
||||||
|
TEST_NVR_NAME2 = "test2_reolink_name"
|
||||||
TEST_USE_HTTPS = True
|
TEST_USE_HTTPS = True
|
||||||
|
|
||||||
|
|
||||||
@ -59,11 +61,15 @@ def reolink_connect_class(
|
|||||||
host_mock.use_https = TEST_USE_HTTPS
|
host_mock.use_https = TEST_USE_HTTPS
|
||||||
host_mock.is_admin = True
|
host_mock.is_admin = True
|
||||||
host_mock.user_level = "admin"
|
host_mock.user_level = "admin"
|
||||||
|
host_mock.stream_channels = [0]
|
||||||
host_mock.sw_version_update_required = False
|
host_mock.sw_version_update_required = False
|
||||||
host_mock.hardware_version = "IPC_00000"
|
host_mock.hardware_version = "IPC_00000"
|
||||||
host_mock.sw_version = "v1.0.0.0.0.0000"
|
host_mock.sw_version = "v1.0.0.0.0.0000"
|
||||||
host_mock.manufacturer = "Reolink"
|
host_mock.manufacturer = "Reolink"
|
||||||
host_mock.model = "RLC-123"
|
host_mock.model = "RLC-123"
|
||||||
|
host_mock.camera_model.return_value = "RLC-123"
|
||||||
|
host_mock.camera_name.return_value = TEST_NVR_NAME
|
||||||
|
host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000"
|
||||||
host_mock.session_active = True
|
host_mock.session_active = True
|
||||||
host_mock.timeout = 60
|
host_mock.timeout = 60
|
||||||
host_mock.renewtimer.return_value = 600
|
host_mock.renewtimer.return_value = 600
|
||||||
|
288
tests/components/reolink/test_media_source.py
Normal file
288
tests/components/reolink/test_media_source.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
"""Tests for the Reolink media_source platform."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from reolink_aio.exceptions import ReolinkError
|
||||||
|
|
||||||
|
from homeassistant.components.media_source import (
|
||||||
|
DOMAIN as MEDIA_SOURCE_DOMAIN,
|
||||||
|
URI_SCHEME,
|
||||||
|
async_browse_media,
|
||||||
|
async_resolve_media,
|
||||||
|
)
|
||||||
|
from homeassistant.components.media_source.error import Unresolvable
|
||||||
|
from homeassistant.components.reolink import const
|
||||||
|
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
|
||||||
|
from homeassistant.components.reolink.const import DOMAIN
|
||||||
|
from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_PORT,
|
||||||
|
CONF_USERNAME,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from .conftest import (
|
||||||
|
TEST_HOST2,
|
||||||
|
TEST_MAC2,
|
||||||
|
TEST_NVR_NAME,
|
||||||
|
TEST_NVR_NAME2,
|
||||||
|
TEST_PASSWORD2,
|
||||||
|
TEST_PORT,
|
||||||
|
TEST_USE_HTTPS,
|
||||||
|
TEST_USERNAME2,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
TEST_YEAR = 2023
|
||||||
|
TEST_MONTH = 11
|
||||||
|
TEST_DAY = 14
|
||||||
|
TEST_DAY2 = 15
|
||||||
|
TEST_HOUR = 13
|
||||||
|
TEST_MINUTE = 12
|
||||||
|
TEST_FILE_NAME = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00"
|
||||||
|
TEST_STREAM = "main"
|
||||||
|
TEST_CHANNEL = "0"
|
||||||
|
|
||||||
|
TEST_MIME_TYPE = "application/x-mpegURL"
|
||||||
|
TEST_URL = "http:test_url"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def setup_component(hass: HomeAssistant) -> None:
|
||||||
|
"""Set up component."""
|
||||||
|
assert await async_setup_component(hass, MEDIA_SOURCE_DOMAIN, {})
|
||||||
|
assert await async_setup_component(hass, MEDIA_STREAM_DOMAIN, {})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
reolink_connect: MagicMock,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test resolving Reolink media items."""
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL)
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
|
||||||
|
file_id = (
|
||||||
|
f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{file_id}")
|
||||||
|
|
||||||
|
assert play_media.mime_type == TEST_MIME_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browsing(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
reolink_connect: MagicMock,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test browsing the Reolink three."""
|
||||||
|
entry_id = config_entry.entry_id
|
||||||
|
reolink_connect.api_version.return_value = 1
|
||||||
|
|
||||||
|
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]):
|
||||||
|
assert await hass.config_entries.async_setup(entry_id) is True
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entries = dr.async_entries_for_config_entry(device_registry, entry_id)
|
||||||
|
assert len(entries) > 0
|
||||||
|
device_registry.async_update_device(entries[0].id, name_by_user="Cam new name")
|
||||||
|
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
|
||||||
|
# browse root
|
||||||
|
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
|
||||||
|
|
||||||
|
browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}"
|
||||||
|
assert browse.domain == DOMAIN
|
||||||
|
assert browse.title == "Reolink"
|
||||||
|
assert browse.identifier is None
|
||||||
|
assert browse.children[0].identifier == browse_root_id
|
||||||
|
|
||||||
|
# browse resolution select
|
||||||
|
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}")
|
||||||
|
|
||||||
|
browse_resolution_id = f"RESs|{entry_id}|{TEST_CHANNEL}"
|
||||||
|
browse_res_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|sub"
|
||||||
|
browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main"
|
||||||
|
assert browse.domain == DOMAIN
|
||||||
|
assert browse.title == TEST_NVR_NAME
|
||||||
|
assert browse.identifier == browse_resolution_id
|
||||||
|
assert browse.children[0].identifier == browse_res_sub_id
|
||||||
|
assert browse.children[1].identifier == browse_res_main_id
|
||||||
|
|
||||||
|
# browse camera recording days
|
||||||
|
mock_status = MagicMock()
|
||||||
|
mock_status.year = TEST_YEAR
|
||||||
|
mock_status.month = TEST_MONTH
|
||||||
|
mock_status.days = (TEST_DAY, TEST_DAY2)
|
||||||
|
reolink_connect.request_vod_files.return_value = ([mock_status], [])
|
||||||
|
|
||||||
|
browse = await async_browse_media(
|
||||||
|
hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}"
|
||||||
|
browse_day_0_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}"
|
||||||
|
browse_day_1_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}"
|
||||||
|
assert browse.domain == DOMAIN
|
||||||
|
assert browse.title == f"{TEST_NVR_NAME} High res."
|
||||||
|
assert browse.identifier == browse_days_id
|
||||||
|
assert browse.children[0].identifier == browse_day_0_id
|
||||||
|
assert browse.children[1].identifier == browse_day_1_id
|
||||||
|
|
||||||
|
# browse camera recording files on day
|
||||||
|
mock_vod_file = MagicMock()
|
||||||
|
mock_vod_file.start_time = datetime(
|
||||||
|
TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE
|
||||||
|
)
|
||||||
|
mock_vod_file.duration = timedelta(minutes=15)
|
||||||
|
mock_vod_file.file_name = TEST_FILE_NAME
|
||||||
|
reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file])
|
||||||
|
|
||||||
|
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}")
|
||||||
|
|
||||||
|
browse_files_id = f"FILES|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}"
|
||||||
|
browse_file_id = f"FILE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}"
|
||||||
|
assert browse.domain == DOMAIN
|
||||||
|
assert (
|
||||||
|
browse.title == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}"
|
||||||
|
)
|
||||||
|
assert browse.identifier == browse_files_id
|
||||||
|
assert browse.children[0].identifier == browse_file_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browsing_unsupported_encoding(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
reolink_connect: MagicMock,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test browsing a Reolink camera with unsupported stream encoding."""
|
||||||
|
entry_id = config_entry.entry_id
|
||||||
|
|
||||||
|
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]):
|
||||||
|
assert await hass.config_entries.async_setup(entry_id) is True
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}"
|
||||||
|
|
||||||
|
# browse resolution select/camera recording days when main encoding unsupported
|
||||||
|
mock_status = MagicMock()
|
||||||
|
mock_status.year = TEST_YEAR
|
||||||
|
mock_status.month = TEST_MONTH
|
||||||
|
mock_status.days = (TEST_DAY, TEST_DAY2)
|
||||||
|
reolink_connect.request_vod_files.return_value = ([mock_status], [])
|
||||||
|
reolink_connect.time.return_value = None
|
||||||
|
reolink_connect.get_encoding.return_value = "h265"
|
||||||
|
|
||||||
|
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}")
|
||||||
|
|
||||||
|
browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|sub"
|
||||||
|
browse_day_0_id = (
|
||||||
|
f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}"
|
||||||
|
)
|
||||||
|
browse_day_1_id = (
|
||||||
|
f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}"
|
||||||
|
)
|
||||||
|
assert browse.domain == DOMAIN
|
||||||
|
assert browse.title == f"{TEST_NVR_NAME} Low res."
|
||||||
|
assert browse.identifier == browse_days_id
|
||||||
|
assert browse.children[0].identifier == browse_day_0_id
|
||||||
|
assert browse.children[1].identifier == browse_day_1_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browsing_rec_playback_unsupported(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
reolink_connect: MagicMock,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test browsing a Reolink camera which does not support playback of recordings."""
|
||||||
|
reolink_connect.api_version.return_value = 0
|
||||||
|
|
||||||
|
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# browse root
|
||||||
|
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
|
||||||
|
|
||||||
|
assert browse.domain == DOMAIN
|
||||||
|
assert browse.title == "Reolink"
|
||||||
|
assert browse.identifier is None
|
||||||
|
assert browse.children == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browsing_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
reolink_connect: MagicMock,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test browsing a Reolink camera errors."""
|
||||||
|
reolink_connect.api_version.return_value = 1
|
||||||
|
|
||||||
|
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# browse root
|
||||||
|
with pytest.raises(Unresolvable):
|
||||||
|
await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN")
|
||||||
|
with pytest.raises(Unresolvable):
|
||||||
|
await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browsing_not_loaded(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
reolink_connect: MagicMock,
|
||||||
|
config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test browsing a Reolink camera integration which is not loaded."""
|
||||||
|
reolink_connect.api_version.return_value = 1
|
||||||
|
|
||||||
|
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
reolink_connect.get_host_data = AsyncMock(side_effect=ReolinkError("Test error"))
|
||||||
|
config_entry2 = MockConfigEntry(
|
||||||
|
domain=const.DOMAIN,
|
||||||
|
unique_id=format_mac(TEST_MAC2),
|
||||||
|
data={
|
||||||
|
CONF_HOST: TEST_HOST2,
|
||||||
|
CONF_USERNAME: TEST_USERNAME2,
|
||||||
|
CONF_PASSWORD: TEST_PASSWORD2,
|
||||||
|
CONF_PORT: TEST_PORT,
|
||||||
|
const.CONF_USE_HTTPS: TEST_USE_HTTPS,
|
||||||
|
},
|
||||||
|
options={
|
||||||
|
const.CONF_PROTOCOL: DEFAULT_PROTOCOL,
|
||||||
|
},
|
||||||
|
title=TEST_NVR_NAME2,
|
||||||
|
)
|
||||||
|
config_entry2.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(config_entry2.entry_id) is False
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# browse root
|
||||||
|
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
|
||||||
|
|
||||||
|
assert browse.domain == DOMAIN
|
||||||
|
assert browse.title == "Reolink"
|
||||||
|
assert browse.identifier is None
|
||||||
|
assert len(browse.children) == 1
|
Loading…
x
Reference in New Issue
Block a user