From 5e530fc42ebfa6a648148480abb193a64ad4221d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 12 Mar 2024 15:05:14 +0000 Subject: [PATCH] Add motion detection enable/disable to ring camera platform (#108789) * Add motion detection enable/disable to ring camera platform * Write ha state directly Co-authored-by: J. Nick Koston * Parametrize on off state tests * Add tests for errors on setting motion detection --------- Co-authored-by: J. Nick Koston --- homeassistant/components/ring/camera.py | 30 ++++ tests/components/ring/conftest.py | 7 + tests/components/ring/fixtures/devices.json | 3 + .../ring/fixtures/devices_updated.json | 3 + .../ring/snapshots/test_diagnostics.ambr | 3 + tests/components/ring/test_camera.py | 140 ++++++++++++++++++ 6 files changed, 186 insertions(+) create mode 100644 tests/components/ring/test_camera.py diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 9221430413a..7cbe3559ab2 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -22,6 +22,7 @@ from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap FORCE_REFRESH_INTERVAL = timedelta(minutes=3) +MOTION_DETECTION_CAPABILITY = "motion_detection" _LOGGER = logging.getLogger(__name__) @@ -67,6 +68,8 @@ class RingCam(RingEntity, Camera): self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL self._attr_unique_id = device.id + if device.has_capability(MOTION_DETECTION_CAPABILITY): + self._attr_motion_detection_enabled = device.motion_detection @callback def _handle_coordinator_update(self): @@ -131,6 +134,13 @@ class RingCam(RingEntity, Camera): async def async_update(self) -> None: """Update camera entity and refresh attributes.""" + if ( + self._device.has_capability(MOTION_DETECTION_CAPABILITY) + and self._attr_motion_detection_enabled != self._device.motion_detection + ): + self._attr_motion_detection_enabled = self._device.motion_detection + self.async_write_ha_state() + if self._last_event is None: return @@ -152,3 +162,23 @@ class RingCam(RingEntity, Camera): @exception_wrap def _get_video(self) -> str: return self._device.recording_url(self._last_event["id"]) + + @exception_wrap + def _set_motion_detection_enabled(self, new_state): + if not self._device.has_capability(MOTION_DETECTION_CAPABILITY): + _LOGGER.error( + "Entity %s does not have motion detection capability", self.entity_id + ) + return + + self._device.motion_detection = new_state + self._attr_motion_detection_enabled = new_state + self.schedule_update_ha_state(False) + + def enable_motion_detection(self) -> None: + """Enable motion detection in the camera.""" + self._set_motion_detection_enabled(True) + + def disable_motion_detection(self) -> None: + """Disable motion detection in camera.""" + self._set_motion_detection_enabled(False) diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index 42b2184f289..758643f912e 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -121,4 +121,11 @@ def requests_mock_fixture(): status_code=200, json={"url": "http://127.0.0.1/foo"}, ) + # Mocks the response for setting properties in settings (i.e. motion_detection) + mock.patch( + re.compile( + r"https:\/\/api\.ring\.com\/devices\/v1\/devices\/\d+\/settings" + ), + text="ok", + ) yield mock diff --git a/tests/components/ring/fixtures/devices.json b/tests/components/ring/fixtures/devices.json index aff234f9726..ae7a62e1bae 100644 --- a/tests/components/ring/fixtures/devices.json +++ b/tests/components/ring/fixtures/devices.json @@ -69,6 +69,7 @@ "enable_vod": true, "live_view_preset_profile": "highest", "live_view_presets": ["low", "middle", "high", "highest"], + "motion_detection_enabled": true, "motion_announcement": false, "motion_snooze_preset_profile": "low", "motion_snooze_presets": ["null", "low", "medium", "high"] @@ -133,6 +134,7 @@ }, "live_view_preset_profile": "highest", "live_view_presets": ["low", "middle", "high", "highest"], + "motion_detection_enabled": false, "motion_announcement": false, "motion_snooze_preset_profile": "low", "motion_snooze_presets": ["none", "low", "medium", "high"], @@ -281,6 +283,7 @@ }, "live_view_preset_profile": "highest", "live_view_presets": ["low", "middle", "high", "highest"], + "motion_detection_enabled": true, "motion_announcement": false, "motion_snooze_preset_profile": "low", "motion_snooze_presets": ["none", "low", "medium", "high"], diff --git a/tests/components/ring/fixtures/devices_updated.json b/tests/components/ring/fixtures/devices_updated.json index 5a4584b72db..01ea2ca25f5 100644 --- a/tests/components/ring/fixtures/devices_updated.json +++ b/tests/components/ring/fixtures/devices_updated.json @@ -69,6 +69,7 @@ "enable_vod": true, "live_view_preset_profile": "highest", "live_view_presets": ["low", "middle", "high", "highest"], + "motion_detection_enabled": true, "motion_announcement": false, "motion_snooze_preset_profile": "low", "motion_snooze_presets": ["null", "low", "medium", "high"] @@ -133,6 +134,7 @@ }, "live_view_preset_profile": "highest", "live_view_presets": ["low", "middle", "high", "highest"], + "motion_detection_enabled": true, "motion_announcement": false, "motion_snooze_preset_profile": "low", "motion_snooze_presets": ["none", "low", "medium", "high"], @@ -281,6 +283,7 @@ }, "live_view_preset_profile": "highest", "live_view_presets": ["low", "middle", "high", "highest"], + "motion_detection_enabled": false, "motion_announcement": false, "motion_snooze_preset_profile": "low", "motion_snooze_presets": ["none", "low", "medium", "high"], diff --git a/tests/components/ring/snapshots/test_diagnostics.ambr b/tests/components/ring/snapshots/test_diagnostics.ambr index 64e753ba2b3..2b8f2bac389 100644 --- a/tests/components/ring/snapshots/test_diagnostics.ambr +++ b/tests/components/ring/snapshots/test_diagnostics.ambr @@ -82,6 +82,7 @@ 'highest', ]), 'motion_announcement': False, + 'motion_detection_enabled': True, 'motion_snooze_preset_profile': 'low', 'motion_snooze_presets': list([ 'null', @@ -158,6 +159,7 @@ 'highest', ]), 'motion_announcement': False, + 'motion_detection_enabled': False, 'motion_snooze_preset_profile': 'low', 'motion_snooze_presets': list([ 'none', @@ -398,6 +400,7 @@ 'highest', ]), 'motion_announcement': False, + 'motion_detection_enabled': True, 'motion_snooze_preset_profile': 'low', 'motion_snooze_presets': list([ 'none', diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py new file mode 100644 index 00000000000..5fd05dd8f28 --- /dev/null +++ b/tests/components/ring/test_camera.py @@ -0,0 +1,140 @@ +"""The tests for the Ring switch platform.""" +from unittest.mock import PropertyMock, patch + +import pytest +import requests_mock +import ring_doorbell + +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .common import setup_platform + +from tests.common import load_fixture + + +async def test_entity_registry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: + """Tests that the devices are registered in the entity registry.""" + await setup_platform(hass, Platform.CAMERA) + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("camera.front") + assert entry.unique_id == 765432 + + entry = entity_registry.async_get("camera.internal") + assert entry.unique_id == 345678 + + +@pytest.mark.parametrize( + ("entity_name", "expected_state", "friendly_name"), + [ + ("camera.internal", True, "Internal"), + ("camera.front", None, "Front"), + ], + ids=["On", "Off"], +) +async def test_camera_motion_detection_state_reports_correctly( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + entity_name, + expected_state, + friendly_name, +) -> None: + """Tests that the initial state of a device that should be off is correct.""" + await setup_platform(hass, Platform.CAMERA) + + state = hass.states.get(entity_name) + assert state.attributes.get("motion_detection") is expected_state + assert state.attributes.get("friendly_name") == friendly_name + + +async def test_camera_motion_detection_can_be_turned_on( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: + """Tests the siren turns on correctly.""" + await setup_platform(hass, Platform.CAMERA) + + state = hass.states.get("camera.front") + assert state.attributes.get("motion_detection") is not True + + await hass.services.async_call( + "camera", + "enable_motion_detection", + {"entity_id": "camera.front"}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get("camera.front") + assert state.attributes.get("motion_detection") is True + + +async def test_updates_work( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: + """Tests the update service works correctly.""" + await setup_platform(hass, Platform.CAMERA) + state = hass.states.get("camera.internal") + assert state.attributes.get("motion_detection") is True + # Changes the return to indicate that the switch is now on. + requests_mock.get( + "https://api.ring.com/clients_api/ring_devices", + text=load_fixture("devices_updated.json", "ring"), + ) + + await hass.services.async_call("ring", "update", {}, blocking=True) + + await hass.async_block_till_done() + + state = hass.states.get("camera.internal") + assert state.attributes.get("motion_detection") is not True + + +@pytest.mark.parametrize( + ("exception_type", "reauth_expected"), + [ + (ring_doorbell.AuthenticationError, True), + (ring_doorbell.RingTimeout, False), + (ring_doorbell.RingError, False), + ], + ids=["Authentication", "Timeout", "Other"], +) +async def test_motion_detection_errors_when_turned_on( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + exception_type, + reauth_expected, +) -> None: + """Tests the motion detection errors are handled correctly.""" + await setup_platform(hass, Platform.CAMERA) + config_entry = hass.config_entries.async_entries("ring")[0] + + assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + with patch.object( + ring_doorbell.RingDoorBell, "motion_detection", new_callable=PropertyMock + ) as mock_motion_detection: + mock_motion_detection.side_effect = exception_type + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "camera", + "enable_motion_detection", + {"entity_id": "camera.front"}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_motion_detection.call_count == 1 + assert ( + any( + flow + for flow in config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}) + if flow["handler"] == "ring" + ) + == reauth_expected + )