diff --git a/.travis.yml b/.travis.yml index e21c6b25..fc521311 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,10 @@ matrix: env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 + - python: "3.7" + env: TOXENV=py37 + dist: xenial + sudo: true - python: "3.4.2" env: TOXENV=lint install: pip install -U tox coveralls diff --git a/ring_doorbell/__init__.py b/ring_doorbell/__init__.py index a05d3d58..7f3ca998 100644 --- a/ring_doorbell/__init__.py +++ b/ring_doorbell/__init__.py @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) +# pylint: disable=useless-object-inheritance class Ring(object): """A Python Abstraction object to Ring Door Bell.""" @@ -159,7 +160,8 @@ def query(self, attempts=RETRY_TOKEN, method='GET', raw=False, - extra_params=None): + extra_params=None, + json=None): """Query data from Ring API.""" if self.debug: _LOGGER.debug("Querying %s", url) @@ -189,7 +191,8 @@ def query(self, elif method == 'PUT': req = self.session.put((url), params=urlencode(params)) elif method == 'POST': - req = self.session.post((url), params=urlencode(params)) + req = self.session.post( + (url), params=urlencode(params), json=json) if self.debug: _LOGGER.debug("_query %s ret %s", loop, req.status_code) @@ -213,7 +216,7 @@ def query(self, response = req.json() break - if self.debug: + if self.debug and response is None: _LOGGER.debug("%s", MSG_GENERIC_FAIL) return response diff --git a/ring_doorbell/chime.py b/ring_doorbell/chime.py index 9f9035e3..c2794e6d 100644 --- a/ring_doorbell/chime.py +++ b/ring_doorbell/chime.py @@ -7,7 +7,7 @@ from ring_doorbell.const import ( API_URI, CHIMES_ENDPOINT, CHIME_VOL_MIN, CHIME_VOL_MAX, LINKED_CHIMES_ENDPOINT, MSG_VOL_OUTBOUND, TESTSOUND_CHIME_ENDPOINT, - CHIME_TEST_SOUND_KINDS, KIND_DING) + CHIME_TEST_SOUND_KINDS, KIND_DING, CHIME_KINDS, CHIME_PRO_KINDS) _LOGGER = logging.getLogger(__name__) @@ -21,9 +21,19 @@ def family(self): return 'chimes' @property - def battery_life(self): - """Return battery life.""" - return int(self._health_attrs.get('battery_percentage')) + def model(self): + """Return Ring device model name.""" + if self.kind in CHIME_KINDS: + return 'Chime' + elif self.kind in CHIME_PRO_KINDS: + return 'Chime Pro' + return None + + def has_capability(self, capability): + """Return if device has specific capability.""" + if capability == 'volume': + return True + return False @property def volume(self): @@ -33,7 +43,7 @@ def volume(self): @volume.setter def volume(self, value): if not ((isinstance(value, int)) and - (value >= CHIME_VOL_MIN and value <= CHIME_VOL_MAX)): + (CHIME_VOL_MIN <= value <= CHIME_VOL_MAX)): _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(CHIME_VOL_MIN, CHIME_VOL_MAX)) return False diff --git a/ring_doorbell/const.py b/ring_doorbell/const.py index e3e1d2da..dc613a01 100644 --- a/ring_doorbell/const.py +++ b/ring_doorbell/const.py @@ -43,6 +43,8 @@ NEW_SESSION_ENDPOINT = '/clients_api/session' RINGTONES_ENDPOINT = '/ringtones' SIREN_ENDPOINT = DOORBELLS_ENDPOINT + '/siren_{1}' +SNAPSHOT_ENDPOINT = "/clients_api/snapshots/image/{0}" +SNAPSHOT_TIMESTAMP_ENDPOINT = "/clients_api/snapshots/timestamps" TESTSOUND_CHIME_ENDPOINT = CHIMES_ENDPOINT + '/play_sound' URL_DOORBELL_HISTORY = DOORBELLS_ENDPOINT + '/history' URL_RECORDING = '/clients_api/dings/{0}/recording' @@ -67,6 +69,22 @@ SIREN_DURATION_MIN = 0 SIREN_DURATION_MAX = 120 +# device model kinds +CHIME_KINDS = ['chime'] +CHIME_PRO_KINDS = ['chime_pro'] + +DOORBELL_KINDS = ['doorbot', 'doorbell', 'doorbell_v3'] +DOORBELL_2_KINDS = ['doorbell_v4', 'doorbell_v5'] +DOORBELL_PRO_KINDS = ['lpd_v1', 'lpd_v2'] +DOORBELL_ELITE_KINDS = ['jbox_v1'] + +FLOODLIGHT_CAM_KINDS = ['hp_cam_v1'] +SPOTLIGHT_CAM_BATTERY_KINDS = ['stickup_cam_v4'] +SPOTLIGHT_CAM_WIRED_KINDS = ['hp_cam_v2'] +STICKUP_CAM_KINDS = ['stickup_cam', 'stickup_cam_v3'] +STICKUP_CAM_BATTERY_KINDS = ['stickup_cam_lunar'] +STICKUP_CAM_WIRED_KINDS = ['stickup_cam_elite'] + # error strings MSG_BOOLEAN_REQUIRED = "Boolean value is required." MSG_EXISTING_TYPE = "Integer value where {0}.".format(DOORBELL_EXISTING_TYPE) diff --git a/ring_doorbell/doorbot.py b/ring_doorbell/doorbot.py index 699f8007..129a90ac 100644 --- a/ring_doorbell/doorbot.py +++ b/ring_doorbell/doorbot.py @@ -4,6 +4,7 @@ import logging from datetime import datetime import os +import time import pytz @@ -12,9 +13,12 @@ from ring_doorbell.utils import _save_cache from ring_doorbell.const import ( API_URI, DOORBELLS_ENDPOINT, DOORBELL_VOL_MIN, DOORBELL_VOL_MAX, - DOORBELL_EXISTING_TYPE, DINGS_ENDPOINT, FILE_EXISTS, - LIVE_STREAMING_ENDPOINT, MSG_BOOLEAN_REQUIRED, MSG_EXISTING_TYPE, - MSG_VOL_OUTBOUND, URL_DOORBELL_HISTORY, URL_RECORDING) + DOORBELL_EXISTING_TYPE, DINGS_ENDPOINT, DOORBELL_KINDS, + DOORBELL_2_KINDS, DOORBELL_PRO_KINDS, DOORBELL_ELITE_KINDS, + FILE_EXISTS, LIVE_STREAMING_ENDPOINT, MSG_BOOLEAN_REQUIRED, + MSG_EXISTING_TYPE, MSG_VOL_OUTBOUND, SNAPSHOT_ENDPOINT, + SNAPSHOT_TIMESTAMP_ENDPOINT, URL_DOORBELL_HISTORY, + URL_RECORDING) _LOGGER = logging.getLogger(__name__) @@ -27,12 +31,34 @@ def family(self): """Return Ring device family type.""" return 'doorbots' + @property + def model(self): + """Return Ring device model name.""" + if self.kind in DOORBELL_KINDS: + return 'Doorbell' + elif self.kind in DOORBELL_2_KINDS: + return 'Doorbell 2' + elif self.kind in DOORBELL_PRO_KINDS: + return 'Doorbell Pro' + elif self.kind in DOORBELL_ELITE_KINDS: + return 'Doorbell Elite' + return None + + def has_capability(self, capability): + """Return if device has specific capability.""" + if capability == 'battery': + return self.kind in (DOORBELL_KINDS + + DOORBELL_2_KINDS) + elif capability == 'volume': + return True + return False + @property def battery_life(self): """Return battery life.""" + value = 0 if 'battery_life_2' in self._attrs: # Camera has two battery bays - value = 0 if self._attrs.get('battery_life') is not None: # Bay 1 value += int(self._attrs.get('battery_life')) @@ -41,9 +67,11 @@ def battery_life(self): value += int(self._attrs.get('battery_life_2')) return value # Camera has a single battery bay - value = int(self._attrs.get('battery_life')) - if value and value > 100: - value = 100 + # Latest stickup cam can be externally powered + if self._attrs.get('battery_life') is not None: + value = int(self._attrs.get('battery_life')) + if value and value > 100: + value = 100 return value def check_alerts(self): @@ -150,7 +178,7 @@ def existing_doorbell_type_duration(self, value): if self.existing_doorbell_type: if not ((isinstance(value, int)) and - (value >= DOORBELL_VOL_MIN and value <= DOORBELL_VOL_MAX)): + (DOORBELL_VOL_MIN <= value <= DOORBELL_VOL_MAX)): _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(DOORBELL_VOL_MIN, DOORBELL_VOL_MAX)) return False @@ -259,8 +287,8 @@ def live_streaming_json(self): def recording_download(self, recording_id, filename=None, override=False): """Save a recording in MP4 format to a file or return raw.""" if not self.has_subscription: - _LOGGER.warning("Your Ring account does not have" + - " an active subscription.") + msg = "Your Ring account does not have an active subscription." + _LOGGER.warning(msg) return False url = API_URI + URL_RECORDING.format(recording_id) @@ -286,8 +314,8 @@ def recording_download(self, recording_id, filename=None, override=False): def recording_url(self, recording_id): """Return HTTPS recording URL.""" if not self.has_subscription: - _LOGGER.warning("Your Ring account does not have" + - " an active subscription.") + msg = "Your Ring account does not have an active subscription." + _LOGGER.warning(msg) return False url = API_URI + URL_RECORDING.format(recording_id) @@ -325,7 +353,7 @@ def volume(self): @volume.setter def volume(self, value): if not ((isinstance(value, int)) and - (value >= DOORBELL_VOL_MIN and value <= DOORBELL_VOL_MAX)): + (DOORBELL_VOL_MIN <= value <= DOORBELL_VOL_MAX)): _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(DOORBELL_VOL_MIN, DOORBELL_VOL_MAX)) return False @@ -342,3 +370,18 @@ def volume(self, value): def connection_status(self): """Return connection status.""" return self._attrs.get('alerts').get('connection') + + def get_snapshot(self, retries=3, delay=1): + """Take a snapshot and download it""" + url = API_URI + SNAPSHOT_TIMESTAMP_ENDPOINT + payload = {"doorbot_ids": [self._attrs.get('id')]} + self._ring.query(url, json=payload) + request_time = time.time() + for _ in range(retries): + time.sleep(delay) + response = self._ring.query( + url, method="POST", json=payload, raw=1).json() + if response["timestamps"][0]["timestamp"] / 1000 > request_time: + return self._ring.query(API_URI + SNAPSHOT_ENDPOINT.format( + self._attrs.get('id')), raw=True).content + return False diff --git a/ring_doorbell/generic.py b/ring_doorbell/generic.py index edcfb5c6..71771504 100644 --- a/ring_doorbell/generic.py +++ b/ring_doorbell/generic.py @@ -12,6 +12,7 @@ _LOGGER = logging.getLogger(__name__) +# pylint: disable=useless-object-inheritance class RingGeneric(object): """Generic Implementation for Ring Chime/Doorbell.""" @@ -39,6 +40,15 @@ def family(self): """Return Ring device family type.""" return None + @property + def model(self): + """Return Ring device model name.""" + return None + + def has_capability(self, capability): + """Return if device has specific capability.""" + return False + def update(self): """Refresh attributes.""" self._get_attrs() diff --git a/ring_doorbell/stickup_cam.py b/ring_doorbell/stickup_cam.py index 0f86bb54..d9bf34db 100644 --- a/ring_doorbell/stickup_cam.py +++ b/ring_doorbell/stickup_cam.py @@ -6,6 +6,9 @@ from ring_doorbell import RingDoorBell from ring_doorbell.const import ( API_URI, LIGHTS_ENDPOINT, MSG_ALLOWED_VALUES, MSG_VOL_OUTBOUND, + FLOODLIGHT_CAM_KINDS, SPOTLIGHT_CAM_BATTERY_KINDS, + SPOTLIGHT_CAM_WIRED_KINDS, STICKUP_CAM_KINDS, + STICKUP_CAM_BATTERY_KINDS, STICKUP_CAM_WIRED_KINDS, SIREN_DURATION_MIN, SIREN_DURATION_MAX, SIREN_ENDPOINT) _LOGGER = logging.getLogger(__name__) @@ -19,6 +22,43 @@ def family(self): """Return Ring device family type.""" return 'stickup_cams' + @property + def model(self): + """Return Ring device model name.""" + if self.kind in FLOODLIGHT_CAM_KINDS: + return 'Floodlight Cam' + elif self.kind in SPOTLIGHT_CAM_BATTERY_KINDS: + return 'Spotlight Cam {}'.format( + self._attrs.get('ring_cam_setup_flow', 'battery').title()) + elif self.kind in SPOTLIGHT_CAM_WIRED_KINDS: + return 'Spotlight Cam {}'.format( + self._attrs.get('ring_cam_setup_flow', 'wired').title()) + elif self.kind in STICKUP_CAM_KINDS: + return 'Stick Up Cam' + elif self.kind in STICKUP_CAM_BATTERY_KINDS: + return 'Stick Up Cam Battery' + elif self.kind in STICKUP_CAM_WIRED_KINDS: + return 'Stick Up Cam Wired' + return None + + def has_capability(self, capability): + """Return if device has specific capability.""" + if capability == 'battery': + return self.kind in (SPOTLIGHT_CAM_BATTERY_KINDS + + STICKUP_CAM_KINDS + + STICKUP_CAM_BATTERY_KINDS) + elif capability == 'light': + return self.kind in (FLOODLIGHT_CAM_KINDS + + SPOTLIGHT_CAM_BATTERY_KINDS + + SPOTLIGHT_CAM_WIRED_KINDS) + elif capability == 'siren': + return self.kind in (FLOODLIGHT_CAM_KINDS + + SPOTLIGHT_CAM_BATTERY_KINDS + + SPOTLIGHT_CAM_WIRED_KINDS + + STICKUP_CAM_BATTERY_KINDS + + STICKUP_CAM_WIRED_KINDS) + return False + @property def lights(self): """Return lights status.""" @@ -48,8 +88,7 @@ def siren(self): def siren(self, duration): """Control the siren.""" if not ((isinstance(duration, int)) and - (duration >= SIREN_DURATION_MIN and - duration <= SIREN_DURATION_MAX)): + (SIREN_DURATION_MIN <= duration <= SIREN_DURATION_MAX)): _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(SIREN_DURATION_MIN, SIREN_DURATION_MAX)) return False diff --git a/setup.py b/setup.py index 14642345..3a834bd9 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,8 @@ """Python Ring Door Bell setup script.""" from setuptools import setup +_VERSION = '0.2.3' + def readme(): with open('README.rst') as desc: @@ -11,7 +13,7 @@ def readme(): setup( name='ring_doorbell', packages=['ring_doorbell'], - version='0.2.2', + version=_VERSION, description='A Python library to communicate with Ring' + ' Door Bell (https://ring.com/)', long_description=readme(), @@ -37,6 +39,7 @@ def readme(): 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Home Automation', 'Topic :: Software Development :: Libraries :: Python Modules' ], diff --git a/tests/test_ring.py b/tests/test_ring.py index cae84803..f8912e11 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -29,7 +29,7 @@ def test_basic_attributes(self, mock): self.assertEqual(2, len(data.doorbells)) self.assertEqual(1, len(data.stickup_cams)) self.assertFalse(data._persist_token) - self.assertEquals('http://localhost/', data._push_token_notify_url) + self.assertEqual('http://localhost/', data._push_token_notify_url) @requests_mock.Mocker() def test_chime_attributes(self, mock): @@ -46,6 +46,9 @@ def test_chime_attributes(self, mock): self.assertNotEqual(99999, dev.account_id) self.assertEqual('abcdef123', dev.id) self.assertEqual('chime', dev.kind) + self.assertEqual('Chime', dev.model) + self.assertEqual(False, dev.has_capability('battery')) + self.assertEqual(True, dev.has_capability('volume')) self.assertIsNotNone(dev.latitude) self.assertEqual('America/New_York', dev.timezone) self.assertEqual(2, dev.volume) @@ -71,6 +74,9 @@ def test_doorbell_attributes(self, mock): self.assertEqual(987652, dev.account_id) self.assertEqual('123 Main St', dev.address) self.assertEqual('lpd_v1', dev.kind) + self.assertEqual('Doorbell Pro', dev.model) + self.assertEqual(False, dev.has_capability('battery')) + self.assertEqual(True, dev.has_capability('volume')) self.assertEqual(-70.12345, dev.longitude) self.assertEqual('America/New_York', dev.timezone) self.assertEqual(1, dev.volume) @@ -109,6 +115,9 @@ def test_shared_doorbell_attributes(self, mock): self.assertEqual(51, dev.battery_life) self.assertEqual('123 Second St', dev.address) self.assertEqual('lpd_v1', dev.kind) + self.assertEqual('Doorbell Pro', dev.model) + self.assertEqual(False, dev.has_capability('battery')) + self.assertEqual(True, dev.has_capability('volume')) self.assertEqual(-70.12345, dev.longitude) self.assertEqual('America/New_York', dev.timezone) self.assertEqual(5, dev.volume) @@ -146,6 +155,10 @@ def test_stickup_cam_attributes(self, mock): data = self.ring_persistent for dev in data.stickup_cams: + self.assertEqual('hp_cam_v1', dev.kind) + self.assertEqual('Floodlight Cam', dev.model) + self.assertEqual(False, dev.has_capability('battery')) + self.assertEqual(True, dev.has_capability('light')) self.assertEqual('off', dev.lights) self.assertEqual(0, dev.siren) diff --git a/tests/test_ring_utils.py b/tests/test_ring_utils.py index 10e056ba..1d757ba9 100644 --- a/tests/test_ring_utils.py +++ b/tests/test_ring_utils.py @@ -25,8 +25,8 @@ def tearDown(self): def test_locator(self): """Test _locator method.""" - self.assertEquals(-1, _locator([DATA], 'key', 'bar')) - self.assertEquals(0, _locator([DATA], 'key', 'value')) + self.assertEqual(-1, _locator([DATA], 'key', 'bar')) + self.assertEqual(0, _locator([DATA], 'key', 'value')) def test_initiliaze_clean_cache(self): """Test _clean_cache method.""" diff --git a/tox.ini b/tox.ini index 3cfc590b..1f434e66 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py35, py36, lint +envlist = py27, py35, py36, py37, lint skip_missing_interpreters = True [testenv]