From 90a1a58f2c38d553ad92cb7a5bc527b4eb7b8f84 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Fri, 5 Jul 2024 00:54:30 +0000 Subject: [PATCH 01/16] Add Per Camera Snooze Functionality --- blinkapp/blinkapp.py | 2 +- blinkpy/api.py | 52 ++++++++++++++++++++++++++++++++++ blinkpy/camera.py | 39 +++++++++++++++++++++++++ blinkpy/sync_module.py | 25 ++++++++++++++++ pyproject.toml | 2 +- requirements_test.txt | 12 ++++---- tests/test_api.py | 22 ++++++++++++++ tests/test_camera_functions.py | 25 ++++++++++++++++ tests/test_sync_module.py | 40 ++++++++++++++++++++++++++ 9 files changed, 211 insertions(+), 8 deletions(-) diff --git a/blinkapp/blinkapp.py b/blinkapp/blinkapp.py index 840ff58d..d0101ff3 100644 --- a/blinkapp/blinkapp.py +++ b/blinkapp/blinkapp.py @@ -9,7 +9,7 @@ from blinkpy.helpers.util import json_load CREDFILE = environ.get("CREDFILE") -TIMEDELTA = timedelta(environ.get("TIMEDELTA", 1)) +TIMEDELTA = timedelta(environ.get("TIMEDELTA", "1")) def get_date(): diff --git a/blinkpy/api.py b/blinkpy/api.py index 95dc280b..019089cf 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -492,6 +492,58 @@ async def request_update_config( return await http_post(blink, url, json=False, data=data) +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" or "catalina" + :param data: string w/JSON dict of parameters/values to update + """ + if product_type == "catalina": + url = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" + f"networks/{network}/cameras/{camera_id}/snooze" + ) + elif product_type == "owl": + url = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" + f"networks/{network}/owls/{camera_id}/snooze" + ) + elif product_type == "doorbell": + url = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" + f"networks/{network}/doorbells/{camera_id}/snooze" + ) + else: + _LOGGER.info( + "Camera %s with product type %s snooze update not implemented.", + camera_id, + product_type, + ) + return None + return await http_post(blink, url, json=True, data=data) + + +async def request_sync_snooze(blink, network, data=None): + """ + Update sync snooze configuration. + + :param blink: Blink instance. + :param network: Sync module network id. + :param data: string w/JSON dict of parameters/values to update + """ + url = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" + f"/networks/{network}/snooze" + ) + return await http_post(blink, url, json=True, data=data) + + async def http_get( blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOUT ): diff --git a/blinkpy/camera.py b/blinkpy/camera.py index b3c4a4b7..99aeaaf7 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -170,6 +170,31 @@ async def async_set_night_vision(self, value): return await res.json() return None + @property + async def snooze_till(self): + """Return snooze_till status.""" + res = await api.request_get_config( + self.sync.blink, + self.network_id, + self.camera_id, + product_type=self.product_type, + ) + if res is None: + return None + return res.get("camera", [{}])[0].get("snooze_till") + + async def async_snooze(self, snooze_time=240): + """Set camera snooze status.""" + 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, + ) + return res + async def record(self): """Initiate clip recording.""" return await api.request_new_video( @@ -625,3 +650,17 @@ async def get_liveview(self): server = response["server"] link = server.replace("immis://", "rtsps://") return link + + async def async_snooze(self): + """Set camera snooze status.""" + data = dumps({"snooze_time": 240}) + res = await api.request_camera_snooze( + self.sync.blink, + self.network_id, + self.camera_id, + product_type="doorbell", + data=data, + ) + if res and res.status == 200: + return await res.json() + return None diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 63b6aef6..63084f19 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -3,6 +3,7 @@ import logging import string import datetime +from json import dumps import traceback import asyncio import aiofiles @@ -127,6 +128,30 @@ async def async_arm(self, value): return await api.request_system_arm(self.blink, self.network_id) return await api.request_system_disarm(self.blink, self.network_id) + @property + async def snooze_till(self): + """Return snooze_till status.""" + res = await api.request_sync_snooze( + self.blink, + self.network_id, + ) + if res is None: + return None + res = res.get("snooze_till") + return res + + async def async_snooze(self, snooze_time=240): + """Set sync snooze status.""" + data = dumps({"snooze_time": snooze_time}) + res = await api.request_sync_snooze( + self.blink, + self.network_id, + data=data, + ) + if res and res.status == 200: + return await res.json() + return None + async def start(self): """Initialize the system.""" _LOGGER.debug("Initializing the sync module") diff --git a/pyproject.toml b/pyproject.toml index bee982a2..6572a855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools~=68.0", "wheel~=0.40.0"] +requires = ["setuptools>=68,<81", "wheel~=0.40.0"] build-backend = "setuptools.build_meta" [project] diff --git a/requirements_test.txt b/requirements_test.txt index 162a94e4..809435e6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,13 +1,13 @@ -ruff==0.5.5 +ruff==0.11.13 black==24.4.2 build==1.2.1 -coverage==7.6.0 -pytest==8.3.2 -pytest-cov==5.0.0 +coverage==7.8.2 +pytest==8.4.0 +pytest-cov==6.1.1 pytest-sugar==1.0.0 -pytest-timeout==2.3.1 +pytest-timeout==2.4.0 restructuredtext-lint==1.4.0 -pygments==2.18.0 +pygments==2.19.1 testtools>=2.4.0 sortedcontainers~=2.4.0 pytest-asyncio>=0.21.0 diff --git a/tests/test_api.py b/tests/test_api.py index 8077fdde..449bf4fe 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -203,3 +203,25 @@ async def test_wait_for_command(self, mock_resp): response = await api.wait_for_command(self.blink, None) self.assertFalse(response) + + async def test_request_camera_snooze(self, mock_resp): + """Test request_camera_snooze.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "owl", {} + ) + self.assertEqual(response.status, 200) + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "catalina", {} + ) + self.assertEqual(response.status, 200) + response = await api.request_camera_snooze( + self.blink, "network", "camera_id", "doorbell", {} + ) + self.assertEqual(response.status, 200) + + async def test_request_sync_snooze(self, mock_resp): + """Test sync snooze update.""" + mock_resp.return_value = mresp.MockResponse({}, 200) + response = await api.request_sync_snooze(self.blink, "network", {}) + self.assertEqual(response.status, 200) diff --git a/tests/test_camera_functions.py b/tests/test_camera_functions.py index 0ca75699..d066e364 100644 --- a/tests/test_camera_functions.py +++ b/tests/test_camera_functions.py @@ -9,6 +9,7 @@ import datetime from unittest import mock from unittest import IsolatedAsyncioTestCase +from blinkpy import api from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule @@ -222,6 +223,30 @@ async def test_night_vision(self, mock_resp): mock_resp.return_value = mresp.MockResponse({"code": 400}, 400) self.assertIsNone(await self.camera.async_set_night_vision("on")) + async def test_snooze_till(self, mock_resp): + """Test snooze_till property.""" + mock_resp = {"camera": [{"snooze_till": 1234567890}]} + with mock.patch.object( + api, + "request_get_config", + return_value=mock_resp, + ): + result = await self.camera.snooze_till + self.assertEqual(result, {"camera": [{"snooze_till": 1234567890}]}) + + async def test_async_snooze(self, mock_resp): + """Test async_snooze function.""" + mock_resp = mresp.MockResponse({}, 200) + with mock.patch("blinkpy.api.request_camera_snooze", return_value=mock_resp): + response = await self.camera.async_snooze() + self.assertEqual(response, {}) + mock_resp = mresp.MockResponse({}, 200) + with mock.patch("blinkpy.api.request_camera_snooze", return_value=mock_resp): + response = await self.camera.async_snooze() + self.assertEqual(response, {}) + response = await self.camera.async_snooze("invalid_value") + self.assertIsNone(response) + async def test_record(self, mock_resp): """Test camera record function.""" with mock.patch( diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index a1c81cbf..eff1c529 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -1,6 +1,7 @@ """Tests camera and system functions.""" import datetime +from json import dumps import logging from unittest import IsolatedAsyncioTestCase from unittest import mock @@ -653,3 +654,42 @@ async def test_download_delete(self, mock_prepdl, mock_del, mock_dl, mock_resp): mock_del.return_value = mock.AsyncMock() mock_dl.return_value = False self.assertFalse(await item.download_video_delete(self.blink, "filename.mp4")) + + async def test_async_snooze(self, mock_resp): + """Test successful snooze.""" + with mock.patch( + "blinkpy.api.request_sync_snooze", new_callable=mock.AsyncMock + ) as mock_resp_local: + mock_resp_local.return_value.status = 200 + mock_resp_local.return_value.json.return_value = {"status": 200} + snooze_time = 240 + expected_data = dumps({"snooze_time": snooze_time}) + expected_response = {"status": 200} + + self.assertEqual( + await self.blink.sync["test"].async_snooze(snooze_time), + expected_response, + ) + mock_resp_local.assert_called_once_with( + self.blink, + self.blink.sync["test"].network_id, + data=expected_data, + ) + + mock_resp_local.return_value.status = 400 + mock_resp_local.return_value.json.return_value = None + expected_response = None + + self.assertEqual( + await self.blink.sync["test"].async_snooze(snooze_time), + expected_response, + ) + + async def test_snooze_till(self, mock_resp) -> None: + """Test snooze_till method.""" + mock_resp.return_value = {"snooze_till": "2022-01-01T00:00:00Z"} + self.assertEqual( + await self.blink.sync["test"].snooze_till, "2022-01-01T00:00:00Z" + ) + mock_resp.return_value = None + self.assertIsNone(await self.blink.sync["test"].snooze_till) From 1e1f9a8e471f042ebd1eceb705fc24f7a231a860 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Mon, 9 Feb 2026 20:06:25 +0000 Subject: [PATCH 02/16] Support for 'hawk' (Blink Arc) cameras --- blinkpy/api.py | 8 ++++---- blinkpy/camera.py | 34 ++++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 372c9d98..eb5a3ae8 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -555,7 +555,7 @@ async def request_get_config(blink, network, camera_id, product_type="owl"): :param camera_id: ID of camera :param product_type: Camera product type "owl" or "catalina" """ - if product_type == "owl": + if product_type in ["owl", "hawk"]: url = ( f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" f"/networks/{network}/owls/{camera_id}/config" @@ -584,7 +584,7 @@ async def request_update_config( :param product_type: Camera product type "owl" or "catalina" :param data: string w/JSON dict of parameters/values to update """ - if product_type == "owl": + if product_type in ["owl", "hawk"]: url = ( f"{blink.urls.base_url}/api/v1/accounts/" f"{blink.account_id}/networks/{network}/owls/{camera_id}/config" @@ -610,7 +610,7 @@ async def request_camera_snooze( :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", "doorbell", or "hawk" :param data: string w/JSON dict of parameters/values to update """ if product_type == "catalina": @@ -618,7 +618,7 @@ async def request_camera_snooze( f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" f"networks/{network}/cameras/{camera_id}/snooze" ) - elif product_type == "owl": + elif product_type in ["owl", "hawk"]: url = ( f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" f"networks/{network}/owls/{camera_id}/snooze" diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 70536301..01e73eaa 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -180,15 +180,33 @@ async def async_set_night_vision(self, value): @property async def snooze_till(self): """Return snooze_till status.""" - res = await api.request_get_config( - self.sync.blink, - self.network_id, - self.camera_id, - product_type=self.product_type, - ) - if res is None: + if self.product_type == "catalina": + # Catalina cameras use the config endpoint + res = await api.request_get_config( + self.sync.blink, + self.network_id, + self.camera_id, + product_type=self.product_type, + ) + if res is None: + return None + res = res.get("camera", [{}])[0] + return res.get("snooze_till") + else: + # Owl/hawk/mini cameras get snooze info from homescreen + try: + for owl in self.sync.blink.homescreen.get("owls", []): + # Compare as integers to handle type mismatch + if int(owl.get("id")) == int(self.camera_id): + if owl.get("snooze"): + return { + "snooze": owl.get("snooze"), + "time_remaining": owl.get("snooze_time_remaining"), + } + return None + except (TypeError, KeyError, ValueError): + pass return None - return res.get("camera", [{}])[0].get("snooze_till") async def async_snooze(self, snooze_time=240): """Set camera snooze status.""" From f327a1c95a47cf0d5369c2e1589e4f64a5cc87f7 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Mon, 9 Feb 2026 20:12:45 +0000 Subject: [PATCH 03/16] Remove inaccurate tests. Will re-add --- tests/test_api.py | 22 ------------------- tests/test_camera_functions.py | 25 --------------------- tests/test_sync_module.py | 40 ---------------------------------- 3 files changed, 87 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index b9b4ccbf..f8f77b89 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -205,25 +205,3 @@ async def test_wait_for_command(self, mock_resp): response = await api.wait_for_command(self.blink, None) self.assertFalse(response) - - async def test_request_camera_snooze(self, mock_resp): - """Test request_camera_snooze.""" - mock_resp.return_value = mresp.MockResponse({}, 200) - response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "owl", {} - ) - self.assertEqual(response.status, 200) - response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "catalina", {} - ) - self.assertEqual(response.status, 200) - response = await api.request_camera_snooze( - self.blink, "network", "camera_id", "doorbell", {} - ) - self.assertEqual(response.status, 200) - - async def test_request_sync_snooze(self, mock_resp): - """Test sync snooze update.""" - mock_resp.return_value = mresp.MockResponse({}, 200) - response = await api.request_sync_snooze(self.blink, "network", {}) - self.assertEqual(response.status, 200) diff --git a/tests/test_camera_functions.py b/tests/test_camera_functions.py index d066e364..0ca75699 100644 --- a/tests/test_camera_functions.py +++ b/tests/test_camera_functions.py @@ -9,7 +9,6 @@ import datetime from unittest import mock from unittest import IsolatedAsyncioTestCase -from blinkpy import api from blinkpy.blinkpy import Blink from blinkpy.helpers.util import BlinkURLHandler from blinkpy.sync_module import BlinkSyncModule @@ -223,30 +222,6 @@ async def test_night_vision(self, mock_resp): mock_resp.return_value = mresp.MockResponse({"code": 400}, 400) self.assertIsNone(await self.camera.async_set_night_vision("on")) - async def test_snooze_till(self, mock_resp): - """Test snooze_till property.""" - mock_resp = {"camera": [{"snooze_till": 1234567890}]} - with mock.patch.object( - api, - "request_get_config", - return_value=mock_resp, - ): - result = await self.camera.snooze_till - self.assertEqual(result, {"camera": [{"snooze_till": 1234567890}]}) - - async def test_async_snooze(self, mock_resp): - """Test async_snooze function.""" - mock_resp = mresp.MockResponse({}, 200) - with mock.patch("blinkpy.api.request_camera_snooze", return_value=mock_resp): - response = await self.camera.async_snooze() - self.assertEqual(response, {}) - mock_resp = mresp.MockResponse({}, 200) - with mock.patch("blinkpy.api.request_camera_snooze", return_value=mock_resp): - response = await self.camera.async_snooze() - self.assertEqual(response, {}) - response = await self.camera.async_snooze("invalid_value") - self.assertIsNone(response) - async def test_record(self, mock_resp): """Test camera record function.""" with mock.patch( diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index 7a1d3392..b35f56fd 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -1,7 +1,6 @@ """Tests camera and system functions.""" import datetime -from json import dumps import logging from unittest import IsolatedAsyncioTestCase from unittest import mock @@ -654,42 +653,3 @@ async def test_download_delete(self, mock_prepdl, mock_del, mock_dl, mock_resp): mock_del.return_value = mock.AsyncMock() mock_dl.return_value = False self.assertFalse(await item.download_video_delete(self.blink, "filename.mp4")) - - async def test_async_snooze(self, mock_resp): - """Test successful snooze.""" - with mock.patch( - "blinkpy.api.request_sync_snooze", new_callable=mock.AsyncMock - ) as mock_resp_local: - mock_resp_local.return_value.status = 200 - mock_resp_local.return_value.json.return_value = {"status": 200} - snooze_time = 240 - expected_data = dumps({"snooze_time": snooze_time}) - expected_response = {"status": 200} - - self.assertEqual( - await self.blink.sync["test"].async_snooze(snooze_time), - expected_response, - ) - mock_resp_local.assert_called_once_with( - self.blink, - self.blink.sync["test"].network_id, - data=expected_data, - ) - - mock_resp_local.return_value.status = 400 - mock_resp_local.return_value.json.return_value = None - expected_response = None - - self.assertEqual( - await self.blink.sync["test"].async_snooze(snooze_time), - expected_response, - ) - - async def test_snooze_till(self, mock_resp) -> None: - """Test snooze_till method.""" - mock_resp.return_value = {"snooze_till": "2022-01-01T00:00:00Z"} - self.assertEqual( - await self.blink.sync["test"].snooze_till, "2022-01-01T00:00:00Z" - ) - mock_resp.return_value = None - self.assertIsNone(await self.blink.sync["test"].snooze_till) From 8009230a5bebd9175e3d992c0d8cce1bca6a0780 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Mon, 9 Feb 2026 20:20:08 +0000 Subject: [PATCH 04/16] Remove json import and use json_dumps --- blinkpy/sync_module.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 63084f19..86a73aa7 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -3,7 +3,6 @@ import logging import string import datetime -from json import dumps import traceback import asyncio import aiofiles @@ -142,7 +141,7 @@ async def snooze_till(self): async def async_snooze(self, snooze_time=240): """Set sync snooze status.""" - data = dumps({"snooze_time": snooze_time}) + data = json_dumps({"snooze_time": snooze_time}) res = await api.request_sync_snooze( self.blink, self.network_id, From a7aa35d05f54315ba0cbaaf639207f8bd02aec4a Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Mon, 9 Feb 2026 20:23:38 +0000 Subject: [PATCH 05/16] Document snooze time parameter --- blinkpy/camera.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 01e73eaa..29d8e7e1 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -209,7 +209,11 @@ async def snooze_till(self): return None async def async_snooze(self, snooze_time=240): - """Set camera snooze status.""" + """ + Set camera snooze status. + + :param snooze_time: Time in minutes to snooze camera. Default is 240 (4 hours). + """ data = dumps({"snooze_time": snooze_time}) res = await api.request_camera_snooze( self.sync.blink, From ab2385313f3bb865f999477cf409d267fb2f5932 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 10:10:24 -0500 Subject: [PATCH 06/16] Apply PR Suggestion Co-authored-by: Kevin Fronczak --- blinkpy/api.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index eb5a3ae8..34ab4fd3 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -613,28 +613,22 @@ async def request_camera_snooze( :param product_type: Camera product type "owl", "catalina", "doorbell", or "hawk" :param data: string w/JSON dict of parameters/values to update """ - if product_type == "catalina": - url = ( - f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" - f"networks/{network}/cameras/{camera_id}/snooze" - ) - elif product_type in ["owl", "hawk"]: - url = ( - f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" - f"networks/{network}/owls/{camera_id}/snooze" - ) - elif product_type == "doorbell": - url = ( - f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/" - f"networks/{network}/doorbells/{camera_id}/snooze" - ) - else: + product_lookup = { + "catalina": "cameras", + "owl": "owls", + "doorbell": "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"}/networks/{network}" + url = f"{url_root}/product_lookup[product_type]/{camera_id}/snooze" return await http_post(blink, url, json=True, data=data) From 35455d6ab0a37e209c140d8dba3d4ac078ac21a0 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 10:11:05 -0500 Subject: [PATCH 07/16] Apply PR Suggestion Co-authored-by: Kevin Fronczak --- blinkpy/camera.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 29d8e7e1..321e4485 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -601,16 +601,9 @@ async def get_liveview(self): link = server.replace("immis://", "rtsps://") return link - async def async_snooze(self): + async def async_snooze(self, snooze_time=240): """Set camera snooze status.""" - data = dumps({"snooze_time": 240}) - res = await api.request_camera_snooze( - self.sync.blink, - self.network_id, - self.camera_id, - product_type="doorbell", - data=data, - ) + res = await super().async_snooze(snooze_time=snooze_time) if res and res.status == 200: return await res.json() return None From e891da6f811dd9bba8503ee2573cb00ab9da4971 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 15:20:58 +0000 Subject: [PATCH 08/16] Log an error when snooze fails for a camera. --- blinkpy/camera.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 321e4485..793ac73b 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -188,10 +188,12 @@ async def snooze_till(self): self.camera_id, product_type=self.product_type, ) - if res is None: + try: + return res["camera"][0]["snooze_till"] + except TypeError: return None - res = res.get("camera", [{}])[0] - return res.get("snooze_till") + except (IndexError, KeyError) as e: + _LOGGER.warning("Exception %s: Encountered a likely malformed response from the snooze API endpoint. Response: %s", e, res) else: # Owl/hawk/mini cameras get snooze info from homescreen try: @@ -200,12 +202,14 @@ async def snooze_till(self): if int(owl.get("id")) == int(self.camera_id): if owl.get("snooze"): return { - "snooze": owl.get("snooze"), - "time_remaining": owl.get("snooze_time_remaining"), + "snooze": owl["snooze"], + "time_remaining": owl["snooze_time_remaining"], } return None - except (TypeError, KeyError, ValueError): - pass + except TypeError: + return None + except (KeyError, ValueError) as e: + _LOGGER.warning("Exception %s: Encountered a likely malformed response from the snooze API endpoint. Response: %s", e, self.sync.blink.homescreen) return None async def async_snooze(self, snooze_time=240): From f12a23a0aef0dc945f5dee07285a16b4b22029b5 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 15:33:29 +0000 Subject: [PATCH 09/16] PR feedback and improvement to try/catch structure. --- blinkpy/camera.py | 55 +++++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 793ac73b..f80c9487 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -180,36 +180,30 @@ async def async_set_night_vision(self, value): @property async def snooze_till(self): """Return snooze_till status.""" - if self.product_type == "catalina": - # Catalina cameras use the config endpoint - res = await api.request_get_config( - self.sync.blink, - self.network_id, - self.camera_id, - product_type=self.product_type, - ) - try: + response_data = None + try: + if self.product_type == "catalina": + # Catalina cameras use the config endpoint + res = await api.request_get_config( + self.sync.blink, + self.network_id, + self.camera_id, + product_type=self.product_type, + ) + response_data = res return res["camera"][0]["snooze_till"] - except TypeError: - return None - except (IndexError, KeyError) as e: - _LOGGER.warning("Exception %s: Encountered a likely malformed response from the snooze API endpoint. Response: %s", e, res) - else: - # Owl/hawk/mini cameras get snooze info from homescreen - try: + else: + # Owl/hawk/mini cameras get snooze info from homescreen + response_data = self.sync.blink.homescreen for owl in self.sync.blink.homescreen.get("owls", []): # Compare as integers to handle type mismatch if int(owl.get("id")) == int(self.camera_id): - if owl.get("snooze"): - return { - "snooze": owl["snooze"], - "time_remaining": owl["snooze_time_remaining"], - } - return None - except TypeError: + return owl["snooze"] return None - except (KeyError, ValueError) as e: - _LOGGER.warning("Exception %s: Encountered a likely malformed response from the snooze API endpoint. Response: %s", e, self.sync.blink.homescreen) + except TypeError: + return None + 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 None async def async_snooze(self, snooze_time=240): @@ -226,7 +220,9 @@ async def async_snooze(self, snooze_time=240): product_type=self.product_type, data=data, ) - return res + if res and res.status == 200: + return await res.json() + return None async def record(self): """Initiate clip recording.""" @@ -604,10 +600,3 @@ async def get_liveview(self): server = response["server"] link = server.replace("immis://", "rtsps://") return link - - async def async_snooze(self, snooze_time=240): - """Set camera snooze status.""" - res = await super().async_snooze(snooze_time=snooze_time) - if res and res.status == 200: - return await res.json() - return None From 1715a8bc0e24a0f8432d7d9169342b1e3d422e27 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 15:47:10 +0000 Subject: [PATCH 10/16] Fix tests before adding new tests --- blinkpy/api.py | 17 ++++++++--------- blinkpy/camera.py | 9 +++++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 34ab4fd3..754ddd39 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -613,12 +613,8 @@ async def request_camera_snooze( :param product_type: Camera product type "owl", "catalina", "doorbell", or "hawk" :param data: string w/JSON dict of parameters/values to update """ - product_lookup = { - "catalina": "cameras", - "owl": "owls", - "doorbell": "doorbells" - } - + product_lookup = {"catalina": "cameras", "owl": "owls", "doorbell": "doorbells"} + if product_type not in product_lookup: _LOGGER.info( "Camera %s with product type %s snooze update not implemented.", @@ -626,9 +622,12 @@ async def request_camera_snooze( product_type, ) return None - - url_root = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id"}/networks/{network}" - url = f"{url_root}/product_lookup[product_type]/{camera_id}/snooze" + + 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 f80c9487..333d3b28 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -203,13 +203,18 @@ async def snooze_till(self): except TypeError: return None 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) + _LOGGER.warning( + "Exception %s: Encountered a likely malformed response " + "from the snooze API endpoint. Response: %s", + e, + response_data, + ) return None async def async_snooze(self, snooze_time=240): """ Set camera snooze status. - + :param snooze_time: Time in minutes to snooze camera. Default is 240 (4 hours). """ data = dumps({"snooze_time": snooze_time}) From 6f50411250d4c5ebb30e3bc687f651acf9d83673 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 16:03:20 +0000 Subject: [PATCH 11/16] Add creds.json and test.py to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e06b56ba..3069337d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ Pipfile.lock blink.json blinktest.py .vscode/* +creds.json +test.py From 52a77251534a51a03d5ef60da6e3fdbc3768b6a0 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 16:04:38 +0000 Subject: [PATCH 12/16] Revert "Add creds.json and test.py to gitignore" This reverts commit 6f50411250d4c5ebb30e3bc687f651acf9d83673. --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3069337d..e06b56ba 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,3 @@ Pipfile.lock blink.json blinktest.py .vscode/* -creds.json -test.py From 725dbbf950fb1ece6ad5292011f0acb5354823b1 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 17:30:25 +0000 Subject: [PATCH 13/16] blinkpy api changes to better work with blink api --- blinkpy/api.py | 21 +++++++++++++++++---- blinkpy/camera.py | 43 +++++++++++++++++++++++++++--------------- blinkpy/sync_module.py | 39 +++++++++++++++++++++++++------------- 3 files changed, 71 insertions(+), 32 deletions(-) diff --git a/blinkpy/api.py b/blinkpy/api.py index 754ddd39..8ea2f7d0 100644 --- a/blinkpy/api.py +++ b/blinkpy/api.py @@ -553,14 +553,19 @@ 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 """ if product_type in ["owl", "hawk"]: 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 ["doorbell", "lotus"]: + url = ( + f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" + f"/networks/{network}/doorbells/{camera_id}/config" + ) + elif product_type in ["catalina", "sedona"]: url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config" else: _LOGGER.info( @@ -610,10 +615,18 @@ async def request_camera_snooze( :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", or "hawk" + :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", "owl": "owls", "doorbell": "doorbells"} + product_lookup = { + "catalina": "cameras", + "sedona": "cameras", # Older outdoor cameras use same endpoint as catalina + "owl": "owls", + "hawk": "owls", # Hawk uses same endpoint as owl + "doorbell": "doorbells", + "lotus": "doorbells", # Lotus is internal name for doorbells + } if product_type not in product_lookup: _LOGGER.info( diff --git a/blinkpy/camera.py b/blinkpy/camera.py index 333d3b28..e090f769 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -178,12 +178,12 @@ async def async_set_night_vision(self, value): return None @property - async def snooze_till(self): - """Return snooze_till status.""" + async def snoozed(self): + """Return snooze status as boolean.""" response_data = None try: - if self.product_type == "catalina": - # Catalina cameras use the config endpoint + if self.product_type in ["catalina", "sedona"]: + # Catalina and Sedona cameras have snooze timestamp in config res = await api.request_get_config( self.sync.blink, self.network_id, @@ -191,17 +191,28 @@ async def snooze_till(self): product_type=self.product_type, ) response_data = res - return res["camera"][0]["snooze_till"] + snooze_value = res["camera"][0].get("snooze_till") + # Return True if timestamp exists and is not empty + return bool(snooze_value) else: - # Owl/hawk/mini cameras get snooze info from homescreen + # Owl/hawk/mini/doorbell/lotus cameras get snooze from homescreen response_data = self.sync.blink.homescreen - for owl in self.sync.blink.homescreen.get("owls", []): + + # Determine which homescreen collection to search + if self.product_type in ["doorbell", "lotus"]: + collection_key = "doorbells" + else: # owl, hawk, mini + collection_key = "owls" + + for device in self.sync.blink.homescreen.get(collection_key, []): # Compare as integers to handle type mismatch - if int(owl.get("id")) == int(self.camera_id): - return owl["snooze"] - return None + if int(device.get("id")) == int(self.camera_id): + snooze_value = device.get("snooze") + # Return True if snooze value exists and is truthy + return bool(snooze_value) + return False except TypeError: - return None + return False except (IndexError, KeyError, ValueError) as e: _LOGGER.warning( "Exception %s: Encountered a likely malformed response " @@ -209,7 +220,7 @@ async def snooze_till(self): e, response_data, ) - return None + return False async def async_snooze(self, snooze_time=240): """ @@ -225,9 +236,11 @@ async def async_snooze(self, snooze_time=240): product_type=self.product_type, data=data, ) - if res and res.status == 200: - return await res.json() - return None + # Refresh homescreen to update snooze status + if res and self.product_type not in ["catalina", "sedona"]: + # Owl/hawk/mini/doorbell/lotus cameras use homescreen for snooze status + await self.sync.blink.get_homescreen() + return res async def record(self): """Initiate clip recording.""" diff --git a/blinkpy/sync_module.py b/blinkpy/sync_module.py index 86a73aa7..56305f44 100644 --- a/blinkpy/sync_module.py +++ b/blinkpy/sync_module.py @@ -128,16 +128,29 @@ async def async_arm(self, value): return await api.request_system_disarm(self.blink, self.network_id) @property - async def snooze_till(self): - """Return snooze_till status.""" - res = await api.request_sync_snooze( - self.blink, - self.network_id, - ) - if res is None: - return None - res = res.get("snooze_till") - return res + async def snoozed(self): + """Return snooze status as boolean.""" + res = None + try: + res = await api.request_sync_snooze( + self.blink, + self.network_id, + ) + if res is None: + return False + snooze_value = res.get("snooze_till") + # Return True if timestamp exists and is not empty + return bool(snooze_value) + except TypeError: + return False + except KeyError as e: + _LOGGER.warning( + "Exception %s: Encountered a likely malformed response " + "from the snooze API endpoint. Response: %s", + e, + res, + ) + return False async def async_snooze(self, snooze_time=240): """Set sync snooze status.""" @@ -147,9 +160,9 @@ async def async_snooze(self, snooze_time=240): self.network_id, data=data, ) - if res and res.status == 200: - return await res.json() - return None + # Refresh homescreen is not needed for sync-level snooze + # as it's retrieved directly from the sync endpoint + return res async def start(self): """Initialize the system.""" From 686d8c5b0db951d2c4225145f32fb913c976ab99 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 17:30:45 +0000 Subject: [PATCH 14/16] Add new tests --- tests/test_api.py | 48 +++++++++ tests/test_cameras.py | 215 ++++++++++++++++++++++++++++++++++++++ tests/test_sync_module.py | 63 +++++++++++ 3 files changed, 326 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index f8f77b89..7574938c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -189,6 +189,54 @@ 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_request_sync_snooze(self, mock_resp): + """Test sync snooze request.""" + mock_resp.return_value = {"message": "Sync snoozed"} + response = await api.request_sync_snooze( + self.blink, "network", '{"snooze": 300}' + ) + self.assertEqual(response, {"message": "Sync snoozed"}) + 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..6ea1f620 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -102,6 +102,221 @@ async def test_doorbell_arm_disarm(self, mock_resp): self.assertEqual(await self.doorbell.async_arm(True), "arm") self.assertEqual(await self.doorbell.async_arm(False), "disarm") + @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 + 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" + 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" + 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) + + @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" + 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" + 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" + result = await self.camera.snoozed + self.assertFalse(result) + def test_missing_attributes(self, mock_resp): """Test that attributes return None if missing.""" self.camera.temperature = None diff --git a/tests/test_sync_module.py b/tests/test_sync_module.py index b35f56fd..040beb2c 100644 --- a/tests/test_sync_module.py +++ b/tests/test_sync_module.py @@ -82,6 +82,69 @@ def test_bad_arm(self, mock_resp) -> None: self.assertEqual(self.blink.sync["test"].arm, None) self.assertFalse(self.blink.sync["test"].available) + @mock.patch( + "blinkpy.api.request_sync_snooze", + mock.AsyncMock(return_value={"snooze_till": "2026-02-15T12:00:00+00:00"}), + ) + async def test_snoozed(self, mock_resp) -> None: + """Check that we get snoozed status.""" + result = await self.blink.sync["test"].snoozed + self.assertTrue(result) + + @mock.patch( + "blinkpy.api.request_sync_snooze", + mock.AsyncMock(return_value=None), + ) + async def test_snoozed_none(self, mock_resp) -> None: + """Check that we handle None response.""" + result = await self.blink.sync["test"].snoozed + self.assertFalse(result) + + @mock.patch( + "blinkpy.api.request_sync_snooze", + mock.AsyncMock(return_value={}), + ) + async def test_snoozed_malformed(self, mock_resp) -> None: + """Check that we handle malformed response.""" + result = await self.blink.sync["test"].snoozed + self.assertFalse(result) + + @mock.patch( + "blinkpy.api.request_sync_snooze", + mock.AsyncMock(return_value={"snooze_till": ""}), + ) + async def test_snoozed_empty_string(self, mock_resp) -> None: + """Check that we handle empty string response.""" + result = await self.blink.sync["test"].snoozed + self.assertFalse(result) + + @mock.patch( + "blinkpy.api.request_sync_snooze", + mock.AsyncMock(return_value={"status": 200}), + ) + async def test_async_snooze(self, mock_resp) -> None: + """Check that we can set snooze.""" + result = await self.blink.sync["test"].async_snooze(300) + self.assertEqual(result, {"status": 200}) + + @mock.patch( + "blinkpy.api.request_sync_snooze", + mock.AsyncMock(return_value={"status": 400}), + ) + async def test_async_snooze_failure(self, mock_resp) -> None: + """Check that we handle snooze failure.""" + result = await self.blink.sync["test"].async_snooze(300) + self.assertEqual(result, {"status": 400}) + + @mock.patch( + "blinkpy.api.request_sync_snooze", + mock.AsyncMock(return_value=None), + ) + async def test_async_snooze_none_response(self, mock_resp) -> None: + """Check that we handle None response when setting snooze.""" + result = await self.blink.sync["test"].async_snooze(300) + self.assertIsNone(result) + def test_get_unique_info_valid_device(self, mock_resp) -> None: """Check that we get the correct info.""" device = { From 800743c48c9cb66ca5fcaedaf9041edcb1ad25b5 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 17:33:42 +0000 Subject: [PATCH 15/16] Note that snooze time is in seconds, not minutes. This was a blink API change. --- blinkpy/camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blinkpy/camera.py b/blinkpy/camera.py index e090f769..5c759c53 100644 --- a/blinkpy/camera.py +++ b/blinkpy/camera.py @@ -222,11 +222,11 @@ async def snoozed(self): ) return False - async def async_snooze(self, snooze_time=240): + async def async_snooze(self, snooze_time=3600): """ Set camera snooze status. - :param snooze_time: Time in minutes to snooze camera. Default is 240 (4 hours). + :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( From 8bad1692e4ad15a1d33a5a157f2cae9b718f07d2 Mon Sep 17 00:00:00 2001 From: Austin Tyler Conn Date: Tue, 10 Feb 2026 18:30:21 +0000 Subject: [PATCH 16/16] Fix tests. --- tests/test_cameras.py | 52 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/test_cameras.py b/tests/test_cameras.py index 6ea1f620..b494256a 100644 --- a/tests/test_cameras.py +++ b/tests/test_cameras.py @@ -128,7 +128,7 @@ async def test_camera_snooze_failure(self, mock_resp): self.blink, "get_homescreen", mock.AsyncMock() ) as mock_homescreen: result = await self.camera.async_snooze(300) - # Non-catalina/sedona cameras refresh homescreen + # Non-catalina/sedona cameras refresh homescreen even on failure mock_homescreen.assert_called_once() self.assertEqual(result, {"status": 400}) @@ -186,6 +186,8 @@ async def test_camera_snooze_sedona(self, mock_resp): 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) @@ -198,6 +200,9 @@ async def test_camera_snoozed_owl(self, mock_resp): } result = await self.camera.snoozed self.assertTrue(result) + # Verify we're reading from homescreen, not calling API + expected_snooze = "2026-02-15T12:00:00+00:00" + self.assertEqual(self.blink.homescreen["owls"][0]["snooze"], expected_snooze) async def test_camera_snoozed_owl_false(self, mock_resp): """Test getting snoozed status returns False when not snoozed.""" @@ -208,6 +213,9 @@ async def test_camera_snoozed_owl_false(self, mock_resp): } result = await self.camera.snoozed self.assertFalse(result) + # Verify camera ID 9999 is not in homescreen + camera_ids = [str(cam["id"]) for cam in self.blink.homescreen["owls"]] + self.assertNotIn("9999", camera_ids) async def test_camera_snoozed_doorbell(self, mock_resp): """Test getting snoozed status for doorbell camera.""" @@ -218,6 +226,9 @@ async def test_camera_snoozed_doorbell(self, mock_resp): } result = await self.camera.snoozed self.assertTrue(result) + # Verify we're reading from the doorbells collection + self.assertIn("doorbells", self.blink.homescreen) + self.assertEqual(self.blink.homescreen["doorbells"][0]["id"], "5678") async def test_camera_snoozed_lotus(self, mock_resp): """Test getting snoozed status for lotus (doorbell) camera.""" @@ -228,6 +239,8 @@ async def test_camera_snoozed_lotus(self, mock_resp): } result = await self.camera.snoozed self.assertTrue(result) + # Verify lotus uses doorbells collection + self.assertIn("doorbells", self.blink.homescreen) async def test_camera_snoozed_hawk(self, mock_resp): """Test getting snoozed status for hawk camera.""" @@ -238,6 +251,8 @@ async def test_camera_snoozed_hawk(self, mock_resp): } result = await self.camera.snoozed self.assertTrue(result) + # Verify hawk uses owls collection + self.assertIn("owls", self.blink.homescreen) @mock.patch( "blinkpy.api.request_get_config", @@ -248,6 +263,8 @@ async def test_camera_snoozed_hawk(self, mock_resp): 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) @@ -266,6 +283,10 @@ async def test_camera_snoozed_boolean_false(self, mock_resp): self.blink.homescreen = {"owls": [{"id": "1234", "snooze": False}]} result = await self.camera.snoozed self.assertFalse(result) + # Verify the snooze value is actually False, not missing + camera = next(c for c in self.blink.homescreen["owls"] if c["id"] == "1234") + self.assertIn("snooze", camera) + self.assertFalse(camera["snooze"]) async def test_camera_snoozed_mini(self, mock_resp): """Test getting snoozed status for mini camera from homescreen.""" @@ -286,6 +307,29 @@ async def test_camera_snoozed_mini_false(self, mock_resp): } result = await self.camera.snoozed self.assertFalse(result) + # Verify mini camera ID not found in owls collection + camera_ids = [str(cam["id"]) for cam in self.blink.homescreen["owls"]] + self.assertNotIn("9999", camera_ids) + + 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) + # Verify owls collection is empty + self.assertEqual(len(self.blink.homescreen["owls"]), 0) + + 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) + # Verify doorbells collection is missing + self.assertNotIn("doorbells", self.blink.homescreen) @mock.patch( "blinkpy.api.request_get_config", @@ -294,6 +338,8 @@ async def test_camera_snoozed_mini_false(self, mock_resp): 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) @@ -304,6 +350,8 @@ async def test_camera_snoozed_none(self, mock_resp): 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) @@ -314,6 +362,8 @@ async def test_camera_snoozed_malformed(self, mock_resp): 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)