From 2effb297ff3c0c304e6078f9966411ceee7009b7 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Mon, 20 Jul 2026 14:20:53 +0000 Subject: [PATCH 1/3] Add per-camera snooze support - Add snoozed async property to BlinkCamera (checks all camera types) - Add async_snooze() method to BlinkCamera - Add request_camera_snooze() API function routing by product type - Tests --- blinkpy/api.py | 38 +++++++ blinkpy/camera.py | 55 ++++++++++ tests/test_api.py | 40 +++++++ tests/test_cameras.py | 241 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+) diff --git a/blinkpy/api.py b/blinkpy/api.py index d3221d71..6dfaad42 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -1065,3 +1065,41 @@ async def oauth_refresh_token(auth, refresh_token, hardware_id): return await response.json() return None + + +async def request_camera_snooze( + blink, network, camera_id, product_type="owl", data=None +): + """ + Update camera snooze configuration. + + :param blink: Blink instance. + :param network: Sync module network id. + :param camera_id: ID of camera + :param product_type: Camera product type "owl", "catalina", + "doorbell", "hawk", "lotus", or "sedona" + :param data: string w/JSON dict of parameters/values to update + """ + product_lookup = { + "catalina": "cameras", + "sedona": "cameras", + "owl": "owls", + "hawk": "owls", + "doorbell": "doorbells", + "lotus": "doorbells", + } + + if product_type not in product_lookup: + _LOGGER.info( + "Camera %s with product type %s snooze update not implemented.", + camera_id, + product_type, + ) + return None + + url_root = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" + f"/networks/{network}" + ) + url = f"{url_root}/{product_lookup[product_type]}/{camera_id}/snooze" + return await http_post(blink, url, json=True, data=data) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 33276963..b24f0eee 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -177,6 +177,61 @@ async def async_set_night_vision(self, value): return await res.json() return None + @property + async def snoozed(self): + """Return snooze status as boolean.""" + response_data = None + try: + if self.product_type in ["catalina", "sedona"]: + res = await api.request_get_config( + self.sync.blink, + self.network_id, + self.camera_id, + product_type=self.product_type, + ) + response_data = res + snooze_value = res["camera"][0].get("snooze_till") + return bool(snooze_value) + else: + response_data = self.sync.blink.homescreen + if self.product_type in ["doorbell", "lotus"]: + collection_key = "doorbells" + else: + collection_key = "owls" + for device in self.sync.blink.homescreen.get(collection_key, []): + if int(device.get("id")) == int(self.camera_id): + snooze_value = device.get("snooze") + return bool(snooze_value) + return False + except TypeError: + return False + except (IndexError, KeyError, ValueError) as e: + _LOGGER.warning( + "Exception %s: Encountered a likely malformed response " + "from the snooze API endpoint. Response: %s", + e, + response_data, + ) + return False + + async def async_snooze(self, snooze_time=3600): + """ + Set camera snooze status. + + :param snooze_time: Time in seconds to snooze camera. Default is 3600 (1 hour). + """ + data = dumps({"snooze_time": snooze_time}) + res = await api.request_camera_snooze( + self.sync.blink, + self.network_id, + self.camera_id, + product_type=self.product_type, + data=data, + ) + if res and self.product_type not in ["catalina", "sedona"]: + await self.sync.blink.get_homescreen() + return res + @property def floodlight_enabled(self): """Return last-known floodlight state if tracked, else None.""" diff --git a/tests/test_api.py b/tests/test_api.py index f8f77b89..4730103f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -189,6 +189,46 @@ async def test_request_update_config(self, mock_resp): ) ) + async def test_request_camera_snooze(self, mock_resp): + """Test camera snooze request.""" + mock_resp.return_value = {"message": "Camera snoozed"} + # Test catalina camera + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "catalina", '{"snooze": 300}' + ) + self.assertEqual(response, {"message": "Camera snoozed"}) + # Test sedona camera + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "sedona", '{"snooze": 300}' + ) + self.assertEqual(response, {"message": "Camera snoozed"}) + # Test owl camera + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "owl", '{"snooze": 300}' + ) + self.assertEqual(response, {"message": "Camera snoozed"}) + # Test hawk camera + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "hawk", '{"snooze": 300}' + ) + self.assertEqual(response, {"message": "Camera snoozed"}) + # Test doorbell camera + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "doorbell", '{"snooze": 300}' + ) + self.assertEqual(response, {"message": "Camera snoozed"}) + # Test lotus camera + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "lotus", '{"snooze": 300}' + ) + self.assertEqual(response, {"message": "Camera snoozed"}) + # Test unsupported camera type + self.assertIsNone( + await api.request_camera_snooze( + self.blink, "network", "camera_id", "unsupported_type" + ) + ) + async def test_wait_for_command(self, mock_resp): """Test Motion detect enable.""" mock_resp.side_effect = (COMMAND_NOT_COMPLETE, COMMAND_COMPLETE) diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 3437ea8d..b72f820e 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -220,3 +220,244 @@ async def test_new_thumb_url_returned(self, mock_resp): self.assertEqual( self.camera.thumbnail, f"https://rest-test.immedia-semi.com{thumb_return}" ) + + @mock.patch( + "blinkpy.api.request_camera_snooze", + mock.AsyncMock(return_value={"status": 200}), + ) + async def test_camera_snooze(self, mock_resp): + """Test camera snooze.""" + self.camera.product_type = "catalina" + with mock.patch.object( + self.blink, "get_homescreen", mock.AsyncMock() + ) as mock_homescreen: + result = await self.camera.async_snooze(300) + # Catalina cameras should NOT refresh homescreen + mock_homescreen.assert_not_called() + self.assertEqual(result, {"status": 200}) + + @mock.patch( + "blinkpy.api.request_camera_snooze", + mock.AsyncMock(return_value={"status": 400}), + ) + async def test_camera_snooze_failure(self, mock_resp): + """Test camera snooze failure.""" + self.camera.product_type = "owl" + with mock.patch.object( + self.blink, "get_homescreen", mock.AsyncMock() + ) as mock_homescreen: + result = await self.camera.async_snooze(300) + # Non-catalina/sedona cameras refresh homescreen even on failure + mock_homescreen.assert_called_once() + self.assertEqual(result, {"status": 400}) + + @mock.patch( + "blinkpy.api.request_camera_snooze", + mock.AsyncMock(return_value=None), + ) + async def test_camera_snooze_none_response(self, mock_resp): + """Test camera snooze with None response.""" + self.camera.product_type = "doorbell" + with mock.patch.object( + self.blink, "get_homescreen", mock.AsyncMock() + ) as mock_homescreen: + result = await self.camera.async_snooze(240) + # get_homescreen should not be called when response is None + mock_homescreen.assert_not_called() + self.assertIsNone(result) + + @mock.patch( + "blinkpy.api.request_camera_snooze", + mock.AsyncMock(return_value={"status": 200}), + ) + async def test_camera_snooze_mini(self, mock_resp): + """Test mini camera snooze refreshes homescreen.""" + self.camera.product_type = "mini" + with mock.patch.object( + self.blink, "get_homescreen", mock.AsyncMock() + ) as mock_homescreen: + result = await self.camera.async_snooze(300) + # Mini cameras should refresh homescreen + mock_homescreen.assert_called_once() + self.assertEqual(result, {"status": 200}) + + @mock.patch( + "blinkpy.api.request_camera_snooze", + mock.AsyncMock(return_value={"status": 200}), + ) + async def test_camera_snooze_sedona(self, mock_resp): + """Test sedona camera snooze does not refresh homescreen.""" + self.camera.product_type = "sedona" + with mock.patch.object( + self.blink, "get_homescreen", mock.AsyncMock() + ) as mock_homescreen: + result = await self.camera.async_snooze(300) + # Sedona cameras should NOT refresh homescreen + mock_homescreen.assert_not_called() + self.assertEqual(result, {"status": 200}) + + @mock.patch( + "blinkpy.api.request_get_config", + mock.AsyncMock( + return_value={"camera": [{"snooze_till": "2026-02-15T12:00:00+00:00"}]} + ), + ) + async def test_camera_snoozed_catalina(self, mock_resp): + """Test getting snoozed status for catalina camera.""" + self.camera.product_type = "catalina" + self.camera.network_id = "5678" + self.camera.camera_id = "1234" + result = await self.camera.snoozed + self.assertTrue(result) + + async def test_camera_snoozed_owl(self, mock_resp): + """Test getting snoozed status for owl camera.""" + self.camera.product_type = "owl" + self.camera.camera_id = "1234" + self.blink.homescreen = { + "owls": [{"id": "1234", "snooze": "2026-02-15T12:00:00+00:00"}] + } + result = await self.camera.snoozed + self.assertTrue(result) + + async def test_camera_snoozed_owl_false(self, mock_resp): + """Test getting snoozed status returns False when not snoozed.""" + self.camera.product_type = "owl" + self.camera.camera_id = "9999" + self.blink.homescreen = { + "owls": [{"id": "1234", "snooze": "2026-02-15T12:00:00+00:00"}] + } + result = await self.camera.snoozed + self.assertFalse(result) + + async def test_camera_snoozed_doorbell(self, mock_resp): + """Test getting snoozed status for doorbell camera.""" + self.camera.product_type = "doorbell" + self.camera.camera_id = "5678" + self.blink.homescreen = { + "doorbells": [{"id": "5678", "snooze": "2026-02-15T12:00:00+00:00"}] + } + result = await self.camera.snoozed + self.assertTrue(result) + + async def test_camera_snoozed_lotus(self, mock_resp): + """Test getting snoozed status for lotus (doorbell) camera.""" + self.camera.product_type = "lotus" + self.camera.camera_id = "5678" + self.blink.homescreen = { + "doorbells": [{"id": "5678", "snooze": "2026-02-15T12:00:00+00:00"}] + } + result = await self.camera.snoozed + self.assertTrue(result) + + async def test_camera_snoozed_hawk(self, mock_resp): + """Test getting snoozed status for hawk camera.""" + self.camera.product_type = "hawk" + self.camera.camera_id = "1234" + self.blink.homescreen = { + "owls": [{"id": "1234", "snooze": "2026-02-15T12:00:00+00:00"}] + } + result = await self.camera.snoozed + self.assertTrue(result) + + @mock.patch( + "blinkpy.api.request_get_config", + mock.AsyncMock( + return_value={"camera": [{"snooze_till": "2026-02-15T12:00:00+00:00"}]} + ), + ) + async def test_camera_snoozed_sedona(self, mock_resp): + """Test getting snoozed status for sedona camera.""" + self.camera.product_type = "sedona" + self.camera.network_id = "5678" + self.camera.camera_id = "1234" + result = await self.camera.snoozed + self.assertTrue(result) + + async def test_camera_snoozed_boolean_true(self, mock_resp): + """Test getting snoozed status when snooze is boolean True.""" + self.camera.product_type = "owl" + self.camera.camera_id = "1234" + self.blink.homescreen = {"owls": [{"id": "1234", "snooze": True}]} + result = await self.camera.snoozed + self.assertTrue(result) + + async def test_camera_snoozed_boolean_false(self, mock_resp): + """Test getting snoozed status when snooze is boolean False.""" + self.camera.product_type = "owl" + self.camera.camera_id = "1234" + self.blink.homescreen = {"owls": [{"id": "1234", "snooze": False}]} + result = await self.camera.snoozed + self.assertFalse(result) + + async def test_camera_snoozed_mini(self, mock_resp): + """Test getting snoozed status for mini camera from homescreen.""" + self.camera.product_type = "mini" + self.camera.camera_id = "1234" + self.blink.homescreen = { + "owls": [{"id": "1234", "snooze": "2026-02-15T12:00:00+00:00"}] + } + result = await self.camera.snoozed + self.assertTrue(result) + + async def test_camera_snoozed_mini_false(self, mock_resp): + """Test getting snoozed status for mini camera returns False.""" + self.camera.product_type = "mini" + self.camera.camera_id = "9999" + self.blink.homescreen = { + "owls": [{"id": "1234", "snooze": "2026-02-15T12:00:00+00:00"}] + } + result = await self.camera.snoozed + self.assertFalse(result) + + async def test_camera_snoozed_empty_homescreen(self, mock_resp): + """Test getting snoozed status with empty homescreen collection.""" + self.camera.product_type = "owl" + self.camera.camera_id = "1234" + self.blink.homescreen = {"owls": []} + result = await self.camera.snoozed + self.assertFalse(result) + + async def test_camera_snoozed_missing_collection(self, mock_resp): + """Test getting snoozed status when homescreen collection is missing.""" + self.camera.product_type = "doorbell" + self.camera.camera_id = "5678" + self.blink.homescreen = {"owls": []} + result = await self.camera.snoozed + self.assertFalse(result) + + @mock.patch( + "blinkpy.api.request_get_config", + mock.AsyncMock(return_value=None), + ) + async def test_camera_snoozed_none(self, mock_resp): + """Test getting snoozed status with None response.""" + self.camera.product_type = "catalina" + self.camera.network_id = "5678" + self.camera.camera_id = "1234" + result = await self.camera.snoozed + self.assertFalse(result) + + @mock.patch( + "blinkpy.api.request_get_config", + mock.AsyncMock(return_value={"camera": [{}]}), + ) + async def test_camera_snoozed_malformed(self, mock_resp): + """Test getting snoozed status with malformed response.""" + self.camera.product_type = "catalina" + self.camera.network_id = "5678" + self.camera.camera_id = "1234" + result = await self.camera.snoozed + self.assertFalse(result) + + @mock.patch( + "blinkpy.api.request_get_config", + mock.AsyncMock(return_value={"camera": [{"snooze_till": ""}]}), + ) + async def test_camera_snoozed_empty_string(self, mock_resp): + """Test getting snoozed status with empty string.""" + self.camera.product_type = "catalina" + self.camera.network_id = "5678" + self.camera.camera_id = "1234" + result = await self.camera.snoozed + self.assertFalse(result) From a0df658af041d9e677acf14ad7f3ed93017de430 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Mon, 20 Jul 2026 16:25:08 +0000 Subject: [PATCH 2/3] Add sedona support to request_get_config; fix test payload key to snooze_time --- blinkpy/api.py | 4 ++-- tests/test_api.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 6dfaad42..7da2ef3f 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -571,14 +571,14 @@ async def request_get_config(blink, network, camera_id, product_type="owl"): :param blink: Blink instance. :param network: Sync module network id. :param camera_id: ID of camera - :param product_type: Camera product type "owl" or "catalina" + :param product_type: Camera product type "owl", "catalina", or "sedona" """ if product_type == "owl": url = ( f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" f"/networks/{network}/owls/{camera_id}/config" ) - elif product_type == "catalina": + elif product_type in ["catalina", "sedona"]: url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config" else: _LOGGER.info( diff --git a/tests/test_api.py b/tests/test_api.py index 4730103f..eaccf082 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -194,32 +194,32 @@ async def test_request_camera_snooze(self, mock_resp): mock_resp.return_value = {"message": "Camera snoozed"} # Test catalina camera response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "catalina", '{"snooze": 300}' + self.blink, "network", "camera_id", "catalina", '{"snooze_time": 300}' ) self.assertEqual(response, {"message": "Camera snoozed"}) # Test sedona camera response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "sedona", '{"snooze": 300}' + self.blink, "network", "camera_id", "sedona", '{"snooze_time": 300}' ) self.assertEqual(response, {"message": "Camera snoozed"}) # Test owl camera response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "owl", '{"snooze": 300}' + self.blink, "network", "camera_id", "owl", '{"snooze_time": 300}' ) self.assertEqual(response, {"message": "Camera snoozed"}) # Test hawk camera response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "hawk", '{"snooze": 300}' + self.blink, "network", "camera_id", "hawk", '{"snooze_time": 300}' ) self.assertEqual(response, {"message": "Camera snoozed"}) # Test doorbell camera response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "doorbell", '{"snooze": 300}' + self.blink, "network", "camera_id", "doorbell", '{"snooze_time": 300}' ) self.assertEqual(response, {"message": "Camera snoozed"}) # Test lotus camera response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "lotus", '{"snooze": 300}' + self.blink, "network", "camera_id", "lotus", '{"snooze_time": 300}' ) self.assertEqual(response, {"message": "Camera snoozed"}) # Test unsupported camera type From 70046dea7c4442f539e22f4ff476324bc26d8e08 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Mon, 20 Jul 2026 17:51:15 +0000 Subject: [PATCH 3/3] Fix snoozed ID comparison and add sedona to request_update_config --- blinkpy/api.py | 4 ++-- blinkpy/camera.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 169da633..8f4679d5 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -596,7 +596,7 @@ async def request_update_config( :param blink: Blink instance. :param network: Sync module network id. :param camera_id: ID of camera - :param product_type: Camera product type "owl" or "catalina" + :param product_type: Camera product type "owl", "catalina", or "sedona" :param data: string w/JSON dict of parameters/values to update """ if product_type == "owl": @@ -604,7 +604,7 @@ async def request_update_config( f"{blink.urls.base_url}/api/v1/accounts/" f"{blink.account_id}/networks/{network}/owls/{camera_id}/config" ) - elif product_type == "catalina": + elif product_type in ["catalina", "sedona"]: url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/update" else: _LOGGER.info( diff --git a/blinkpy/camera.py b/blinkpy/camera.py index b24f0eee..9f67c36b 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -199,7 +199,7 @@ async def snoozed(self): else: collection_key = "owls" for device in self.sync.blink.homescreen.get(collection_key, []): - if int(device.get("id")) == int(self.camera_id): + if str(device.get("id")) == str(self.camera_id): snooze_value = device.get("snooze") return bool(snooze_value) return False