Add UniFi Protect media_player (#62895)
This commit is contained in:
parent
4025ce8f97
commit
490d76e01e
8 changed files with 470 additions and 83 deletions
|
@ -41,4 +41,4 @@ DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT}
|
|||
MIN_REQUIRED_PROTECT_V = Version("1.20.0")
|
||||
OUTDATED_LOG_MESSAGE = "You are running v%s of UniFi Protect. Minimum required version is v%s. Please upgrade UniFi Protect and then retry"
|
||||
|
||||
PLATFORMS = [Platform.CAMERA]
|
||||
PLATFORMS = [Platform.CAMERA, Platform.MEDIA_PLAYER]
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/unifiprotect",
|
||||
"requirements": [
|
||||
"pyunifiprotect==1.4.4"
|
||||
"pyunifiprotect==1.4.7"
|
||||
],
|
||||
"codeowners": [
|
||||
"@briis",
|
||||
|
|
139
homeassistant/components/unifiprotect/media_player.py
Normal file
139
homeassistant/components/unifiprotect/media_player.py
Normal file
|
@ -0,0 +1,139 @@
|
|||
"""Support for Ubiquiti's UniFi Protect NVR."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyunifiprotect.data import Camera
|
||||
from pyunifiprotect.exceptions import StreamError
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
DEVICE_CLASS_SPEAKER,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityDescription,
|
||||
)
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_TYPE_MUSIC,
|
||||
SUPPORT_PLAY_MEDIA,
|
||||
SUPPORT_SELECT_SOURCE,
|
||||
SUPPORT_STOP,
|
||||
SUPPORT_VOLUME_SET,
|
||||
SUPPORT_VOLUME_STEP,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_IDLE, STATE_PLAYING
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import ProtectData
|
||||
from .entity import ProtectDeviceEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[Sequence[Entity]], None],
|
||||
) -> None:
|
||||
"""Discover cameras with speakers on a UniFi Protect NVR."""
|
||||
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
ProtectMediaPlayer(
|
||||
data,
|
||||
camera,
|
||||
)
|
||||
for camera in data.api.bootstrap.cameras.values()
|
||||
if camera.feature_flags.has_speaker
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
|
||||
"""A Ubiquiti UniFi Protect Speaker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: ProtectData,
|
||||
camera: Camera,
|
||||
) -> None:
|
||||
"""Initialize an UniFi speaker."""
|
||||
|
||||
self.device: Camera = camera
|
||||
self.entity_description = MediaPlayerEntityDescription(
|
||||
key="speaker", device_class=DEVICE_CLASS_SPEAKER
|
||||
)
|
||||
super().__init__(data)
|
||||
|
||||
self._attr_name = f"{self.device.name} Speaker"
|
||||
self._attr_supported_features = (
|
||||
SUPPORT_PLAY_MEDIA
|
||||
| SUPPORT_VOLUME_SET
|
||||
| SUPPORT_VOLUME_STEP
|
||||
| SUPPORT_STOP
|
||||
| SUPPORT_SELECT_SOURCE
|
||||
)
|
||||
self._attr_media_content_type = MEDIA_TYPE_MUSIC
|
||||
|
||||
@callback
|
||||
def _async_update_device_from_protect(self) -> None:
|
||||
super()._async_update_device_from_protect()
|
||||
self._attr_volume_level = float(self.device.speaker_settings.volume / 100)
|
||||
|
||||
if (
|
||||
self.device.talkback_stream is not None
|
||||
and self.device.talkback_stream.is_running
|
||||
):
|
||||
self._attr_state = STATE_PLAYING
|
||||
else:
|
||||
self._attr_state = STATE_IDLE
|
||||
|
||||
@callback
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
|
||||
volume_int = int(volume * 100)
|
||||
await self.device.set_speaker_volume(volume_int)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
|
||||
if (
|
||||
self.device.talkback_stream is not None
|
||||
and self.device.talkback_stream.is_running
|
||||
):
|
||||
_LOGGER.debug("Stopping playback for %s Speaker", self.device.name)
|
||||
await self.device.stop_audio()
|
||||
self._async_updated_event()
|
||||
|
||||
async def async_play_media(
|
||||
self, media_type: str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
|
||||
if media_type != MEDIA_TYPE_MUSIC:
|
||||
_LOGGER.warning(
|
||||
"%s: Cannot play media type of %s, only `%s` supported",
|
||||
self.device.name,
|
||||
media_type,
|
||||
MEDIA_TYPE_MUSIC,
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.debug("Playing Media %s for %s Speaker", media_id, self.device.name)
|
||||
await self.async_media_stop()
|
||||
try:
|
||||
await self.device.play_audio(media_id, blocking=False)
|
||||
except StreamError as err:
|
||||
_LOGGER.error("Error while playing media: %s", err)
|
||||
else:
|
||||
# update state after starting player
|
||||
self._async_updated_event()
|
||||
# wait until player finishes to update state again
|
||||
await self.device.wait_until_audio_completes()
|
||||
|
||||
self._async_updated_event()
|
|
@ -2000,7 +2000,7 @@ pytrafikverket==0.1.6.2
|
|||
pyudev==0.22.0
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
pyunifiprotect==1.4.4
|
||||
pyunifiprotect==1.4.7
|
||||
|
||||
# homeassistant.components.uptimerobot
|
||||
pyuptimerobot==21.11.0
|
||||
|
|
|
@ -1213,7 +1213,7 @@ pytrafikverket==0.1.6.2
|
|||
pyudev==0.22.0
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
pyunifiprotect==1.4.4
|
||||
pyunifiprotect==1.4.7
|
||||
|
||||
# homeassistant.components.uptimerobot
|
||||
pyuptimerobot==21.11.0
|
||||
|
|
|
@ -95,7 +95,9 @@ def mock_client():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_entry(hass: HomeAssistant, mock_client):
|
||||
def mock_entry(
|
||||
hass: HomeAssistant, mock_client # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""Mock ProtectApiClient for testing."""
|
||||
|
||||
with patch("homeassistant.components.unifiprotect.ProtectApiClient") as mock_api:
|
||||
|
@ -123,44 +125,12 @@ def mock_camera():
|
|||
"""Mock UniFi Protect Camera device."""
|
||||
|
||||
path = Path(__file__).parent / "sample_data" / "sample_camera.json"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
with open(path, encoding="utf-8") as json_file:
|
||||
data = json.load(json_file)
|
||||
|
||||
yield Camera.from_unifi_dict(**data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def simple_camera(
|
||||
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera
|
||||
):
|
||||
"""Fixture for a single camera, no extra setup."""
|
||||
|
||||
camera = mock_camera.copy(deep=True)
|
||||
camera._api = mock_entry.api
|
||||
camera.channels[0]._api = mock_entry.api
|
||||
camera.channels[1]._api = mock_entry.api
|
||||
camera.channels[2]._api = mock_entry.api
|
||||
camera.name = "Test Camera"
|
||||
camera.channels[0].is_rtsp_enabled = True
|
||||
camera.channels[0].name = "High"
|
||||
camera.channels[1].is_rtsp_enabled = False
|
||||
camera.channels[2].is_rtsp_enabled = False
|
||||
|
||||
mock_entry.api.bootstrap.cameras = {
|
||||
camera.id: camera,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
assert len(entity_registry.entities) == 2
|
||||
|
||||
yield (camera, "camera.test_camera_high")
|
||||
|
||||
|
||||
async def time_changed(hass: HomeAssistant, seconds: int) -> None:
|
||||
"""Trigger time changed."""
|
||||
next_update = dt_util.utcnow() + timedelta(seconds)
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
"""Test the UniFi Protect camera platform."""
|
||||
# pylint: disable=protected-access
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import copy
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from pyunifiprotect.data import Camera as ProtectCamera
|
||||
from pyunifiprotect.data.devices import CameraChannel
|
||||
from pyunifiprotect.exceptions import NvrError
|
||||
|
@ -27,6 +29,7 @@ from homeassistant.const import (
|
|||
ATTR_ATTRIBUTION,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
@ -35,17 +38,50 @@ from homeassistant.setup import async_setup_component
|
|||
from .conftest import MockEntityFixture, enable_entity, time_changed
|
||||
|
||||
|
||||
@pytest.fixture(name="camera")
|
||||
async def camera_fixture(
|
||||
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera
|
||||
):
|
||||
"""Fixture for a single camera, no extra setup."""
|
||||
|
||||
camera_obj = mock_camera.copy(deep=True)
|
||||
camera_obj._api = mock_entry.api
|
||||
camera_obj.channels[0]._api = mock_entry.api
|
||||
camera_obj.channels[1]._api = mock_entry.api
|
||||
camera_obj.channels[2]._api = mock_entry.api
|
||||
camera_obj.name = "Test Camera"
|
||||
camera_obj.channels[0].is_rtsp_enabled = True
|
||||
camera_obj.channels[0].name = "High"
|
||||
camera_obj.channels[1].is_rtsp_enabled = False
|
||||
camera_obj.channels[2].is_rtsp_enabled = False
|
||||
|
||||
mock_entry.api.bootstrap.cameras = {
|
||||
camera_obj.id: camera_obj,
|
||||
}
|
||||
|
||||
with patch("homeassistant.components.unifiprotect.PLATFORMS", [Platform.CAMERA]):
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
assert len(entity_registry.entities) == 2
|
||||
|
||||
yield (camera_obj, "camera.test_camera_high")
|
||||
|
||||
|
||||
def validate_default_camera_entity(
|
||||
hass: HomeAssistant,
|
||||
camera: ProtectCamera,
|
||||
camera_obj: ProtectCamera,
|
||||
channel_id: int,
|
||||
) -> str:
|
||||
"""Validate a camera entity."""
|
||||
|
||||
channel = camera.channels[channel_id]
|
||||
channel = camera_obj.channels[channel_id]
|
||||
|
||||
entity_name = f"{camera.name} {channel.name}"
|
||||
unique_id = f"{camera.id}_{channel.id}"
|
||||
entity_name = f"{camera_obj.name} {channel.name}"
|
||||
unique_id = f"{camera_obj.id}_{channel.id}"
|
||||
entity_id = f"camera.{entity_name.replace(' ', '_').lower()}"
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
@ -59,15 +95,15 @@ def validate_default_camera_entity(
|
|||
|
||||
def validate_rtsps_camera_entity(
|
||||
hass: HomeAssistant,
|
||||
camera: ProtectCamera,
|
||||
camera_obj: ProtectCamera,
|
||||
channel_id: int,
|
||||
) -> str:
|
||||
"""Validate a disabled RTSPS camera entity."""
|
||||
|
||||
channel = camera.channels[channel_id]
|
||||
channel = camera_obj.channels[channel_id]
|
||||
|
||||
entity_name = f"{camera.name} {channel.name}"
|
||||
unique_id = f"{camera.id}_{channel.id}"
|
||||
entity_name = f"{camera_obj.name} {channel.name}"
|
||||
unique_id = f"{camera_obj.id}_{channel.id}"
|
||||
entity_id = f"camera.{entity_name.replace(' ', '_').lower()}"
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
@ -81,15 +117,15 @@ def validate_rtsps_camera_entity(
|
|||
|
||||
def validate_rtsp_camera_entity(
|
||||
hass: HomeAssistant,
|
||||
camera: ProtectCamera,
|
||||
camera_obj: ProtectCamera,
|
||||
channel_id: int,
|
||||
) -> str:
|
||||
"""Validate a disabled RTSP camera entity."""
|
||||
|
||||
channel = camera.channels[channel_id]
|
||||
channel = camera_obj.channels[channel_id]
|
||||
|
||||
entity_name = f"{camera.name} {channel.name} Insecure"
|
||||
unique_id = f"{camera.id}_{channel.id}_insecure"
|
||||
entity_name = f"{camera_obj.name} {channel.name} Insecure"
|
||||
unique_id = f"{camera_obj.id}_{channel.id}_insecure"
|
||||
entity_id = f"camera.{entity_name.replace(' ', '_').lower()}"
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
@ -121,13 +157,13 @@ def validate_common_camera_state(
|
|||
|
||||
async def validate_rtsps_camera_state(
|
||||
hass: HomeAssistant,
|
||||
camera: ProtectCamera,
|
||||
camera_obj: ProtectCamera,
|
||||
channel_id: int,
|
||||
entity_id: str,
|
||||
features: int = SUPPORT_STREAM,
|
||||
):
|
||||
"""Validate a camera's state."""
|
||||
channel = camera.channels[channel_id]
|
||||
channel = camera_obj.channels[channel_id]
|
||||
|
||||
assert await async_get_stream_source(hass, entity_id) == channel.rtsps_url
|
||||
validate_common_camera_state(hass, channel, entity_id, features)
|
||||
|
@ -135,13 +171,13 @@ async def validate_rtsps_camera_state(
|
|||
|
||||
async def validate_rtsp_camera_state(
|
||||
hass: HomeAssistant,
|
||||
camera: ProtectCamera,
|
||||
camera_obj: ProtectCamera,
|
||||
channel_id: int,
|
||||
entity_id: str,
|
||||
features: int = SUPPORT_STREAM,
|
||||
):
|
||||
"""Validate a camera's state."""
|
||||
channel = camera.channels[channel_id]
|
||||
channel = camera_obj.channels[channel_id]
|
||||
|
||||
assert await async_get_stream_source(hass, entity_id) == channel.rtsp_url
|
||||
validate_common_camera_state(hass, channel, entity_id, features)
|
||||
|
@ -149,13 +185,13 @@ async def validate_rtsp_camera_state(
|
|||
|
||||
async def validate_no_stream_camera_state(
|
||||
hass: HomeAssistant,
|
||||
camera: ProtectCamera,
|
||||
camera_obj: ProtectCamera,
|
||||
channel_id: int,
|
||||
entity_id: str,
|
||||
features: int = SUPPORT_STREAM,
|
||||
):
|
||||
"""Validate a camera's state."""
|
||||
channel = camera.channels[channel_id]
|
||||
channel = camera_obj.channels[channel_id]
|
||||
|
||||
assert await async_get_stream_source(hass, entity_id) is None
|
||||
validate_common_camera_state(hass, channel, entity_id, features)
|
||||
|
@ -228,8 +264,9 @@ async def test_basic_setup(
|
|||
camera_no_channels.id: camera_no_channels,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
with patch("homeassistant.components.unifiprotect.PLATFORMS", [Platform.CAMERA]):
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
|
@ -305,52 +342,52 @@ async def test_missing_channels(
|
|||
async def test_camera_image(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[Camera, str],
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test retrieving camera image."""
|
||||
|
||||
mock_entry.api.get_camera_snapshot = AsyncMock()
|
||||
|
||||
await async_get_image(hass, simple_camera[1])
|
||||
await async_get_image(hass, camera[1])
|
||||
mock_entry.api.get_camera_snapshot.assert_called_once()
|
||||
|
||||
|
||||
async def test_camera_generic_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""Tests generic entity update service."""
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
mock_entry.api.update = AsyncMock(return_value=None)
|
||||
await hass.services.async_call(
|
||||
"homeassistant",
|
||||
"update_entity",
|
||||
{ATTR_ENTITY_ID: simple_camera[1]},
|
||||
{ATTR_ENTITY_ID: camera[1]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
|
||||
async def test_camera_interval_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""Interval updates updates camera entity."""
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = simple_camera[0].copy()
|
||||
new_camera = camera[0].copy()
|
||||
new_camera.is_recording = True
|
||||
|
||||
new_bootstrap.cameras = {new_camera.id: new_camera}
|
||||
|
@ -358,47 +395,47 @@ async def test_camera_interval_update(
|
|||
mock_entry.api.bootstrap = new_bootstrap
|
||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "recording"
|
||||
|
||||
|
||||
async def test_camera_bad_interval_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[Camera, str],
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Interval updates marks camera unavailable."""
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
# update fails
|
||||
mock_entry.api.update = AsyncMock(side_effect=NvrError)
|
||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "unavailable"
|
||||
|
||||
# next update succeeds
|
||||
mock_entry.api.update = AsyncMock(return_value=mock_entry.api.bootstrap)
|
||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
|
||||
async def test_camera_ws_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""WS update updates camera entity."""
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = simple_camera[0].copy()
|
||||
new_camera = camera[0].copy()
|
||||
new_camera.is_recording = True
|
||||
|
||||
mock_msg = Mock()
|
||||
|
@ -409,23 +446,23 @@ async def test_camera_ws_update(
|
|||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "recording"
|
||||
|
||||
|
||||
async def test_camera_ws_update_offline(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""WS updates marks camera unavailable."""
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
# camera goes offline
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = simple_camera[0].copy()
|
||||
new_camera = camera[0].copy()
|
||||
new_camera.is_connected = False
|
||||
|
||||
mock_msg = Mock()
|
||||
|
@ -436,7 +473,7 @@ async def test_camera_ws_update_offline(
|
|||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "unavailable"
|
||||
|
||||
# camera comes back online
|
||||
|
@ -450,5 +487,5 @@ async def test_camera_ws_update_offline(
|
|||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
state = hass.states.get(camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
|
241
tests/components/unifiprotect/test_media_player.py
Normal file
241
tests/components/unifiprotect/test_media_player.py
Normal file
|
@ -0,0 +1,241 @@
|
|||
"""Test the UniFi Protect button platform."""
|
||||
# pylint: disable=protected-access
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import copy
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from pyunifiprotect.data import Camera
|
||||
from pyunifiprotect.exceptions import StreamError
|
||||
|
||||
from homeassistant.components.media_player.const import (
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
)
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
STATE_IDLE,
|
||||
STATE_PLAYING,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MockEntityFixture
|
||||
|
||||
|
||||
@pytest.fixture(name="camera")
|
||||
async def camera_fixture(
|
||||
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera
|
||||
):
|
||||
"""Fixture for a single camera with only the media_player platform active, camera has speaker."""
|
||||
|
||||
# disable pydantic validation so mocking can happen
|
||||
Camera.__config__.validate_assignment = False
|
||||
|
||||
camera_obj = mock_camera.copy(deep=True)
|
||||
camera_obj._api = mock_entry.api
|
||||
camera_obj.channels[0]._api = mock_entry.api
|
||||
camera_obj.channels[1]._api = mock_entry.api
|
||||
camera_obj.channels[2]._api = mock_entry.api
|
||||
camera_obj.name = "Test Camera"
|
||||
camera_obj.feature_flags.has_speaker = True
|
||||
|
||||
mock_entry.api.bootstrap.cameras = {
|
||||
camera_obj.id: camera_obj,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifiprotect.PLATFORMS", [Platform.MEDIA_PLAYER]
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
assert len(entity_registry.entities) == 1
|
||||
|
||||
yield (camera_obj, "media_player.test_camera_speaker")
|
||||
|
||||
Camera.__config__.validate_assignment = True
|
||||
|
||||
|
||||
async def test_media_player_setup(
|
||||
hass: HomeAssistant,
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test media_player entity setup."""
|
||||
|
||||
unique_id = f"{camera[0].id}_speaker"
|
||||
entity_id = camera[1]
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entity = entity_registry.async_get(entity_id)
|
||||
assert entity
|
||||
assert entity.unique_id == unique_id
|
||||
|
||||
expected_volume = float(camera[0].speaker_settings.volume / 100)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_IDLE
|
||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] == 7684
|
||||
assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == "music"
|
||||
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == expected_volume
|
||||
|
||||
|
||||
async def test_media_player_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test media_player entity update."""
|
||||
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = camera[0].copy()
|
||||
new_camera.talkback_stream = Mock()
|
||||
new_camera.talkback_stream.is_running = True
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.new_obj = new_camera
|
||||
|
||||
new_bootstrap.cameras = {new_camera.id: new_camera}
|
||||
mock_entry.api.bootstrap = new_bootstrap
|
||||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(camera[1])
|
||||
assert state
|
||||
assert state.state == STATE_PLAYING
|
||||
|
||||
|
||||
async def test_media_player_set_volume(
|
||||
hass: HomeAssistant,
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test media_player entity test set_volume_level."""
|
||||
|
||||
camera[0].__fields__["set_speaker_volume"] = Mock()
|
||||
camera[0].set_speaker_volume = AsyncMock()
|
||||
|
||||
await hass.services.async_call(
|
||||
"media_player",
|
||||
"volume_set",
|
||||
{ATTR_ENTITY_ID: camera[1], "volume_level": 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
camera[0].set_speaker_volume.assert_called_once_with(50)
|
||||
|
||||
|
||||
async def test_media_player_stop(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test media_player entity test media_stop."""
|
||||
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = camera[0].copy()
|
||||
new_camera.talkback_stream = AsyncMock()
|
||||
new_camera.talkback_stream.is_running = True
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.new_obj = new_camera
|
||||
|
||||
new_bootstrap.cameras = {new_camera.id: new_camera}
|
||||
mock_entry.api.bootstrap = new_bootstrap
|
||||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
"media_player",
|
||||
"media_stop",
|
||||
{ATTR_ENTITY_ID: camera[1]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
new_camera.talkback_stream.stop.assert_called_once()
|
||||
|
||||
|
||||
async def test_media_player_play(
|
||||
hass: HomeAssistant,
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test media_player entity test play_media."""
|
||||
|
||||
camera[0].__fields__["stop_audio"] = Mock()
|
||||
camera[0].__fields__["play_audio"] = Mock()
|
||||
camera[0].__fields__["wait_until_audio_completes"] = Mock()
|
||||
camera[0].stop_audio = AsyncMock()
|
||||
camera[0].play_audio = AsyncMock()
|
||||
camera[0].wait_until_audio_completes = AsyncMock()
|
||||
|
||||
await hass.services.async_call(
|
||||
"media_player",
|
||||
"play_media",
|
||||
{
|
||||
ATTR_ENTITY_ID: camera[1],
|
||||
"media_content_id": "/test.mp3",
|
||||
"media_content_type": "music",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
camera[0].play_audio.assert_called_once_with("/test.mp3", blocking=False)
|
||||
camera[0].wait_until_audio_completes.assert_called_once()
|
||||
|
||||
|
||||
async def test_media_player_play_invalid(
|
||||
hass: HomeAssistant,
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test media_player entity test play_media, not music."""
|
||||
|
||||
camera[0].__fields__["play_audio"] = Mock()
|
||||
camera[0].play_audio = AsyncMock()
|
||||
|
||||
await hass.services.async_call(
|
||||
"media_player",
|
||||
"play_media",
|
||||
{
|
||||
ATTR_ENTITY_ID: camera[1],
|
||||
"media_content_id": "/test.png",
|
||||
"media_content_type": "image",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert not camera[0].play_audio.called
|
||||
|
||||
|
||||
async def test_media_player_play_error(
|
||||
hass: HomeAssistant,
|
||||
camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test media_player entity test play_media, not music."""
|
||||
|
||||
camera[0].__fields__["play_audio"] = Mock()
|
||||
camera[0].__fields__["wait_until_audio_completes"] = Mock()
|
||||
camera[0].play_audio = AsyncMock(side_effect=StreamError)
|
||||
camera[0].wait_until_audio_completes = AsyncMock()
|
||||
|
||||
await hass.services.async_call(
|
||||
"media_player",
|
||||
"play_media",
|
||||
{
|
||||
ATTR_ENTITY_ID: camera[1],
|
||||
"media_content_id": "/test.mp3",
|
||||
"media_content_type": "music",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert camera[0].play_audio.called
|
||||
assert not camera[0].wait_until_audio_completes.called
|
Loading…
Add table
Reference in a new issue