Merge pull request #225 from fronzbot/dev

0.14.3
This commit is contained in:
Kevin Fronczak
2020-04-22 10:22:48 -04:00
committed by GitHub
23 changed files with 398 additions and 59 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
[run]
omit =
helpers/*
parallel = true
omit =
tests/*
setup.py
setup.py
+2
View File
@@ -0,0 +1,2 @@
[flake8]
ignore = BLK100
+41
View File
@@ -0,0 +1,41 @@
name: coverage
on:
push:
branches: [ master, dev ]
pull_request:
branches: [ master, dev ]
jobs:
build:
runs-on: ${{ matrix.platform }}
strategy:
max-parallel: 4
matrix:
platform:
- ubuntu-latest
python-version: [3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements_test.txt
pip install tox
pip install codecov
- name: Test
run: |
tox -r -e cov
- name: Codecov
uses: codecov/codecov-action@v1.0.6
with:
flags: unittests
file: ./coverage.xml
name: blinkpy
fail_ci_if_error: true
+34
View File
@@ -0,0 +1,34 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Lint
on:
push:
branches: [ master, dev ]
pull_request:
branches: [ master, dev ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements_test.txt
pip install tox
- name: Lint
run: |
tox -r -e lint
+31
View File
@@ -0,0 +1,31 @@
# This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: Upload Python Package
on:
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: '3.7'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py bdist_wheel
twine upload dist/*
+33
View File
@@ -0,0 +1,33 @@
name: build
on:
push:
branches: [ master, dev ]
pull_request:
branches: [ master, dev ]
jobs:
build:
runs-on: ${{ matrix.platform }}
strategy:
max-parallel: 4
matrix:
platform:
- ubuntu-latest
python-version: [3.5, 3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements_test.txt
pip install tox
- name: Test
run: |
tox -r
+1
View File
@@ -4,6 +4,7 @@
__pycache__/*
htmlcov/*
.coverage
coverage.xml
*.pyc
*.egg*/*
dist/*
+4
View File
@@ -4,4 +4,8 @@ python:
ignored_directories:
- docs
flake8:
enabled: true
config_file: .flake8
fail_on_violations: true
-23
View File
@@ -1,23 +0,0 @@
sudo: required
matrix:
fast_finish: true
include:
- python: "3.5.3"
env: TOXENV=lint
- python: "3.5.3"
env: TOXENV=py35
- python: "3.6"
env: TOXENV=py36
- python: "3.6"
env: TOXENV=build
- python: "3.7"
env: TOXENV=py37
dist: xenial
- python: "3.8-dev"
env: TOXENV=py38
dist: xenial
install: pip install -U tox coveralls
language: python
script: tox
after_success: coveralls
+8
View File
@@ -3,6 +3,14 @@ Changelog
A list of changes between each release
0.14.3 (2020-04-22)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- Add time check on recorded videos before determining motion
- Fix motion detection variable suck to ``True``
- Add ability to load credentials from a json file
- Only allow ``motion_detected`` variable to trigger if system was armed
- Log response message from server if not attempting a re-authorization
0.14.2 (2019-10-12)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- Update dependencies
+21 -4
View File
@@ -55,6 +55,23 @@ The simplest way to use this package from a terminal is to call ``Blink.start()`
If you would like to log in without setting up the cameras or system, you can simply call the ``Blink.login()`` function which will prompt for a username and password and then authenticate with the server. This is useful if you want to avoid use of the ``start()`` function which simply acts as a wrapper for more targeted API methods.
In addition, you can also save your credentials in a json file and initialize Blink with the credential file as follows:
.. code:: python
from blinkpy import blinkpy
blink = blinkpy.Blink(cred_file="path/to/credentials.json")
blink.start()
The credential file must be json formatted with a ``username`` and ``password`` key like follows:
.. code:: json
{
"username": "YOUR USER NAME",
"password": "YOUR PASSWORD"
}
Cameras are instantiated as individual ``BlinkCamera`` classes within a ``BlinkSyncModule`` instance. All of your sync modules are stored within the ``Blink.sync`` dictionary and can be accessed using the name of the sync module as the key (this is the name of your sync module in the Blink App).
The below code will display cameras and their available attributes:
@@ -100,10 +117,10 @@ Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34a
blink.download_videos('/home/blink', since='2018/07/04 09:34')
.. |Build Status| image:: https://travis-ci.org/fronzbot/blinkpy.svg?branch=dev
:target: https://travis-ci.org/fronzbot/blinkpy
.. |Coverage Status| image:: https://coveralls.io/repos/github/fronzbot/blinkpy/badge.svg?branch=dev
:target: https://coveralls.io/github/fronzbot/blinkpy?branch=dev
.. |Build Status| image:: https://github.com/fronzbot/blinkpy/workflows/build/badge.svg
:target: https://github.com/fronzbot/blinkpy/actions?query=workflow%3Abuild
.. |Coverage Status| image:: https://codecov.io/gh/fronzbot/blinkpy/branch/dev/graph/badge.svg
:target: https://codecov.io/gh/fronzbot/blinkpy
.. |PyPi Version| image:: https://img.shields.io/pypi/v/blinkpy.svg
:target: https://pypi.python.org/pypi/blinkpy
.. |Docs| image:: https://readthedocs.org/projects/blinkpy/badge/?version=latest
+26 -2
View File
@@ -17,6 +17,7 @@ import os.path
import time
import getpass
import logging
import json
from shutil import copyfileobj
from requests.structures import CaseInsensitiveDict
@@ -42,6 +43,7 @@ class Blink():
"""Class to initialize communication."""
def __init__(self, username=None, password=None,
cred_file=None,
refresh_rate=DEFAULT_REFRESH,
motion_interval=DEFAULT_MOTION_INTERVAL,
legacy_subdomain=False):
@@ -50,6 +52,10 @@ class Blink():
:param username: Blink username (usually email address)
:param password: Blink password
:param cred_file: JSON formatted file to store credentials.
If username and password are given, file
is ignored. Otherwise, username and password
are loaded from file.
:param refresh_rate: Refresh rate of blink information.
Defaults to 15 (seconds)
:param motion_interval: How far back to register motion in minutes.
@@ -62,6 +68,7 @@ class Blink():
"""
self._username = username
self._password = password
self._cred_file = cred_file
self._token = None
self._auth_header = None
self._host = None
@@ -117,8 +124,25 @@ class Blink():
def login(self):
"""Prompt user for username and password."""
self._username = input("Username:")
self._password = getpass.getpass("Password:")
if self._cred_file is not None and os.path.isfile(self._cred_file):
try:
with open(self._cred_file, 'r') as json_file:
creds = json.load(json_file)
self._username = creds['username']
self._password = creds['password']
except ValueError:
_LOGGER.error("Improperly formated json file %s.",
self._cred_file,
exc_info=True)
return False
except KeyError:
_LOGGER.error("JSON file information incomplete %s.",
exc_info=True)
return False
else:
self._username = input("Username:")
self._password = getpass.getpass("Password:")
if self.get_auth_token():
_LOGGER.debug("Login successful!")
return True
+2 -4
View File
@@ -178,8 +178,7 @@ class BlinkCamera():
copyfileobj(response.raw, imgfile)
else:
_LOGGER.error("Cannot write image to file, response %s",
response.status_code,
exc_info=True)
response.status_code)
def video_to_file(self, path):
"""Write video to file.
@@ -190,8 +189,7 @@ class BlinkCamera():
response = self._cached_video
if response is None:
_LOGGER.error("No saved video exist for %s.",
self.name,
exc_info=True)
self.name)
return
with open(path, 'wb') as vidfile:
copyfileobj(response.raw, vidfile)
+2 -2
View File
@@ -4,7 +4,7 @@ import os
MAJOR_VERSION = 0
MINOR_VERSION = 14
PATCH_VERSION = 2
PATCH_VERSION = 3
__version__ = '{}.{}.{}'.format(MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION)
@@ -60,7 +60,7 @@ ONLINE = {'online': True, 'offline': False}
'''
OTHER
'''
TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S%Z'
TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S%z'
DEFAULT_MOTION_INTERVAL = 1
DEFAULT_REFRESH = 30
+2
View File
@@ -11,3 +11,5 @@ AUTH_TOKEN = (
"Authentication header incorrect. Are you sure you received your token?"
)
REQUEST = (4, "Cannot perform request (get/post type incorrect)")
BLINK_ERRORS = [101, 400, 404]
+20 -2
View File
@@ -2,8 +2,10 @@
import logging
import time
from calendar import timegm
from functools import partial, wraps
from requests import Request, Session, exceptions
import dateutil.parser
from blinkpy.helpers.constants import BLINK_URL, TIMESTAMP_FORMAT
import blinkpy.helpers.errors as ERROR
@@ -11,6 +13,17 @@ import blinkpy.helpers.errors as ERROR
_LOGGER = logging.getLogger(__name__)
def time_to_seconds(timestamp):
"""Convert TIMESTAMP_FORMAT time to seconds."""
try:
dtime = dateutil.parser.isoparse(timestamp)
except ValueError:
_LOGGER.error("Incorrect timestamp format for conversion: %s.",
timestamp)
return False
return timegm(dtime.timetuple())
def get_time(time_to_convert=None):
"""Create blink-compatible timestamp."""
if time_to_convert is None:
@@ -73,16 +86,21 @@ def http_req(blink, url='http://example.com', data=None, headers=None,
try:
response = blink.session.send(prepped, stream=stream)
if json_resp and 'code' in response.json():
if is_retry:
resp_dict = response.json()
code = resp_dict['code']
message = resp_dict['message']
if is_retry and code in ERROR.BLINK_ERRORS:
_LOGGER.error("Cannot obtain new token for server auth.")
return None
else:
elif code in ERROR.BLINK_ERRORS:
headers = attempt_reauthorization(blink)
if not headers:
raise exceptions.ConnectionError
return http_req(blink, url=url, data=data, headers=headers,
reqtype=reqtype, stream=stream,
json_resp=json_resp, is_retry=True)
_LOGGER.warning("Response from server: %s - %s", code, message)
except (exceptions.ConnectionError, exceptions.Timeout):
_LOGGER.info("Cannot connect to server with url %s.", url)
if not is_retry:
+9 -3
View File
@@ -5,6 +5,7 @@ import logging
from requests.structures import CaseInsensitiveDict
from blinkpy import api
from blinkpy.camera import BlinkCamera
from blinkpy.helpers.util import time_to_seconds
from blinkpy.helpers.constants import ONLINE
_LOGGER = logging.getLogger(__name__)
@@ -163,7 +164,7 @@ class BlinkSyncModule():
def check_new_videos(self):
"""Check if new videos since last refresh."""
try:
interval = self.blink.last_refresh - self.motion_interval*60
interval = self.blink.last_refresh - self.motion_interval * 60
except TypeError:
# This is the first start, so refresh hasn't happened yet.
# No need to check for motion.
@@ -187,9 +188,14 @@ class BlinkSyncModule():
name = entry['device_name']
clip = entry['media']
timestamp = entry['created_at']
self.motion[name] = True
self.last_record[name] = {'clip': clip, 'time': timestamp}
if self.check_new_video_time(timestamp):
self.motion[name] = True and self.arm
self.last_record[name] = {'clip': clip, 'time': timestamp}
except KeyError:
_LOGGER.debug("No new videos since last refresh.")
return True
def check_new_video_time(self, timestamp):
"""Check if video has timestamp since last refresh."""
return time_to_seconds(timestamp) > self.blink.last_refresh
+13
View File
@@ -0,0 +1,13 @@
codecov:
branch: dev
coverage:
status:
project:
default:
threshold: 0.02
comment: true
require_ci_to_pass: yes
range: 65..90
round: down
precision: 1
+9 -8
View File
@@ -1,10 +1,11 @@
flake8==3.5.0
flake8-docstrings==1.3.0
pylint==2.3.0
pydocstyle==2.1.1
pytest==3.7.1
pytest-cov>=2.3.1
pytest-sugar>=0.9.0
pytest-timeout>=1.0.0
coverage==4.5.4
flake8==3.7.9
flake8-docstrings==1.5.0
pylint==2.4.4
pydocstyle==5.0.2
pytest==5.3.5
pytest-cov==2.8.1
pytest-sugar>=0.9.2
pytest-timeout>=1.3.3
restructuredtext-lint>=1.0.1
pygments>=2.2.0
+43 -2
View File
@@ -79,11 +79,52 @@ class TestBlinkSetup(unittest.TestCase):
# pylint: disable=protected-access
self.assertEqual(self.blink_no_cred._password, PASSWORD)
@mock.patch('blinkpy.blinkpy.getpass.getpass')
@mock.patch('blinkpy.blinkpy.Blink.get_auth_token')
def test_no_cred_file(self, getpwd, getauth, mock_sess):
"""Check that normal login occurs when cred file doesn't exist."""
# pylint: disable=protected-access
self.blink._cred_file = '/tmp/fake.file'
getpwd.return_value = PASSWORD
getauth.return_value = True
with mock.patch('builtins.input', return_value=USERNAME):
self.assertTrue(self.blink.login())
def test_exit_on_missing_json(self, mock_sess):
"""Test that we fail on missing json data."""
# pylint: disable=protected-access
self.blink._cred_file = '/tmp/fake.file'
with mock.patch('os.path.isfile', return_value=True):
with mock.patch('builtins.open', mock.mock_open(read_data="{}")):
self.assertFalse(self.blink.login())
def test_exit_on_bad_json(self, mock_sess):
"""Test that we fail on bad json format."""
# pylint: disable=protected-access
self.blink._cred_file = '/tmp/fake.file'
with mock.patch('os.path.isfile', return_value=True):
with mock.patch('builtins.open', mock.mock_open(read_data='{]')):
self.assertFalse(self.blink.login())
@mock.patch('blinkpy.blinkpy.json.load')
def test_cred_file(self, mockjson, mock_sess):
"""Test that loading credential file works."""
# pylint: disable=protected-access
self.blink_no_cred._cred_file = '/tmp/fake.file'
mockjson.return_value = {'username': 'foo', 'password': 'bar'}
with mock.patch('os.path.isfile', return_value=True):
with mock.patch('builtins.open', mock.mock_open(read_data='')):
self.assertTrue(self.blink_no_cred.login())
# pylint: disable=protected-access
self.assertEqual(self.blink_no_cred._username, 'foo')
# pylint: disable=protected-access
self.assertEqual(self.blink_no_cred._password, 'bar')
def test_bad_request(self, mock_sess):
"""Check that we raise an Exception with a bad request."""
self.blink.session = create_session()
explog = ("ERROR:blinkpy.helpers.util:"
"Cannot obtain new token for server auth.")
explog = ("WARNING:blinkpy.helpers.util:"
"Response from server: 200 - foo")
with self.assertRaises(BlinkException):
http_req(self.blink, reqtype='bad')
+74 -3
View File
@@ -43,6 +43,7 @@ class TestBlinkSyncModule(unittest.TestCase):
None,
{'devicestatus': {}},
]
self.blink.sync['test'].network_info = {'network': {'armed': True}}
def tearDown(self):
"""Clean up after test."""
@@ -73,23 +74,93 @@ class TestBlinkSyncModule(unittest.TestCase):
'media': [{
'device_name': 'foo',
'media': '/foo/bar.mp4',
'created_at': '1970-01-01T00:00:00+0:00'
'created_at': '1990-01-01T00:00:00+00:00'
}]
}
sync_module = self.blink.sync['test']
sync_module.cameras = {'foo': None}
sync_module.blink.last_refresh = 0
self.assertEqual(sync_module.motion, {})
self.assertTrue(sync_module.check_new_videos())
self.assertEqual(sync_module.last_record['foo'],
{'clip': '/foo/bar.mp4',
'time': '1970-01-01T00:00:00+0:00'})
'time': '1990-01-01T00:00:00+00:00'})
self.assertEqual(sync_module.motion, {'foo': True})
mock_resp.return_value = {'media': []}
self.assertTrue(sync_module.check_new_videos())
self.assertEqual(sync_module.motion, {'foo': False})
self.assertEqual(sync_module.last_record['foo'],
{'clip': '/foo/bar.mp4',
'time': '1970-01-01T00:00:00+0:00'})
'time': '1990-01-01T00:00:00+00:00'})
def test_check_new_videos_old_date(self, mock_resp):
"""Test videos return response with old date."""
mock_resp.return_value = {
'media': [{
'device_name': 'foo',
'media': '/foo/bar.mp4',
'created_at': '1970-01-01T00:00:00+00:00'
}]
}
sync_module = self.blink.sync['test']
sync_module.cameras = {'foo': None}
sync_module.blink.last_refresh = 1000
self.assertTrue(sync_module.check_new_videos())
self.assertEqual(sync_module.motion, {'foo': False})
def test_check_no_motion_if_not_armed(self, mock_resp):
"""Test that motion detection is not set if module unarmed."""
mock_resp.return_value = {
'media': [{
'device_name': 'foo',
'media': '/foo/bar.mp4',
'created_at': '1990-01-01T00:00:00+00:00'
}]
}
sync_module = self.blink.sync['test']
sync_module.cameras = {'foo': None}
sync_module.blink.last_refresh = 1000
self.assertTrue(sync_module.check_new_videos())
self.assertEqual(sync_module.motion, {'foo': True})
sync_module.network_info = {'network': {'armed': False}}
self.assertTrue(sync_module.check_new_videos())
self.assertEqual(sync_module.motion, {'foo': False})
def test_check_multiple_videos(self, mock_resp):
"""Test motion found even with multiple videos."""
mock_resp.return_value = {
'media': [
{
'device_name': 'foo',
'media': '/foo/bar.mp4',
'created_at': '1970-01-01T00:00:00+00:00'
},
{
'device_name': 'foo',
'media': '/bar/foo.mp4',
'created_at': '1990-01-01T00:00:00+00:00'
},
{
'device_name': 'foo',
'media': '/foobar.mp4',
'created_at': '1970-01-01T00:00:01+00:00'
}
]
}
sync_module = self.blink.sync['test']
sync_module.cameras = {'foo': None}
sync_module.blink.last_refresh = 1000
self.assertTrue(sync_module.check_new_videos())
self.assertEqual(sync_module.motion, {'foo': True})
expected_result = {
'foo': {
'clip': '/bar/foo.mp4',
'time': '1990-01-01T00:00:00+00:00'
}
}
self.assertEqual(sync_module.last_record, expected_result)
def test_check_new_videos_failed(self, mock_resp):
"""Test method when response is unexpected."""
+8 -1
View File
@@ -3,7 +3,7 @@
import unittest
from unittest import mock
import time
from blinkpy.helpers.util import Throttle, BlinkURLHandler
from blinkpy.helpers.util import Throttle, BlinkURLHandler, time_to_seconds
class TestUtil(unittest.TestCase):
@@ -105,3 +105,10 @@ class TestUtil(unittest.TestCase):
self.assertEqual(urls.subdomain, 'rest-test')
urls = BlinkURLHandler('test', legacy=True)
self.assertEqual(urls.subdomain, 'rest.test')
def test_time_to_seconds(self):
"""Test time to seconds conversion."""
correct_time = '1970-01-01T00:00:05+00:00'
wrong_time = '1/1/1970 00:00:03'
self.assertEqual(time_to_seconds(correct_time), 5)
self.assertFalse(time_to_seconds(wrong_time))
+12 -2
View File
@@ -8,11 +8,21 @@ setenv =
LANG=en_US.UTF-8
PYTHONPATH = {toxinidir}
commands =
py.test --timeout=30 --duration=10 --cov=blinkpy --cov-report term-missing {posargs}
pytest --timeout=9 --durations=10 --cov=blinkpy --cov-report term-missing {posargs}
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/requirements_test.txt
[testenv:cov]
setenv =
LANG=en_US.UTF-8
PYTHONPATH = {toxinidir}
commands =
pytest --timeout=9 --durations=10 --cov=blinkpy --cov-report=xml {posargs}
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/requirements_test.txt
[testenv:lint]
deps =
-r{toxinidir}/requirements.txt