Skip to content

Commit 078405c

Browse files
authored
Merge pull request #724 from fronzbot/dev
0.21.0
2 parents fa959f8 + ddc61f6 commit 078405c

File tree

8 files changed

+213
-24
lines changed

8 files changed

+213
-24
lines changed

CHANGES.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@ Changelog
44

55
A list of changes between each release
66

7+
0.21.0 (2023-05-28)
8+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
9+
10+
**Bugfixes**
11+
12+
- None
13+
14+
**New Features**
15+
16+
- Add get_videos_metadata function (`@rhhayward #685 <https://github.com/fronzbot/blinkpy/pull/685>`__)
17+
- Add night vision toggling support (`@jrhunger #717 <https://github.com/fronzbot/blinkpy/pull/717>`__)
18+
- Add doorbell arming functionality (`@mkmer #719 <https://github.com/fronzbot/blinkpy/pull/719>`__)
19+
20+
**Other Changes**
21+
22+
- Upgrade pylint to 2.17.4
23+
- Upgrade coverage to 7.2.5
24+
- Upgrade pygments to 2.15.1
25+
- Upgrade pytest to 7.3.1
26+
- Upgrade pytest-sugar to 0.9.7
27+
- Upgrade black to 23.3.0
28+
29+
730
0.20.0 (2023-01-29)
831
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
932

blinkpy/api.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,51 @@ def request_local_storage_clip(blink, network, sync_id, manifest_id, clip_id):
352352
return http_post(blink, url)
353353

354354

355+
def request_get_config(blink, network, camera_id, product_type="owl"):
356+
"""Get camera configuration.
357+
358+
:param blink: Blink instance.
359+
:param network: Sync module network id.
360+
:param camera_id: ID of camera
361+
:param product_type: Camera product type "owl" or "catalina"
362+
"""
363+
if product_type == "owl":
364+
url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/owls/{camera_id}/config"
365+
elif product_type == "catalina":
366+
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config"
367+
else:
368+
_LOGGER.info(
369+
"Camera %s with product type %s config get not implemented.",
370+
camera_id,
371+
product_type,
372+
)
373+
return None
374+
return http_get(blink, url)
375+
376+
377+
def request_update_config(blink, network, camera_id, product_type="owl", data=None):
378+
"""Update camera configuration.
379+
380+
:param blink: Blink instance.
381+
:param network: Sync module network id.
382+
:param camera_id: ID of camera
383+
:param product_type: Camera product type "owl" or "catalina"
384+
:param data: string w/JSON dict of parameters/values to update
385+
"""
386+
if product_type == "owl":
387+
url = f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}/networks/{network}/owls/{camera_id}/update"
388+
elif product_type == "catalina":
389+
url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/update"
390+
else:
391+
_LOGGER.info(
392+
"Camera %s with product type %s config update not implemented.",
393+
camera_id,
394+
product_type,
395+
)
396+
return None
397+
return http_post(blink, url, json=False, data=data)
398+
399+
355400
def http_get(blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOUT):
356401
"""Perform an http get request.
357402

blinkpy/blinkpy.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,22 @@ def download_videos(
330330
:param debug: Set to TRUE to prevent downloading of items.
331331
Instead of downloading, entries will be printed to log.
332332
"""
333+
if not isinstance(camera, list):
334+
camera = [camera]
335+
336+
results = self.get_videos_metadata(since=since, stop=stop)
337+
self._parse_downloaded_items(results, camera, path, delay, debug)
338+
339+
def get_videos_metadata(self, since=None, camera="all", stop=10):
340+
"""
341+
Fetch and return video metadata.
342+
343+
:param since: Date and time to get videos from.
344+
Ex: "2018/07/28 12:33:00" to retrieve videos since
345+
July 28th 2018 at 12:33:00
346+
:param stop: Page to stop on (~25 items per page. Default page 10).
347+
"""
348+
videos = []
333349
if since is None:
334350
since_epochs = self.last_refresh
335351
else:
@@ -339,21 +355,33 @@ def download_videos(
339355
formatted_date = util.get_time(time_to_convert=since_epochs)
340356
_LOGGER.info("Retrieving videos since %s", formatted_date)
341357

342-
if not isinstance(camera, list):
343-
camera = [camera]
344-
345358
for page in range(1, stop):
346359
response = api.request_videos(self, time=since_epochs, page=page)
347360
_LOGGER.debug("Processing page %s", page)
348361
try:
349362
result = response["media"]
350363
if not result:
351364
raise KeyError
365+
videos.extend(result)
352366
except (KeyError, TypeError):
353367
_LOGGER.info("No videos found on page %s. Exiting.", page)
354368
break
369+
return videos
355370

356-
self._parse_downloaded_items(result, camera, path, delay, debug)
371+
def do_http_get(self, address):
372+
"""
373+
Do an http_get on address.
374+
375+
:param address: address to be added to base_url.
376+
"""
377+
response = api.http_get(
378+
self,
379+
url=f"{self.urls.base_url}{address}",
380+
stream=True,
381+
json=False,
382+
timeout=TIMEOUT_MEDIA,
383+
)
384+
return response
357385

358386
def _parse_downloaded_items(self, result, camera, path, delay, debug):
359387
"""Parse downloaded videos."""
@@ -375,7 +403,6 @@ def _parse_downloaded_items(self, result, camera, path, delay, debug):
375403
_LOGGER.debug("%s: %s is marked as deleted.", camera_name, address)
376404
continue
377405

378-
clip_address = f"{self.urls.base_url}{address}"
379406
filename = f"{camera_name}-{created_at}"
380407
filename = f"{slugify(filename)}.mp4"
381408
filename = os.path.join(path, filename)
@@ -385,13 +412,7 @@ def _parse_downloaded_items(self, result, camera, path, delay, debug):
385412
_LOGGER.info("%s already exists, skipping...", filename)
386413
continue
387414

388-
response = api.http_get(
389-
self,
390-
url=clip_address,
391-
stream=True,
392-
json=False,
393-
timeout=TIMEOUT_MEDIA,
394-
)
415+
response = self.do_http_get(address)
395416
with open(filename, "wb") as vidfile:
396417
copyfileobj(response.raw, vidfile)
397418

blinkpy/camera.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,49 @@ def arm(self, value):
109109
self.sync.blink, self.network_id, self.camera_id
110110
)
111111

112+
@property
113+
def night_vision(self):
114+
"""Return night_vision status."""
115+
res = api.request_get_config(
116+
self.sync.blink,
117+
self.network_id,
118+
self.camera_id,
119+
product_type=self.product_type,
120+
)
121+
if res is None:
122+
return None
123+
if self.product_type == "catalina":
124+
res = res.get("camera", [{}])[0]
125+
if res["illuminator_enable"] in [0, 1, 2]:
126+
res["illuminator_enable"] = ["off", "on", "auto"][
127+
res.get("illuminator_enable")
128+
]
129+
nv_keys = [
130+
"night_vision_control",
131+
"illuminator_enable",
132+
"illuminator_enable_v2",
133+
]
134+
return {key: res.get(key) for key in nv_keys}
135+
136+
@night_vision.setter
137+
def night_vision(self, value):
138+
"""Set camera night_vision status."""
139+
if value not in ["on", "off", "auto"]:
140+
return None
141+
if self.product_type == "catalina":
142+
value = {"off": 0, "on": 1, "auto": 2}.get(value, None)
143+
data = dumps({"illuminator_enable": value})
144+
res = api.request_update_config(
145+
self.sync.blink,
146+
self.network_id,
147+
self.camera_id,
148+
product_type=self.product_type,
149+
data=data,
150+
)
151+
if res.ok:
152+
return res.json()
153+
return None
154+
112155
def record(self):
113156
"""Initiate clip recording."""
114157
return api.request_new_video(self.sync.blink, self.network_id, self.camera_id)
@@ -440,14 +483,17 @@ def __init__(self, sync):
440483
@property
441484
def arm(self):
442485
"""Return camera arm status."""
443-
return self.sync.arm
486+
return self.motion_enabled
444487

445488
@arm.setter
446489
def arm(self, value):
447490
"""Set camera arm status."""
448-
_LOGGER.warning(
449-
"Individual camera motion detection enable/disable for Blink Doorbell is unsupported at this time."
450-
)
491+
url = f"{self.sync.urls.base_url}/api/v1/accounts/{self.sync.blink.account_id}/networks/{self.sync.network_id}/doorbells/{self.camera_id}"
492+
if value:
493+
url = f"{url}/enable"
494+
else:
495+
url = f"{url}/disable"
496+
return api.http_post(self.sync.blink, url)
451497

452498
def snap_picture(self):
453499
"""Snap picture for a blink doorbell camera."""

blinkpy/helpers/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44

55
MAJOR_VERSION = 0
6-
MINOR_VERSION = 20
6+
MINOR_VERSION = 21
77
PATCH_VERSION = 0
88

99
__version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}"

requirements_test.txt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
black==22.12.0
2-
coverage==7.1.0
1+
black==23.3.0
2+
coverage==7.2.5
33
flake8==6.0.0
4-
pre-commit==3.0.2
4+
pre-commit==3.0.4
55
flake8-docstrings==1.7.0
6-
pylint==2.15.10
6+
pylint==2.17.4
77
pydocstyle==6.3.0
8-
pytest==7.2.1
8+
pytest==7.3.1
99
pytest-cov==3.0.0
10-
pytest-sugar==0.9.6
10+
pytest-sugar==0.9.7
1111
pytest-timeout==2.1.0
1212
restructuredtext-lint==1.4.0
13-
pygments==2.14.0
13+
pygments==2.15.1
1414
testtools>=2.4.0
1515
sortedcontainers~=2.4.0

tests/test_blink_functions.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from blinkpy.camera import BlinkCamera
1010
from blinkpy.helpers.util import get_time, BlinkURLHandler
1111

12+
from requests import Response
13+
1214

1315
class MockSyncModule(BlinkSyncModule):
1416
"""Mock blink sync module object."""
@@ -117,6 +119,41 @@ def test_parse_downloaded_throttle(self, mock_req):
117119
delta = now - start
118120
self.assertTrue(delta >= 0.1)
119121

122+
@mock.patch("blinkpy.blinkpy.api.request_videos")
123+
def test_get_videos_metadata(self, mock_req):
124+
"""Test ability to fetch videos metadata."""
125+
blink = blinkpy.Blink()
126+
generic_entry = {
127+
"created_at": "1970",
128+
"device_name": "foo",
129+
"deleted": True,
130+
"media": "/bar.mp4",
131+
}
132+
result = [generic_entry]
133+
mock_req.return_value = {"media": result}
134+
blink.last_refresh = 0
135+
136+
results = blink.get_videos_metadata(stop=2)
137+
expected_results = [
138+
{
139+
"created_at": "1970",
140+
"device_name": "foo",
141+
"deleted": True,
142+
"media": "/bar.mp4",
143+
}
144+
]
145+
self.assertListEqual(results, expected_results)
146+
147+
@mock.patch("blinkpy.blinkpy.api.http_get")
148+
def test_do_http_get(self, mock_req):
149+
"""Test ability to do_http_get."""
150+
blink = blinkpy.Blink()
151+
blink.urls = BlinkURLHandler("test")
152+
153+
mock_req.return_value = Response()
154+
response = blink.do_http_get("/path/to/request")
155+
self.assertTrue(response is not None)
156+
120157
@mock.patch("blinkpy.blinkpy.api.request_videos")
121158
def test_parse_camera_not_in_list(self, mock_req):
122159
"""Test ability to parse downloaded items list."""

tests/test_cameras.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,29 @@ def tearDown(self):
4646
def test_camera_arm_status(self, mock_resp):
4747
"""Test arming and disarming camera."""
4848
self.camera.motion_enabled = None
49+
self.camera.arm = None
4950
self.assertFalse(self.camera.arm)
51+
self.camera.arm = False
5052
self.camera.motion_enabled = False
5153
self.assertFalse(self.camera.arm)
54+
self.camera.arm = True
5255
self.camera.motion_enabled = True
5356
self.assertTrue(self.camera.arm)
5457

58+
def test_doorbell_camera_arm(self, mock_resp):
59+
"""Test arming and disarming camera."""
60+
self.blink.sync.arm = False
61+
doorbell_camera = BlinkDoorbell(self.blink.sync["test"])
62+
doorbell_camera.motion_enabled = None
63+
doorbell_camera.arm = None
64+
self.assertFalse(doorbell_camera.arm)
65+
doorbell_camera.arm = False
66+
doorbell_camera.motion_enabled = False
67+
self.assertFalse(doorbell_camera.arm)
68+
doorbell_camera.arm = True
69+
doorbell_camera.motion_enabled = True
70+
self.assertTrue(doorbell_camera.arm)
71+
5572
def test_missing_attributes(self, mock_resp):
5673
"""Test that attributes return None if missing."""
5774
self.camera.temperature = None

0 commit comments

Comments
 (0)