mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Add live view camera entity to ring integration (#127579)
This commit is contained in:
parent
9510ef56f9
commit
147679f803
@ -9,6 +9,7 @@ import uuid
|
|||||||
|
|
||||||
from ring_doorbell import Auth, Ring, RingDevices
|
from ring_doorbell import Auth, Ring, RingDevices
|
||||||
|
|
||||||
|
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN
|
from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
@ -70,8 +71,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
|
|||||||
)
|
)
|
||||||
ring = Ring(auth)
|
ring = Ring(auth)
|
||||||
|
|
||||||
await _migrate_old_unique_ids(hass, entry.entry_id)
|
|
||||||
|
|
||||||
devices_coordinator = RingDataCoordinator(hass, ring)
|
devices_coordinator = RingDataCoordinator(hass, ring)
|
||||||
listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS)
|
listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS)
|
||||||
listen_coordinator = RingListenCoordinator(
|
listen_coordinator = RingListenCoordinator(
|
||||||
@ -104,11 +103,25 @@ async def async_remove_config_entry_device(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old config entry."""
|
||||||
|
entry_version = entry.version
|
||||||
|
entry_minor_version = entry.minor_version
|
||||||
|
entry_id = entry.entry_id
|
||||||
|
|
||||||
|
new_minor_version = 2
|
||||||
|
if entry_version == 1 and entry_minor_version == 1:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating from version %s.%s", entry_version, entry_minor_version
|
||||||
|
)
|
||||||
|
# Migrate non-str unique ids
|
||||||
|
# This step used to run unconditionally from async_setup_entry
|
||||||
entity_registry = er.async_get(hass)
|
entity_registry = er.async_get(hass)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
|
def _async_str_unique_id_migrator(
|
||||||
|
entity_entry: er.RegistryEntry,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
# Old format for camera and light was int
|
# Old format for camera and light was int
|
||||||
unique_id = cast(str | int, entity_entry.unique_id)
|
unique_id = cast(str | int, entity_entry.unique_id)
|
||||||
if isinstance(unique_id, int):
|
if isinstance(unique_id, int):
|
||||||
@ -127,19 +140,9 @@ async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
|
|||||||
return {"new_unique_id": new_unique_id}
|
return {"new_unique_id": new_unique_id}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
await er.async_migrate_entries(hass, entry_id, _async_migrator)
|
await er.async_migrate_entries(hass, entry_id, _async_str_unique_id_migrator)
|
||||||
|
|
||||||
|
# Migrate the hardware id
|
||||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Migrate old config entry."""
|
|
||||||
entry_version = entry.version
|
|
||||||
entry_minor_version = entry.minor_version
|
|
||||||
|
|
||||||
new_minor_version = 2
|
|
||||||
if entry_version == 1 and entry_minor_version == 1:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Migrating from version %s.%s", entry_version, entry_minor_version
|
|
||||||
)
|
|
||||||
hardware_id = str(uuid.uuid4())
|
hardware_id = str(uuid.uuid4())
|
||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
entry,
|
entry,
|
||||||
@ -149,4 +152,34 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Migration to version %s.%s complete", entry_version, new_minor_version
|
"Migration to version %s.%s complete", entry_version, new_minor_version
|
||||||
)
|
)
|
||||||
|
|
||||||
|
entry_minor_version = entry.minor_version
|
||||||
|
new_minor_version = 3
|
||||||
|
if entry_version == 1 and entry_minor_version == 2:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating from version %s.%s", entry_version, entry_minor_version
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_camera_unique_id_migrator(
|
||||||
|
entity_entry: er.RegistryEntry,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
# Migrate camera unique ids to append -last
|
||||||
|
if entity_entry.domain == CAMERA_DOMAIN and not isinstance(
|
||||||
|
cast(str | int, entity_entry.unique_id), int
|
||||||
|
):
|
||||||
|
new_unique_id = f"{entity_entry.unique_id}-last_recording"
|
||||||
|
return {"new_unique_id": new_unique_id}
|
||||||
|
return None
|
||||||
|
|
||||||
|
await er.async_migrate_entries(hass, entry_id, _async_camera_unique_id_migrator)
|
||||||
|
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
minor_version=new_minor_version,
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to version %s.%s complete", entry_version, new_minor_version
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -2,24 +2,37 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Generic
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from haffmpeg.camera import CameraMjpeg
|
from haffmpeg.camera import CameraMjpeg
|
||||||
from ring_doorbell import RingDoorBell
|
from ring_doorbell import RingDoorBell
|
||||||
|
from ring_doorbell.webrtcstream import RingWebRtcMessage
|
||||||
|
|
||||||
from homeassistant.components import ffmpeg
|
from homeassistant.components import ffmpeg
|
||||||
from homeassistant.components.camera import Camera
|
from homeassistant.components.camera import (
|
||||||
|
Camera,
|
||||||
|
CameraEntityDescription,
|
||||||
|
CameraEntityFeature,
|
||||||
|
RTCIceCandidateInit,
|
||||||
|
WebRTCAnswer,
|
||||||
|
WebRTCCandidate,
|
||||||
|
WebRTCError,
|
||||||
|
WebRTCSendMessage,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import RingConfigEntry
|
from . import RingConfigEntry
|
||||||
from .coordinator import RingDataCoordinator
|
from .coordinator import RingDataCoordinator
|
||||||
from .entity import RingEntity, exception_wrap
|
from .entity import RingDeviceT, RingEntity, exception_wrap
|
||||||
|
|
||||||
FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
|
FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
|
||||||
MOTION_DETECTION_CAPABILITY = "motion_detection"
|
MOTION_DETECTION_CAPABILITY = "motion_detection"
|
||||||
@ -27,6 +40,34 @@ MOTION_DETECTION_CAPABILITY = "motion_detection"
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class RingCameraEntityDescription(CameraEntityDescription, Generic[RingDeviceT]):
|
||||||
|
"""Base class for event entity description."""
|
||||||
|
|
||||||
|
exists_fn: Callable[[RingDoorBell], bool]
|
||||||
|
live_stream: bool
|
||||||
|
motion_detection: bool
|
||||||
|
|
||||||
|
|
||||||
|
CAMERA_DESCRIPTIONS: tuple[RingCameraEntityDescription, ...] = (
|
||||||
|
RingCameraEntityDescription(
|
||||||
|
key="live_view",
|
||||||
|
translation_key="live_view",
|
||||||
|
exists_fn=lambda _: True,
|
||||||
|
live_stream=True,
|
||||||
|
motion_detection=False,
|
||||||
|
),
|
||||||
|
RingCameraEntityDescription(
|
||||||
|
key="last_recording",
|
||||||
|
translation_key="last_recording",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
exists_fn=lambda camera: camera.has_subscription,
|
||||||
|
live_stream=False,
|
||||||
|
motion_detection=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: RingConfigEntry,
|
entry: RingConfigEntry,
|
||||||
@ -38,9 +79,10 @@ async def async_setup_entry(
|
|||||||
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
|
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
|
||||||
|
|
||||||
cams = [
|
cams = [
|
||||||
RingCam(camera, devices_coordinator, ffmpeg_manager)
|
RingCam(camera, devices_coordinator, description, ffmpeg_manager=ffmpeg_manager)
|
||||||
|
for description in CAMERA_DESCRIPTIONS
|
||||||
for camera in ring_data.devices.video_devices
|
for camera in ring_data.devices.video_devices
|
||||||
if camera.has_subscription
|
if description.exists_fn(camera)
|
||||||
]
|
]
|
||||||
|
|
||||||
async_add_entities(cams)
|
async_add_entities(cams)
|
||||||
@ -49,26 +91,31 @@ async def async_setup_entry(
|
|||||||
class RingCam(RingEntity[RingDoorBell], Camera):
|
class RingCam(RingEntity[RingDoorBell], Camera):
|
||||||
"""An implementation of a Ring Door Bell camera."""
|
"""An implementation of a Ring Door Bell camera."""
|
||||||
|
|
||||||
_attr_name = None
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device: RingDoorBell,
|
device: RingDoorBell,
|
||||||
coordinator: RingDataCoordinator,
|
coordinator: RingDataCoordinator,
|
||||||
|
description: RingCameraEntityDescription,
|
||||||
|
*,
|
||||||
ffmpeg_manager: ffmpeg.FFmpegManager,
|
ffmpeg_manager: ffmpeg.FFmpegManager,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a Ring Door Bell camera."""
|
"""Initialize a Ring Door Bell camera."""
|
||||||
super().__init__(device, coordinator)
|
super().__init__(device, coordinator)
|
||||||
|
self.entity_description = description
|
||||||
Camera.__init__(self)
|
Camera.__init__(self)
|
||||||
self._ffmpeg_manager = ffmpeg_manager
|
self._ffmpeg_manager = ffmpeg_manager
|
||||||
self._last_event: dict[str, Any] | None = None
|
self._last_event: dict[str, Any] | None = None
|
||||||
self._last_video_id: int | None = None
|
self._last_video_id: int | None = None
|
||||||
self._video_url: str | None = None
|
self._video_url: str | None = None
|
||||||
self._image: bytes | None = None
|
self._images: dict[tuple[int | None, int | None], bytes] = {}
|
||||||
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
|
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
|
||||||
self._attr_unique_id = str(device.id)
|
self._attr_unique_id = f"{device.id}-{description.key}"
|
||||||
if device.has_capability(MOTION_DETECTION_CAPABILITY):
|
if description.motion_detection and device.has_capability(
|
||||||
|
MOTION_DETECTION_CAPABILITY
|
||||||
|
):
|
||||||
self._attr_motion_detection_enabled = device.motion_detection
|
self._attr_motion_detection_enabled = device.motion_detection
|
||||||
|
if description.live_stream:
|
||||||
|
self._attr_supported_features |= CameraEntityFeature.STREAM
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_coordinator_update(self) -> None:
|
def _handle_coordinator_update(self) -> None:
|
||||||
@ -86,7 +133,7 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
|||||||
self._last_event = None
|
self._last_event = None
|
||||||
self._last_video_id = None
|
self._last_video_id = None
|
||||||
self._video_url = None
|
self._video_url = None
|
||||||
self._image = None
|
self._images = {}
|
||||||
self._expires_at = dt_util.utcnow()
|
self._expires_at = dt_util.utcnow()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@ -102,7 +149,8 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
|||||||
self, width: int | None = None, height: int | None = None
|
self, width: int | None = None, height: int | None = None
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""Return a still image response from the camera."""
|
"""Return a still image response from the camera."""
|
||||||
if self._image is None and self._video_url is not None:
|
key = (width, height)
|
||||||
|
if not (image := self._images.get(key)) and self._video_url is not None:
|
||||||
image = await ffmpeg.async_get_image(
|
image = await ffmpeg.async_get_image(
|
||||||
self.hass,
|
self.hass,
|
||||||
self._video_url,
|
self._video_url,
|
||||||
@ -111,9 +159,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if image:
|
if image:
|
||||||
self._image = image
|
self._images[key] = image
|
||||||
|
|
||||||
return self._image
|
return image
|
||||||
|
|
||||||
async def handle_async_mjpeg_stream(
|
async def handle_async_mjpeg_stream(
|
||||||
self, request: web.Request
|
self, request: web.Request
|
||||||
@ -136,6 +184,47 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
|||||||
finally:
|
finally:
|
||||||
await stream.close()
|
await stream.close()
|
||||||
|
|
||||||
|
async def async_handle_async_webrtc_offer(
|
||||||
|
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
|
||||||
|
) -> None:
|
||||||
|
"""Return the source of the stream."""
|
||||||
|
|
||||||
|
def message_wrapper(ring_message: RingWebRtcMessage) -> None:
|
||||||
|
if ring_message.error_code:
|
||||||
|
msg = ring_message.error_message or ""
|
||||||
|
send_message(WebRTCError(ring_message.error_code, msg))
|
||||||
|
elif ring_message.answer:
|
||||||
|
send_message(WebRTCAnswer(ring_message.answer))
|
||||||
|
elif ring_message.candidate:
|
||||||
|
send_message(
|
||||||
|
WebRTCCandidate(
|
||||||
|
RTCIceCandidateInit(
|
||||||
|
ring_message.candidate,
|
||||||
|
sdp_m_line_index=ring_message.sdp_m_line_index or 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._device.generate_async_webrtc_stream(
|
||||||
|
offer_sdp, session_id, message_wrapper, keep_alive_timeout=None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_on_webrtc_candidate(
|
||||||
|
self, session_id: str, candidate: RTCIceCandidateInit
|
||||||
|
) -> None:
|
||||||
|
"""Handle a WebRTC candidate."""
|
||||||
|
if candidate.sdp_m_line_index is None:
|
||||||
|
msg = "The sdp_m_line_index is required for ring webrtc streaming"
|
||||||
|
raise HomeAssistantError(msg)
|
||||||
|
await self._device.on_webrtc_candidate(
|
||||||
|
session_id, candidate.candidate, candidate.sdp_m_line_index
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def close_webrtc_session(self, session_id: str) -> None:
|
||||||
|
"""Close a WebRTC session."""
|
||||||
|
self._device.sync_close_webrtc_stream(session_id)
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Update camera entity and refresh attributes."""
|
"""Update camera entity and refresh attributes."""
|
||||||
if (
|
if (
|
||||||
@ -157,7 +246,7 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self._last_video_id != self._last_event["id"]:
|
if self._last_video_id != self._last_event["id"]:
|
||||||
self._image = None
|
self._images = {}
|
||||||
|
|
||||||
self._video_url = await self._async_get_video()
|
self._video_url = await self._async_get_video()
|
||||||
|
|
||||||
|
@ -33,4 +33,4 @@ SCAN_INTERVAL = timedelta(minutes=1)
|
|||||||
CONF_2FA = "2fa"
|
CONF_2FA = "2fa"
|
||||||
CONF_LISTEN_CREDENTIALS = "listen_token"
|
CONF_LISTEN_CREDENTIALS = "listen_token"
|
||||||
|
|
||||||
CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 2
|
CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 3
|
||||||
|
@ -124,6 +124,14 @@
|
|||||||
"motion_detection": {
|
"motion_detection": {
|
||||||
"name": "Motion detection"
|
"name": "Motion detection"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"live_view": {
|
||||||
|
"name": "Live view"
|
||||||
|
},
|
||||||
|
"last_recording": {
|
||||||
|
"name": "Last recording"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# serializer version: 1
|
# serializer version: 1
|
||||||
# name: test_states[camera.front-entry]
|
# name: test_states[camera.front_door_last_recording-entry]
|
||||||
EntityRegistryEntrySnapshot({
|
EntityRegistryEntrySnapshot({
|
||||||
'aliases': set({
|
'aliases': set({
|
||||||
}),
|
}),
|
||||||
@ -11,7 +11,7 @@
|
|||||||
'disabled_by': None,
|
'disabled_by': None,
|
||||||
'domain': 'camera',
|
'domain': 'camera',
|
||||||
'entity_category': None,
|
'entity_category': None,
|
||||||
'entity_id': 'camera.front',
|
'entity_id': 'camera.front_door_last_recording',
|
||||||
'has_entity_name': True,
|
'has_entity_name': True,
|
||||||
'hidden_by': None,
|
'hidden_by': None,
|
||||||
'icon': None,
|
'icon': None,
|
||||||
@ -23,88 +23,36 @@
|
|||||||
}),
|
}),
|
||||||
'original_device_class': None,
|
'original_device_class': None,
|
||||||
'original_icon': None,
|
'original_icon': None,
|
||||||
'original_name': None,
|
'original_name': 'Last recording',
|
||||||
'platform': 'ring',
|
'platform': 'ring',
|
||||||
'previous_unique_id': None,
|
'previous_unique_id': None,
|
||||||
'supported_features': 0,
|
'supported_features': 0,
|
||||||
'translation_key': None,
|
'translation_key': 'last_recording',
|
||||||
'unique_id': '765432',
|
'unique_id': '987654-last_recording',
|
||||||
'unit_of_measurement': None,
|
'unit_of_measurement': None,
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_states[camera.front-state]
|
# name: test_states[camera.front_door_last_recording-state]
|
||||||
StateSnapshot({
|
StateSnapshot({
|
||||||
'attributes': ReadOnlyDict({
|
'attributes': ReadOnlyDict({
|
||||||
'access_token': '1caab5c3b3',
|
'access_token': '1caab5c3b3',
|
||||||
'attribution': 'Data provided by Ring.com',
|
'attribution': 'Data provided by Ring.com',
|
||||||
'entity_picture': '/api/camera_proxy/camera.front?token=1caab5c3b3',
|
'entity_picture': '/api/camera_proxy/camera.front_door_last_recording?token=1caab5c3b3',
|
||||||
'friendly_name': 'Front',
|
'friendly_name': 'Front Door Last recording',
|
||||||
'last_video_id': None,
|
|
||||||
'supported_features': <CameraEntityFeature: 0>,
|
|
||||||
'video_url': None,
|
|
||||||
}),
|
|
||||||
'context': <ANY>,
|
|
||||||
'entity_id': 'camera.front',
|
|
||||||
'last_changed': <ANY>,
|
|
||||||
'last_reported': <ANY>,
|
|
||||||
'last_updated': <ANY>,
|
|
||||||
'state': 'idle',
|
|
||||||
})
|
|
||||||
# ---
|
|
||||||
# name: test_states[camera.front_door-entry]
|
|
||||||
EntityRegistryEntrySnapshot({
|
|
||||||
'aliases': set({
|
|
||||||
}),
|
|
||||||
'area_id': None,
|
|
||||||
'capabilities': None,
|
|
||||||
'config_entry_id': <ANY>,
|
|
||||||
'device_class': None,
|
|
||||||
'device_id': <ANY>,
|
|
||||||
'disabled_by': None,
|
|
||||||
'domain': 'camera',
|
|
||||||
'entity_category': None,
|
|
||||||
'entity_id': 'camera.front_door',
|
|
||||||
'has_entity_name': True,
|
|
||||||
'hidden_by': None,
|
|
||||||
'icon': None,
|
|
||||||
'id': <ANY>,
|
|
||||||
'labels': set({
|
|
||||||
}),
|
|
||||||
'name': None,
|
|
||||||
'options': dict({
|
|
||||||
}),
|
|
||||||
'original_device_class': None,
|
|
||||||
'original_icon': None,
|
|
||||||
'original_name': None,
|
|
||||||
'platform': 'ring',
|
|
||||||
'previous_unique_id': None,
|
|
||||||
'supported_features': 0,
|
|
||||||
'translation_key': None,
|
|
||||||
'unique_id': '987654',
|
|
||||||
'unit_of_measurement': None,
|
|
||||||
})
|
|
||||||
# ---
|
|
||||||
# name: test_states[camera.front_door-state]
|
|
||||||
StateSnapshot({
|
|
||||||
'attributes': ReadOnlyDict({
|
|
||||||
'access_token': '1caab5c3b3',
|
|
||||||
'attribution': 'Data provided by Ring.com',
|
|
||||||
'entity_picture': '/api/camera_proxy/camera.front_door?token=1caab5c3b3',
|
|
||||||
'friendly_name': 'Front Door',
|
|
||||||
'last_video_id': None,
|
'last_video_id': None,
|
||||||
'motion_detection': True,
|
'motion_detection': True,
|
||||||
'supported_features': <CameraEntityFeature: 0>,
|
'supported_features': <CameraEntityFeature: 0>,
|
||||||
'video_url': None,
|
'video_url': None,
|
||||||
}),
|
}),
|
||||||
'context': <ANY>,
|
'context': <ANY>,
|
||||||
'entity_id': 'camera.front_door',
|
'entity_id': 'camera.front_door_last_recording',
|
||||||
'last_changed': <ANY>,
|
'last_changed': <ANY>,
|
||||||
'last_reported': <ANY>,
|
'last_reported': <ANY>,
|
||||||
'last_updated': <ANY>,
|
'last_updated': <ANY>,
|
||||||
'state': 'idle',
|
'state': 'idle',
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_states[camera.internal-entry]
|
# name: test_states[camera.front_door_live_view-entry]
|
||||||
EntityRegistryEntrySnapshot({
|
EntityRegistryEntrySnapshot({
|
||||||
'aliases': set({
|
'aliases': set({
|
||||||
}),
|
}),
|
||||||
@ -116,7 +64,7 @@
|
|||||||
'disabled_by': None,
|
'disabled_by': None,
|
||||||
'domain': 'camera',
|
'domain': 'camera',
|
||||||
'entity_category': None,
|
'entity_category': None,
|
||||||
'entity_id': 'camera.internal',
|
'entity_id': 'camera.front_door_live_view',
|
||||||
'has_entity_name': True,
|
'has_entity_name': True,
|
||||||
'hidden_by': None,
|
'hidden_by': None,
|
||||||
'icon': None,
|
'icon': None,
|
||||||
@ -128,29 +76,240 @@
|
|||||||
}),
|
}),
|
||||||
'original_device_class': None,
|
'original_device_class': None,
|
||||||
'original_icon': None,
|
'original_icon': None,
|
||||||
'original_name': None,
|
'original_name': 'Live view',
|
||||||
'platform': 'ring',
|
'platform': 'ring',
|
||||||
'previous_unique_id': None,
|
'previous_unique_id': None,
|
||||||
'supported_features': 0,
|
'supported_features': <CameraEntityFeature: 2>,
|
||||||
'translation_key': None,
|
'translation_key': 'live_view',
|
||||||
'unique_id': '345678',
|
'unique_id': '987654-live_view',
|
||||||
'unit_of_measurement': None,
|
'unit_of_measurement': None,
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
# name: test_states[camera.internal-state]
|
# name: test_states[camera.front_door_live_view-state]
|
||||||
StateSnapshot({
|
StateSnapshot({
|
||||||
'attributes': ReadOnlyDict({
|
'attributes': ReadOnlyDict({
|
||||||
'access_token': '1caab5c3b3',
|
'access_token': '1caab5c3b3',
|
||||||
'attribution': 'Data provided by Ring.com',
|
'attribution': 'Data provided by Ring.com',
|
||||||
'entity_picture': '/api/camera_proxy/camera.internal?token=1caab5c3b3',
|
'entity_picture': '/api/camera_proxy/camera.front_door_live_view?token=1caab5c3b3',
|
||||||
'friendly_name': 'Internal',
|
'friendly_name': 'Front Door Live view',
|
||||||
|
'frontend_stream_type': <StreamType.WEB_RTC: 'web_rtc'>,
|
||||||
'last_video_id': None,
|
'last_video_id': None,
|
||||||
'motion_detection': True,
|
'supported_features': <CameraEntityFeature: 2>,
|
||||||
'supported_features': <CameraEntityFeature: 0>,
|
|
||||||
'video_url': None,
|
'video_url': None,
|
||||||
}),
|
}),
|
||||||
'context': <ANY>,
|
'context': <ANY>,
|
||||||
'entity_id': 'camera.internal',
|
'entity_id': 'camera.front_door_live_view',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'idle',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.front_last_recording-entry]
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': None,
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'camera',
|
||||||
|
'entity_category': None,
|
||||||
|
'entity_id': 'camera.front_last_recording',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': None,
|
||||||
|
'original_name': 'Last recording',
|
||||||
|
'platform': 'ring',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': 0,
|
||||||
|
'translation_key': 'last_recording',
|
||||||
|
'unique_id': '765432-last_recording',
|
||||||
|
'unit_of_measurement': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.front_last_recording-state]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'access_token': '1caab5c3b3',
|
||||||
|
'attribution': 'Data provided by Ring.com',
|
||||||
|
'entity_picture': '/api/camera_proxy/camera.front_last_recording?token=1caab5c3b3',
|
||||||
|
'friendly_name': 'Front Last recording',
|
||||||
|
'last_video_id': None,
|
||||||
|
'supported_features': <CameraEntityFeature: 0>,
|
||||||
|
'video_url': None,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'camera.front_last_recording',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'idle',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.front_live_view-entry]
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': None,
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'camera',
|
||||||
|
'entity_category': None,
|
||||||
|
'entity_id': 'camera.front_live_view',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': None,
|
||||||
|
'original_name': 'Live view',
|
||||||
|
'platform': 'ring',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': <CameraEntityFeature: 2>,
|
||||||
|
'translation_key': 'live_view',
|
||||||
|
'unique_id': '765432-live_view',
|
||||||
|
'unit_of_measurement': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.front_live_view-state]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'access_token': '1caab5c3b3',
|
||||||
|
'attribution': 'Data provided by Ring.com',
|
||||||
|
'entity_picture': '/api/camera_proxy/camera.front_live_view?token=1caab5c3b3',
|
||||||
|
'friendly_name': 'Front Live view',
|
||||||
|
'frontend_stream_type': <StreamType.WEB_RTC: 'web_rtc'>,
|
||||||
|
'last_video_id': None,
|
||||||
|
'supported_features': <CameraEntityFeature: 2>,
|
||||||
|
'video_url': None,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'camera.front_live_view',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'idle',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.internal_last_recording-entry]
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': None,
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'camera',
|
||||||
|
'entity_category': None,
|
||||||
|
'entity_id': 'camera.internal_last_recording',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': None,
|
||||||
|
'original_name': 'Last recording',
|
||||||
|
'platform': 'ring',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': 0,
|
||||||
|
'translation_key': 'last_recording',
|
||||||
|
'unique_id': '345678-last_recording',
|
||||||
|
'unit_of_measurement': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.internal_last_recording-state]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'access_token': '1caab5c3b3',
|
||||||
|
'attribution': 'Data provided by Ring.com',
|
||||||
|
'entity_picture': '/api/camera_proxy/camera.internal_last_recording?token=1caab5c3b3',
|
||||||
|
'friendly_name': 'Internal Last recording',
|
||||||
|
'last_video_id': None,
|
||||||
|
'motion_detection': True,
|
||||||
|
'supported_features': <CameraEntityFeature: 0>,
|
||||||
|
'video_url': None,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'camera.internal_last_recording',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'idle',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.internal_live_view-entry]
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': None,
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'camera',
|
||||||
|
'entity_category': None,
|
||||||
|
'entity_id': 'camera.internal_live_view',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': None,
|
||||||
|
'original_name': 'Live view',
|
||||||
|
'platform': 'ring',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': <CameraEntityFeature: 2>,
|
||||||
|
'translation_key': 'live_view',
|
||||||
|
'unique_id': '345678-live_view',
|
||||||
|
'unit_of_measurement': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_states[camera.internal_live_view-state]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'access_token': '1caab5c3b3',
|
||||||
|
'attribution': 'Data provided by Ring.com',
|
||||||
|
'entity_picture': '/api/camera_proxy/camera.internal_live_view?token=1caab5c3b3',
|
||||||
|
'friendly_name': 'Internal Live view',
|
||||||
|
'frontend_stream_type': <StreamType.WEB_RTC: 'web_rtc'>,
|
||||||
|
'last_video_id': None,
|
||||||
|
'supported_features': <CameraEntityFeature: 2>,
|
||||||
|
'video_url': None,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'camera.internal_live_view',
|
||||||
'last_changed': <ANY>,
|
'last_changed': <ANY>,
|
||||||
'last_reported': <ANY>,
|
'last_reported': <ANY>,
|
||||||
'last_updated': <ANY>,
|
'last_updated': <ANY>,
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
"""The tests for the Ring switch platform."""
|
"""The tests for the Ring switch platform."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
from aiohttp.test_utils import make_mocked_request
|
from aiohttp.test_utils import make_mocked_request
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
import ring_doorbell
|
import ring_doorbell
|
||||||
|
from ring_doorbell.webrtcstream import RingWebRtcMessage
|
||||||
from syrupy.assertion import SnapshotAssertion
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components import camera
|
from homeassistant.components.camera import (
|
||||||
|
CameraEntityFeature,
|
||||||
|
StreamType,
|
||||||
|
async_get_image,
|
||||||
|
async_get_mjpeg_stream,
|
||||||
|
get_camera_from_entity_id,
|
||||||
|
)
|
||||||
from homeassistant.components.ring.camera import FORCE_REFRESH_INTERVAL
|
from homeassistant.components.ring.camera import FORCE_REFRESH_INTERVAL
|
||||||
from homeassistant.components.ring.const import SCAN_INTERVAL
|
from homeassistant.components.ring.const import SCAN_INTERVAL
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH
|
from homeassistant.config_entries import SOURCE_REAUTH
|
||||||
@ -19,8 +27,10 @@ from homeassistant.helpers import entity_registry as er
|
|||||||
from homeassistant.util.aiohttp import MockStreamReader
|
from homeassistant.util.aiohttp import MockStreamReader
|
||||||
|
|
||||||
from .common import MockConfigEntry, setup_platform
|
from .common import MockConfigEntry, setup_platform
|
||||||
|
from .device_mocks import FRONT_DEVICE_ID
|
||||||
|
|
||||||
from tests.common import async_fire_time_changed, snapshot_platform
|
from tests.common import async_fire_time_changed, snapshot_platform
|
||||||
|
from tests.typing import WebSocketGenerator
|
||||||
|
|
||||||
SMALLEST_VALID_JPEG = (
|
SMALLEST_VALID_JPEG = (
|
||||||
"ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060"
|
"ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060"
|
||||||
@ -30,6 +40,7 @@ SMALLEST_VALID_JPEG = (
|
|||||||
SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG)
|
SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||||
async def test_states(
|
async def test_states(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_ring_client: Mock,
|
mock_ring_client: Mock,
|
||||||
@ -48,11 +59,12 @@ async def test_states(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("entity_name", "expected_state", "friendly_name"),
|
("entity_name", "expected_state", "friendly_name"),
|
||||||
[
|
[
|
||||||
("camera.internal", True, "Internal"),
|
("camera.internal_last_recording", True, "Internal Last recording"),
|
||||||
("camera.front", None, "Front"),
|
("camera.front_last_recording", None, "Front Last recording"),
|
||||||
],
|
],
|
||||||
ids=["On", "Off"],
|
ids=["On", "Off"],
|
||||||
)
|
)
|
||||||
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||||
async def test_camera_motion_detection_state_reports_correctly(
|
async def test_camera_motion_detection_state_reports_correctly(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_ring_client,
|
mock_ring_client,
|
||||||
@ -68,40 +80,43 @@ async def test_camera_motion_detection_state_reports_correctly(
|
|||||||
assert state.attributes.get("friendly_name") == friendly_name
|
assert state.attributes.get("friendly_name") == friendly_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||||
async def test_camera_motion_detection_can_be_turned_on_and_off(
|
async def test_camera_motion_detection_can_be_turned_on_and_off(
|
||||||
hass: HomeAssistant, mock_ring_client
|
hass: HomeAssistant,
|
||||||
|
mock_ring_client,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Tests the siren turns on correctly."""
|
"""Tests the siren turns on correctly."""
|
||||||
await setup_platform(hass, Platform.CAMERA)
|
await setup_platform(hass, Platform.CAMERA)
|
||||||
|
|
||||||
state = hass.states.get("camera.front")
|
state = hass.states.get("camera.front_last_recording")
|
||||||
assert state.attributes.get("motion_detection") is not True
|
assert state.attributes.get("motion_detection") is not True
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
"camera",
|
"camera",
|
||||||
"enable_motion_detection",
|
"enable_motion_detection",
|
||||||
{"entity_id": "camera.front"},
|
{"entity_id": "camera.front_last_recording"},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("camera.front")
|
state = hass.states.get("camera.front_last_recording")
|
||||||
assert state.attributes.get("motion_detection") is True
|
assert state.attributes.get("motion_detection") is True
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
"camera",
|
"camera",
|
||||||
"disable_motion_detection",
|
"disable_motion_detection",
|
||||||
{"entity_id": "camera.front"},
|
{"entity_id": "camera.front_last_recording"},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("camera.front")
|
state = hass.states.get("camera.front_last_recording")
|
||||||
assert state.attributes.get("motion_detection") is None
|
assert state.attributes.get("motion_detection") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||||
async def test_camera_motion_detection_not_supported(
|
async def test_camera_motion_detection_not_supported(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_ring_client,
|
mock_ring_client,
|
||||||
@ -121,21 +136,22 @@ async def test_camera_motion_detection_not_supported(
|
|||||||
|
|
||||||
await setup_platform(hass, Platform.CAMERA)
|
await setup_platform(hass, Platform.CAMERA)
|
||||||
|
|
||||||
state = hass.states.get("camera.front")
|
state = hass.states.get("camera.front_last_recording")
|
||||||
assert state.attributes.get("motion_detection") is None
|
assert state.attributes.get("motion_detection") is None
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
"camera",
|
"camera",
|
||||||
"enable_motion_detection",
|
"enable_motion_detection",
|
||||||
{"entity_id": "camera.front"},
|
{"entity_id": "camera.front_last_recording"},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
state = hass.states.get("camera.front")
|
state = hass.states.get("camera.front_last_recording")
|
||||||
assert state.attributes.get("motion_detection") is None
|
assert state.attributes.get("motion_detection") is None
|
||||||
assert (
|
assert (
|
||||||
"Entity camera.front does not have motion detection capability" in caplog.text
|
"Entity camera.front_last_recording does not have motion detection capability"
|
||||||
|
in caplog.text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -148,6 +164,7 @@ async def test_camera_motion_detection_not_supported(
|
|||||||
],
|
],
|
||||||
ids=["Authentication", "Timeout", "Other"],
|
ids=["Authentication", "Timeout", "Other"],
|
||||||
)
|
)
|
||||||
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||||
async def test_motion_detection_errors_when_turned_on(
|
async def test_motion_detection_errors_when_turned_on(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_ring_client,
|
mock_ring_client,
|
||||||
@ -168,7 +185,7 @@ async def test_motion_detection_errors_when_turned_on(
|
|||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
"camera",
|
"camera",
|
||||||
"enable_motion_detection",
|
"enable_motion_detection",
|
||||||
{"entity_id": "camera.front"},
|
{"entity_id": "camera.front_last_recording"},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
@ -183,6 +200,7 @@ async def test_motion_detection_errors_when_turned_on(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||||
async def test_camera_handle_mjpeg_stream(
|
async def test_camera_handle_mjpeg_stream(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_ring_client,
|
mock_ring_client,
|
||||||
@ -195,7 +213,7 @@ async def test_camera_handle_mjpeg_stream(
|
|||||||
front_camera_mock = mock_ring_devices.get_device(765432)
|
front_camera_mock = mock_ring_devices.get_device(765432)
|
||||||
front_camera_mock.async_recording_url.return_value = None
|
front_camera_mock.async_recording_url.return_value = None
|
||||||
|
|
||||||
state = hass.states.get("camera.front")
|
state = hass.states.get("camera.front_last_recording")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
|
|
||||||
mock_request = make_mocked_request("GET", "/", headers={"token": "x"})
|
mock_request = make_mocked_request("GET", "/", headers={"token": "x"})
|
||||||
@ -203,7 +221,9 @@ async def test_camera_handle_mjpeg_stream(
|
|||||||
# history not updated yet
|
# history not updated yet
|
||||||
front_camera_mock.async_history.assert_not_called()
|
front_camera_mock.async_history.assert_not_called()
|
||||||
front_camera_mock.async_recording_url.assert_not_called()
|
front_camera_mock.async_recording_url.assert_not_called()
|
||||||
stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front")
|
stream = await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.front_last_recording"
|
||||||
|
)
|
||||||
assert stream is None
|
assert stream is None
|
||||||
|
|
||||||
# Video url will be none so no stream
|
# Video url will be none so no stream
|
||||||
@ -211,9 +231,11 @@ async def test_camera_handle_mjpeg_stream(
|
|||||||
async_fire_time_changed(hass)
|
async_fire_time_changed(hass)
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
front_camera_mock.async_history.assert_called_once()
|
front_camera_mock.async_history.assert_called_once()
|
||||||
front_camera_mock.async_recording_url.assert_called_once()
|
front_camera_mock.async_recording_url.assert_called()
|
||||||
|
|
||||||
stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front")
|
stream = await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.front_last_recording"
|
||||||
|
)
|
||||||
assert stream is None
|
assert stream is None
|
||||||
|
|
||||||
# Stop the history updating so we can update the values manually
|
# Stop the history updating so we can update the values manually
|
||||||
@ -222,8 +244,10 @@ async def test_camera_handle_mjpeg_stream(
|
|||||||
freezer.tick(SCAN_INTERVAL)
|
freezer.tick(SCAN_INTERVAL)
|
||||||
async_fire_time_changed(hass)
|
async_fire_time_changed(hass)
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
front_camera_mock.async_recording_url.assert_called_once()
|
front_camera_mock.async_recording_url.assert_called()
|
||||||
stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front")
|
stream = await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.front_last_recording"
|
||||||
|
)
|
||||||
assert stream is None
|
assert stream is None
|
||||||
|
|
||||||
# If the history id hasn't changed the camera will not check again for the video url
|
# If the history id hasn't changed the camera will not check again for the video url
|
||||||
@ -235,13 +259,15 @@ async def test_camera_handle_mjpeg_stream(
|
|||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
front_camera_mock.async_recording_url.assert_not_called()
|
front_camera_mock.async_recording_url.assert_not_called()
|
||||||
|
|
||||||
stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front")
|
stream = await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.front_last_recording"
|
||||||
|
)
|
||||||
assert stream is None
|
assert stream is None
|
||||||
|
|
||||||
freezer.tick(FORCE_REFRESH_INTERVAL)
|
freezer.tick(FORCE_REFRESH_INTERVAL)
|
||||||
async_fire_time_changed(hass)
|
async_fire_time_changed(hass)
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
front_camera_mock.async_recording_url.assert_called_once()
|
front_camera_mock.async_recording_url.assert_called()
|
||||||
|
|
||||||
# Now the stream should be returned
|
# Now the stream should be returned
|
||||||
stream_reader = MockStreamReader(SMALLEST_VALID_JPEG_BYTES)
|
stream_reader = MockStreamReader(SMALLEST_VALID_JPEG_BYTES)
|
||||||
@ -250,7 +276,9 @@ async def test_camera_handle_mjpeg_stream(
|
|||||||
mock_camera.return_value.open_camera = AsyncMock()
|
mock_camera.return_value.open_camera = AsyncMock()
|
||||||
mock_camera.return_value.close = AsyncMock()
|
mock_camera.return_value.close = AsyncMock()
|
||||||
|
|
||||||
stream = await camera.async_get_mjpeg_stream(hass, mock_request, "camera.front")
|
stream = await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.front_last_recording"
|
||||||
|
)
|
||||||
assert stream is not None
|
assert stream is not None
|
||||||
# Check the stream has been read
|
# Check the stream has been read
|
||||||
assert not await stream_reader.read(-1)
|
assert not await stream_reader.read(-1)
|
||||||
@ -267,7 +295,7 @@ async def test_camera_image(
|
|||||||
|
|
||||||
front_camera_mock = mock_ring_devices.get_device(765432)
|
front_camera_mock = mock_ring_devices.get_device(765432)
|
||||||
|
|
||||||
state = hass.states.get("camera.front")
|
state = hass.states.get("camera.front_live_view")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
|
|
||||||
# history not updated yet
|
# history not updated yet
|
||||||
@ -280,7 +308,7 @@ async def test_camera_image(
|
|||||||
),
|
),
|
||||||
pytest.raises(HomeAssistantError),
|
pytest.raises(HomeAssistantError),
|
||||||
):
|
):
|
||||||
image = await camera.async_get_image(hass, "camera.front")
|
image = await async_get_image(hass, "camera.front_live_view")
|
||||||
|
|
||||||
freezer.tick(SCAN_INTERVAL)
|
freezer.tick(SCAN_INTERVAL)
|
||||||
async_fire_time_changed(hass)
|
async_fire_time_changed(hass)
|
||||||
@ -293,5 +321,145 @@ async def test_camera_image(
|
|||||||
"homeassistant.components.ring.camera.ffmpeg.async_get_image",
|
"homeassistant.components.ring.camera.ffmpeg.async_get_image",
|
||||||
return_value=SMALLEST_VALID_JPEG_BYTES,
|
return_value=SMALLEST_VALID_JPEG_BYTES,
|
||||||
):
|
):
|
||||||
image = await camera.async_get_image(hass, "camera.front")
|
image = await async_get_image(hass, "camera.front_live_view")
|
||||||
assert image.content == SMALLEST_VALID_JPEG_BYTES
|
assert image.content == SMALLEST_VALID_JPEG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||||
|
async def test_camera_stream_attributes(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_ring_client: Mock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test stream attributes."""
|
||||||
|
await setup_platform(hass, Platform.CAMERA)
|
||||||
|
|
||||||
|
# Live view
|
||||||
|
state = hass.states.get("camera.front_live_view")
|
||||||
|
supported_features = state.attributes.get("supported_features")
|
||||||
|
assert supported_features is CameraEntityFeature.STREAM
|
||||||
|
camera = get_camera_from_entity_id(hass, "camera.front_live_view")
|
||||||
|
assert camera.camera_capabilities.frontend_stream_types == {StreamType.WEB_RTC}
|
||||||
|
|
||||||
|
# Last recording
|
||||||
|
state = hass.states.get("camera.front_last_recording")
|
||||||
|
supported_features = state.attributes.get("supported_features")
|
||||||
|
assert supported_features is CameraEntityFeature(0)
|
||||||
|
camera = get_camera_from_entity_id(hass, "camera.front_last_recording")
|
||||||
|
assert camera.camera_capabilities.frontend_stream_types == set()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_webrtc(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_ring_client: Mock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
mock_ring_devices,
|
||||||
|
hass_ws_client: WebSocketGenerator,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test WebRTC interactions."""
|
||||||
|
caplog.set_level(logging.ERROR)
|
||||||
|
await setup_platform(hass, Platform.CAMERA)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
# sdp offer
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{
|
||||||
|
"type": "camera/webrtc/offer",
|
||||||
|
"entity_id": "camera.front_live_view",
|
||||||
|
"offer": "v=0\r\n",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response
|
||||||
|
assert response.get("success") is True
|
||||||
|
subscription_id = response["id"]
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
front_camera_mock = mock_ring_devices.get_device(FRONT_DEVICE_ID)
|
||||||
|
front_camera_mock.generate_async_webrtc_stream.assert_called_once()
|
||||||
|
args = front_camera_mock.generate_async_webrtc_stream.call_args.args
|
||||||
|
session_id = args[1]
|
||||||
|
on_message = args[2]
|
||||||
|
|
||||||
|
# receive session
|
||||||
|
response = await client.receive_json()
|
||||||
|
event = response.get("event")
|
||||||
|
assert event
|
||||||
|
assert event.get("type") == "session"
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
# Ring candidate
|
||||||
|
on_message(RingWebRtcMessage(candidate="candidate", sdp_m_line_index=1))
|
||||||
|
response = await client.receive_json()
|
||||||
|
event = response.get("event")
|
||||||
|
assert event
|
||||||
|
assert event.get("type") == "candidate"
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
# Error message
|
||||||
|
on_message(RingWebRtcMessage(error_code=1, error_message="error"))
|
||||||
|
response = await client.receive_json()
|
||||||
|
event = response.get("event")
|
||||||
|
assert event
|
||||||
|
assert event.get("type") == "error"
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
# frontend candidate
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{
|
||||||
|
"type": "camera/webrtc/candidate",
|
||||||
|
"entity_id": "camera.front_live_view",
|
||||||
|
"session_id": session_id,
|
||||||
|
"candidate": {"candidate": "candidate", "sdpMLineIndex": 1},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response
|
||||||
|
assert response.get("success") is True
|
||||||
|
assert not caplog.text
|
||||||
|
front_camera_mock.on_webrtc_candidate.assert_called_once()
|
||||||
|
|
||||||
|
# Invalid frontend candidate
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{
|
||||||
|
"type": "camera/webrtc/candidate",
|
||||||
|
"entity_id": "camera.front_live_view",
|
||||||
|
"session_id": session_id,
|
||||||
|
"candidate": {"candidate": "candidate", "sdpMid": "1"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response
|
||||||
|
assert response.get("success") is False
|
||||||
|
assert response["error"]["code"] == "home_assistant_error"
|
||||||
|
msg = "The sdp_m_line_index is required for ring webrtc streaming"
|
||||||
|
assert msg in response["error"].get("message")
|
||||||
|
assert msg in caplog.text
|
||||||
|
front_camera_mock.on_webrtc_candidate.assert_called_once()
|
||||||
|
|
||||||
|
# Answer message
|
||||||
|
caplog.clear()
|
||||||
|
on_message(RingWebRtcMessage(answer="v=0\r\n"))
|
||||||
|
response = await client.receive_json()
|
||||||
|
event = response.get("event")
|
||||||
|
assert event
|
||||||
|
assert event.get("type") == "answer"
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
# Unsubscribe/Close session
|
||||||
|
front_camera_mock.sync_close_webrtc_stream.assert_not_called()
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{
|
||||||
|
"type": "unsubscribe_events",
|
||||||
|
"subscription": subscription_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response
|
||||||
|
assert response.get("success") is True
|
||||||
|
front_camera_mock.sync_close_webrtc_stream.assert_called_once()
|
||||||
|
@ -11,7 +11,11 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI
|
|||||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||||
from homeassistant.components.ring import DOMAIN
|
from homeassistant.components.ring import DOMAIN
|
||||||
from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL
|
from homeassistant.components.ring.const import (
|
||||||
|
CONF_CONFIG_ENTRY_MINOR_VERSION,
|
||||||
|
CONF_LISTEN_CREDENTIALS,
|
||||||
|
SCAN_INTERVAL,
|
||||||
|
)
|
||||||
from homeassistant.components.ring.coordinator import RingEventListener
|
from homeassistant.components.ring.coordinator import RingEventListener
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||||
from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME
|
from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME
|
||||||
@ -237,15 +241,14 @@ async def test_error_on_device_update(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("domain", "old_unique_id"),
|
("domain", "old_unique_id", "new_unique_id"),
|
||||||
[
|
[
|
||||||
(
|
pytest.param(LIGHT_DOMAIN, 123456, "123456", id="Light integer"),
|
||||||
LIGHT_DOMAIN,
|
pytest.param(
|
||||||
123456,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
CAMERA_DOMAIN,
|
CAMERA_DOMAIN,
|
||||||
654321,
|
654321,
|
||||||
|
"654321-last_recording",
|
||||||
|
id="Camera integer",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -256,6 +259,7 @@ async def test_update_unique_id(
|
|||||||
mock_ring_client,
|
mock_ring_client,
|
||||||
domain: str,
|
domain: str,
|
||||||
old_unique_id: int | str,
|
old_unique_id: int | str,
|
||||||
|
new_unique_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test unique_id update of integration."""
|
"""Test unique_id update of integration."""
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
@ -266,6 +270,7 @@ async def test_update_unique_id(
|
|||||||
"token": {"access_token": "mock-token"},
|
"token": {"access_token": "mock-token"},
|
||||||
},
|
},
|
||||||
unique_id="foo@bar.com",
|
unique_id="foo@bar.com",
|
||||||
|
minor_version=1,
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
@ -281,8 +286,9 @@ async def test_update_unique_id(
|
|||||||
|
|
||||||
entity_migrated = entity_registry.async_get(entity.entity_id)
|
entity_migrated = entity_registry.async_get(entity.entity_id)
|
||||||
assert entity_migrated
|
assert entity_migrated
|
||||||
assert entity_migrated.unique_id == str(old_unique_id)
|
assert entity_migrated.unique_id == new_unique_id
|
||||||
assert (f"Fixing non string unique id {old_unique_id}") in caplog.text
|
assert (f"Fixing non string unique id {old_unique_id}") in caplog.text
|
||||||
|
assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION
|
||||||
|
|
||||||
|
|
||||||
async def test_update_unique_id_existing(
|
async def test_update_unique_id_existing(
|
||||||
@ -301,6 +307,7 @@ async def test_update_unique_id_existing(
|
|||||||
"token": {"access_token": "mock-token"},
|
"token": {"access_token": "mock-token"},
|
||||||
},
|
},
|
||||||
unique_id="foo@bar.com",
|
unique_id="foo@bar.com",
|
||||||
|
minor_version=1,
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
@ -331,16 +338,17 @@ async def test_update_unique_id_existing(
|
|||||||
f"already exists for '{entity_existing.entity_id}', "
|
f"already exists for '{entity_existing.entity_id}', "
|
||||||
"You may have to delete unavailable ring entities"
|
"You may have to delete unavailable ring entities"
|
||||||
) in caplog.text
|
) in caplog.text
|
||||||
|
assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION
|
||||||
|
|
||||||
|
|
||||||
async def test_update_unique_id_no_update(
|
async def test_update_unique_id_camera_update(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
mock_ring_client,
|
mock_ring_client,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test unique_id update of integration."""
|
"""Test camera unique id with no suffix is updated."""
|
||||||
correct_unique_id = "123456"
|
correct_unique_id = "123456-last_recording"
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
title="Ring",
|
title="Ring",
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
@ -349,6 +357,7 @@ async def test_update_unique_id_no_update(
|
|||||||
"token": {"access_token": "mock-token"},
|
"token": {"access_token": "mock-token"},
|
||||||
},
|
},
|
||||||
unique_id="foo@bar.com",
|
unique_id="foo@bar.com",
|
||||||
|
minor_version=1,
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
@ -358,14 +367,16 @@ async def test_update_unique_id_no_update(
|
|||||||
unique_id="123456",
|
unique_id="123456",
|
||||||
config_entry=entry,
|
config_entry=entry,
|
||||||
)
|
)
|
||||||
assert entity.unique_id == correct_unique_id
|
assert entity.unique_id == "123456"
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
entity_migrated = entity_registry.async_get(entity.entity_id)
|
entity_migrated = entity_registry.async_get(entity.entity_id)
|
||||||
assert entity_migrated
|
assert entity_migrated
|
||||||
assert entity_migrated.unique_id == correct_unique_id
|
assert entity_migrated.unique_id == correct_unique_id
|
||||||
|
assert entity.disabled is False
|
||||||
assert "Fixing non string unique id" not in caplog.text
|
assert "Fixing non string unique id" not in caplog.text
|
||||||
|
assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION
|
||||||
|
|
||||||
|
|
||||||
async def test_token_updated(
|
async def test_token_updated(
|
||||||
@ -477,7 +488,7 @@ async def test_migrate_create_device_id(
|
|||||||
await hass.config_entries.async_setup(entry.entry_id)
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert entry.minor_version == 2
|
assert entry.minor_version == CONF_CONFIG_ENTRY_MINOR_VERSION
|
||||||
assert CONF_DEVICE_ID in entry.data
|
assert CONF_DEVICE_ID in entry.data
|
||||||
assert entry.data[CONF_DEVICE_ID] == MOCK_HARDWARE_ID
|
assert entry.data[CONF_DEVICE_ID] == MOCK_HARDWARE_ID
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user