Add live view camera entity to ring integration (#127579)

This commit is contained in:
Steven B. 2024-11-26 14:20:25 +00:00 committed by GitHub
parent 9510ef56f9
commit 147679f803
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 624 additions and 156 deletions

View File

@ -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,42 +103,46 @@ async def async_remove_config_entry_device(
return True return True
async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
entity_registry = er.async_get(hass)
@callback
def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
# Old format for camera and light was int
unique_id = cast(str | int, entity_entry.unique_id)
if isinstance(unique_id, int):
new_unique_id = str(unique_id)
if existing_entity_id := entity_registry.async_get_entity_id(
entity_entry.domain, entity_entry.platform, new_unique_id
):
_LOGGER.error(
"Cannot migrate to unique_id '%s', already exists for '%s', "
"You may have to delete unavailable ring entities",
new_unique_id,
existing_entity_id,
)
return None
_LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id)
return {"new_unique_id": new_unique_id}
return None
await er.async_migrate_entries(hass, entry_id, _async_migrator)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entry.""" """Migrate old config entry."""
entry_version = entry.version entry_version = entry.version
entry_minor_version = entry.minor_version entry_minor_version = entry.minor_version
entry_id = entry.entry_id
new_minor_version = 2 new_minor_version = 2
if entry_version == 1 and entry_minor_version == 1: if entry_version == 1 and entry_minor_version == 1:
_LOGGER.debug( _LOGGER.debug(
"Migrating from version %s.%s", entry_version, entry_minor_version "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)
@callback
def _async_str_unique_id_migrator(
entity_entry: er.RegistryEntry,
) -> dict[str, str] | None:
# Old format for camera and light was int
unique_id = cast(str | int, entity_entry.unique_id)
if isinstance(unique_id, int):
new_unique_id = str(unique_id)
if existing_entity_id := entity_registry.async_get_entity_id(
entity_entry.domain, entity_entry.platform, new_unique_id
):
_LOGGER.error(
"Cannot migrate to unique_id '%s', already exists for '%s', "
"You may have to delete unavailable ring entities",
new_unique_id,
existing_entity_id,
)
return None
_LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id)
return {"new_unique_id": new_unique_id}
return None
await er.async_migrate_entries(hass, entry_id, _async_str_unique_id_migrator)
# Migrate the hardware id
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

View File

@ -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()

View File

@ -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

View File

@ -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": {

View File

@ -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>,

View File

@ -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()

View File

@ -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