diff --git a/README.rst b/README.rst index 68457496..3963a8bc 100644 --- a/README.rst +++ b/README.rst @@ -8,8 +8,8 @@ Python Ring Door Bell .. image:: https://travis-ci.org/tchellomello/python-ring-doorbell.svg?branch=master :target: https://travis-ci.org/tchellomello/python-ring-doorbell -.. image:: https://coveralls.io/repos/github/tchellomello/python-ring-doorbell/badge.svg - :target: https://coveralls.io/github/tchellomello/python-ring-doorbell +.. image:: https://coveralls.io/repos/github/tchellomello/python-ring-doorbell/badge.svg?branch=master + :target: https://coveralls.io/github/tchellomello/python-ring-doorbell?branch=master .. image:: https://img.shields.io/pypi/pyversions/ring-doorbell.svg :target: https://pypi.python.org/pypi/ring-doorbell @@ -20,6 +20,8 @@ that exposes the Ring.com devices as Python objects. *Currently Ring.com does not provide an official API. The results of this project are merely from reverse engineering.* +Documentation: `http://python-ring-doorbell.readthedocs.io/ `_ + Installation ------------ @@ -30,7 +32,7 @@ Installation $ pip install ring_doorbell # Installing latest development - $ pip3 install \ + $ pip install \ git+https://github.com/tchellomello/python-ring-doorbell@dev @@ -63,11 +65,15 @@ Listing devices linked to your account myring.doorbells [] -Playing with the attributes ---------------------------- + # All stickup cams + myring.stickup_cams + [] + +Playing with the attributes and functions +----------------------------------------- .. code-block:: python - for dev in list(myring.chimes + myring.doorbells): + for dev in list(myring.stickup_cams + myring.chimes + myring.doorbells): # refresh data dev.update() @@ -78,6 +84,8 @@ Playing with the attributes print('ID: %s' % dev.id) print('Name: %s' % dev.name) print('Timezone: %s' % dev.timezone) + print('Wifi Name: %s' % dev.wifi_name) + print('Wifi RSSI: %s' % dev.wifi_signal_strength) # setting dev volume print('Volume: %s' % dev.volume) @@ -86,7 +94,8 @@ Playing with the attributes # play dev test shound if dev.family == 'chimes' - dev.test_sound + dev.test_sound(kind = 'ding') + dev.test_sound(kind = 'motion') Showing door bell events diff --git a/docs/source/credits.rst b/docs/source/credits.rst deleted file mode 100644 index 1191aedc..00000000 --- a/docs/source/credits.rst +++ /dev/null @@ -1,9 +0,0 @@ -Credits && Thanks ------------------ - -* This project was inspired and based on https://github.com/jeroenmoors/php-ring-api. Many thanks @jeroenmoors. -* A guy named MadBagger at Prism19 for his initial research (http://www.prism19.com/doorbot/second-pass-and-comm-reversing/) -* The creators of mitmproxy (https://mitmproxy.org/) great http and https traffic inspector -* @mfussenegger for his post on mitmproxy and virtualbox https://zignar.net/2015/12/31/sniffing-vbox-traffic-mitmproxy/ -* To the project http://www.android-x86.org/ which allowed me to install Android on KVM. - diff --git a/docs/source/how_to.rst b/docs/source/how_to.rst deleted file mode 100644 index bf8fce7c..00000000 --- a/docs/source/how_to.rst +++ /dev/null @@ -1,91 +0,0 @@ -How To Use It -============= - -Initializing your Ring object ------------------------------ - -.. code-block:: python - - from ring_doorbell import Ring - myring = Ring('foo@bar', 'secret') - - myring.is_connected - True - - myring.has_subscription - True - - Chimes - ------ - -Listing devices linked to your account ------------------------------------------- - -.. code-block:: python - - # All devices - myring.devices - {'chimes': [], - 'doorbells': []} - - # All chimes - myring.chimes - [] - - # All door bells - myring.doorbells - [] - -Getting/setting attributes --------------------------------- -.. code-block:: python - - for dev in list(myring.chimes + myring.doorbells): - - # refresh data - dev.update() - - print('Account ID: %s' % dev.account_id) - print('Address: %s' % dev.address) - print('Family: %s' % dev.family) - print('ID: %s' % dev.id) - print('Name: %s' % dev.name) - print('Timezone: %s' % dev.timezone) - - # setting dev volume - print('Volume: %s' % dev.volume) - dev.volume = 5 - print('Volume: %s' % dev.volume) - - # play dev test shound - if dev.family == 'chimes' - dev.test_sound - - -Showing door bell events ------------------------- -.. code-block:: python - - for doorbell in myring.doorbells: - - # listing the last 15 events of any kind - for event in doorbell.history(limit=15): - print('ID: %s' % event['id']) - print('Kind: %s' % event['kind']) - print('Answered: %s' % event['answered']) - print('When: %s' % event['created_at']) - print('--' * 50) - - # get a event list only the triggered by motion - events = doorbell.history(kind='motion') - - -Download the last video triggerd by ding ----------------------------------------- -.. code-block:: python - - doorbell = myring.doorbells[0] - doorbell.recording_download( - doorbell.history(limit=100, kind='ding')[0]['id'], - filename='/home/user/last_ding.mp4', - override=True) diff --git a/docs/source/index.rst b/docs/source/index.rst index f160cfee..70c533f5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,15 +10,162 @@ that exposes the Ring.com devices as Python objects. Ring.com does not provide an official API. The results of this project are merely from reverse engineering. - .. toctree:: :maxdepth: 2 :caption: Contents: - Installation - How to Use it - Source Code - Credits & Thanks +Installation +------------ + +.. code-block:: bash + + # Installing from PyPi + $ pip install ring_doorbell + + # Installing latest development + $ pip install \ + git+https://github.com/tchellomello/python-ring-doorbell@dev + + +Initializing your Ring object +----------------------------- + +.. code-block:: python + + from ring_doorbell import Ring + myring = Ring('foo@bar', 'secret') + + myring.is_connected + True + +Listing devices linked to your account +-------------------------------------- + +.. code-block:: python + + # All devices + myring.devices + {'chimes': [], + 'doorbells': []} + + # All chimes + myring.chimes + [] + + # All door bells + myring.doorbells + [] + + # All stickup cams + myring.stickup_cams + [] + + +Playing with the attributes +--------------------------- +.. code-block:: python + + for dev in list(myring.stickup_cams + myring.chimes + myring.doorbells): + + # refresh data + dev.update() + + print('Account ID: %s' % dev.account_id) + print('Address: %s' % dev.address) + print('Family: %s' % dev.family) + print('ID: %s' % dev.id) + print('Name: %s' % dev.name) + print('Timezone: %s' % dev.timezone) + print('Wifi Name: %s' % dev.wifi_name) + print('Wifi RSSI: %s' % dev.wifi_signal_strength) + + # setting dev volume + print('Volume: %s' % dev.volume) + dev.volume = 5 + print('Volume: %s' % dev.volume) + + # play dev test shound + if dev.family == 'chimes' + dev.test_sound + + +Showing door bell events +------------------------ +.. code-block:: python + + for doorbell in myring.doorbells: + + # listing the last 15 events of any kind + for event in doorbell.history(limit=15): + print('ID: %s' % event['id']) + print('Kind: %s' % event['kind']) + print('Answered: %s' % event['answered']) + print('When: %s' % event['created_at']) + print('--' * 50) + + # get a event list only the triggered by motion + events = doorbell.history(kind='motion') + + +Downloading the last video triggered by ding +-------------------------------------------- +.. code-block:: python + + doorbell = myring.doorbells[0] + doorbell.recording_download( + doorbell.history(limit=100, kind='ding')[0]['id'], + filename='/home/user/last_ding.mp4', + override=True) + + +Displaying the last video capture URL +------------------------------------- +.. code-block:: python + + print(doorbell.recording_url(doorbell.last_recording_id)) + 'https://ring-transcoded-videos.s3.amazonaws.com/99999999.mp4?X-Amz-Expires=3600&X-Amz-Date=20170313T232537Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=TOKEN_SECRET/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=secret' + + +Developing +========== + +.. autoclass:: ring_doorbell.Ring + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: ring_doorbell.generic.RingGeneric + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: ring_doorbell.chime.RingChime + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: ring_doorbell.doorbot.RingDoorBell + :members: + :undoc-members: + :show-inheritance: + :inherited-members: + +.. autoclass:: ring_doorbell.stickup_cam.RingStickUpCam + :members: + :undoc-members: + :show-inheritance: + :inherited-members: + + +Credits && Thanks +----------------- + +* This project was inspired and based on https://github.com/jeroenmoors/php-ring-api. Many thanks @jeroenmoors. +* A guy named MadBagger at Prism19 for his initial research (http://www.prism19.com/doorbot/second-pass-and-comm-reversing/) +* The creators of mitmproxy (https://mitmproxy.org/) great http and https traffic inspector +* @mfussenegger for his post on mitmproxy and virtualbox https://zignar.net/2015/12/31/sniffing-vbox-traffic-mitmproxy/ +* To the project http://www.android-x86.org/ which allowed me to install Android on KVM. + Indices and tables ================== diff --git a/docs/source/installation.rst b/docs/source/installation.rst deleted file mode 100644 index 161c57fb..00000000 --- a/docs/source/installation.rst +++ /dev/null @@ -1,13 +0,0 @@ -Installation ------------- - -.. code-block:: bash - - # Installing from PyPi - $ pip install ring_doorbell #python 2.7 - $ pip3 install ring_doorbell #python 3.x - - # Installing latest development - $ pip3 install \ - git+https://github.com/tchellomello/python-ring-doorbell@dev - diff --git a/docs/source/source_code.rst b/docs/source/source_code.rst deleted file mode 100644 index 221fbd38..00000000 --- a/docs/source/source_code.rst +++ /dev/null @@ -1,36 +0,0 @@ -Source code ------------ - - -class Ring -========== - -.. autoclass:: ring_doorbell.Ring - :members: - :undoc-members: - :show-inheritance: - -class RingGeneric -================= - -.. autoclass:: ring_doorbell.RingGeneric - :members: - :undoc-members: - :show-inheritance: - -class RingChime -=============== - -.. autoclass:: ring_doorbell.RingChime - :members: - :undoc-members: - :show-inheritance: - -class RingDoorBell -================== - -.. autoclass:: ring_doorbell.RingDoorBell - :members: - :undoc-members: - :show-inheritance: - :inherited-members: diff --git a/requirements_tests.txt b/requirements_tests.txt index f871e407..634c6f84 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -4,4 +4,5 @@ mock pylint pytest pytest-cov +requests_mock tox diff --git a/ring_doorbell/__init__.py b/ring_doorbell/__init__.py index 4d875044..fe37317a 100644 --- a/ring_doorbell/__init__.py +++ b/ring_doorbell/__init__.py @@ -1,31 +1,24 @@ # coding: utf-8 # vim:sw=4:ts=4:et: """Python Ring Doorbell wrapper.""" -from datetime import datetime - try: from urllib.parse import urlencode except ImportError: from urllib import urlencode -import os import logging import requests -import pytz -from ring_doorbell.utils import ( - _locator, _exists_cache, _save_cache, _read_cache) +from ring_doorbell.utils import _exists_cache, _save_cache, _read_cache + from ring_doorbell.const import ( - API_VERSION, API_URI, CACHE_ATTRS, CACHE_FILE, CHIMES_ENDPOINT, - CHIME_VOL_MIN, CHIME_VOL_MAX, - DEVICES_ENDPOINT, DOORBELLS_ENDPOINT, DOORBELL_VOL_MIN, DOORBELL_VOL_MAX, - DOORBELL_EXISTING_TYPE, DINGS_ENDPOINT, FILE_EXISTS, - HEADERS, LINKED_CHIMES_ENDPOINT, LIVE_STREAMING_ENDPOINT, - NEW_SESSION_ENDPOINT, MSG_BOOLEAN_REQUIRED, MSG_EXISTING_TYPE, - MSG_GENERIC_FAIL, MSG_VOL_OUTBOUND, - NOT_FOUND, URL_DOORBELL_HISTORY, URL_RECORDING, - POST_DATA, PERSIST_TOKEN_ENDPOINT, PERSIST_TOKEN_DATA, - RETRY_TOKEN, TESTSOUND_CHIME_ENDPOINT) + API_VERSION, API_URI, CACHE_ATTRS, CACHE_FILE, + DEVICES_ENDPOINT, HEADERS, NEW_SESSION_ENDPOINT, MSG_GENERIC_FAIL, + POST_DATA, PERSIST_TOKEN_ENDPOINT, PERSIST_TOKEN_DATA, RETRY_TOKEN) + +from ring_doorbell.doorbot import RingDoorBell +from ring_doorbell.chime import RingChime +from ring_doorbell.stickup_cam import RingStickUpCam _LOGGER = logging.getLogger(__name__) @@ -207,6 +200,7 @@ def devices(self): """Return all devices.""" devs = {} devs['chimes'] = self.chimes + devs['stickup_cams'] = self.stickup_cams devs['doorbells'] = self.doorbells return devs @@ -215,12 +209,17 @@ def __devices(self, device_type): lst = [] url = API_URI + DEVICES_ENDPOINT try: - if device_type == 'chime': + if device_type == 'stickup_cams': + req = self.query(url).get('stickup_cams') + for member in list((obj['description'] for obj in req)): + lst.append(RingStickUpCam(self, member)) + + if device_type == 'chimes': req = self.query(url).get('chimes') for member in list((obj['description'] for obj in req)): lst.append(RingChime(self, member)) - if device_type == 'doorbell': + if device_type == 'doorbells': req = self.query(url).get('doorbots') for member in list((obj['description'] for obj in req)): lst.append(RingDoorBell(self, member)) @@ -237,418 +236,14 @@ def __devices(self, device_type): @property def chimes(self): """Return a list of RingDoorChime objects.""" - return self.__devices('chime') - - @property - def doorbells(self): - """Return a list of RingDoorBell objects.""" - return self.__devices('doorbell') - - -class RingGeneric(object): - """Generic Implementation for Ring Chime/Doorbell.""" - - def __init__(self): - """Initialize Ring Generic.""" - self._attrs = None - self._ring = None - self.debug = None - self.family = None - self.name = None - - # alerts notifications - self.alert_expires_at = None - - def __repr__(self): - """Return __repr__.""" - return "<{0}: {1}>".format(self.__class__.__name__, self.name) - - def update(self): - """Refresh attributes.""" - self._get_attrs() - self._update_alert() - - @property - def alert(self): - """Return alert attribute.""" - return self._ring.cache['alerts'] - - @alert.setter - def alert(self, value): - """Set attribute to alert.""" - self._ring.cache['alerts'] = value - _save_cache(self._ring.cache, self._ring.cache_file) - return True - - def _update_alert(self): - """Verify if alert received is still valid.""" - # alert is no longer valid - if self.alert and self.alert_expires_at: - if datetime.now() >= self.alert_expires_at: - self.alert = None - self.alert_expires_at = None - _save_cache(self._ring.cache, self._ring.cache_file) - - def _get_attrs(self): - """Return attributes.""" - url = API_URI + DEVICES_ENDPOINT - try: - if self.family == 'doorbots' and self.shared: - lst = self._ring.query(url).get('authorized_doorbots') - else: - lst = self._ring.query(url).get(self.family) - index = _locator(lst, 'description', self.name) - if index == NOT_FOUND: - return None - except AttributeError: - return None - - self._attrs = lst[index] - return True - - @property - def account_id(self): - """Return account ID.""" - return self._attrs.get('id') - - @property - def address(self): - """Return address.""" - return self._attrs.get('address') - - @property - def firmware(self): - """Return firmware.""" - return self._attrs.get('firmware_version') - - # pylint: disable=invalid-name - @property - def id(self): - """Return ID.""" - return self._attrs.get('device_id') - - @property - def latitude(self): - """Return latitude attr.""" - return self._attrs.get('latitude') - - @property - def longitude(self): - """Return longitude attr.""" - return self._attrs.get('longitude') - - @property - def kind(self): - """Return kind attr.""" - return self._attrs.get('kind') - - @property - def timezone(self): - """Return timezone.""" - return self._attrs.get('time_zone') - - -class RingChime(RingGeneric): - """Implementation for Ring Chime.""" - - def __init__(self, ring, name): - """Initilize Ring chime object.""" - super(RingChime, self).__init__() - self._attrs = None - self._ring = ring - self.debug = self._ring.debug - self.family = 'chimes' - self.name = name - self.update() - - @property - def volume(self): - """Return if chime volume.""" - return self._attrs.get('settings').get('volume') - - @volume.setter - def volume(self, value): - if not ((isinstance(value, int)) and - (value >= CHIME_VOL_MIN and value <= CHIME_VOL_MAX)): - _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(CHIME_VOL_MIN, - CHIME_VOL_MAX)) - return False - - params = { - 'chime[description]': self.name, - 'chime[settings][volume]': str(value)} - url = API_URI + CHIMES_ENDPOINT.format(self.account_id) - self._ring.query(url, extra_params=params, method='PUT') - self.update() - return True - - @property - def linked_tree(self): - """Return doorbell data linked to chime.""" - url = API_URI + LINKED_CHIMES_ENDPOINT.format(self.account_id) - return self._ring.query(url) - - @property - def test_sound(self): - """Play chime to test sound.""" - url = API_URI + TESTSOUND_CHIME_ENDPOINT.format(self.account_id) - self._ring.query(url, method='POST') - return True - - -class RingDoorBell(RingGeneric): - """Implementation for Ring Doorbell.""" - - def __init__(self, ring, name, shared=False): - """Initilize Ring doorbell object.""" - super(RingDoorBell, self).__init__() - self._attrs = None - self._ring = ring - self.shared = shared - self.debug = self._ring.debug - self.family = 'doorbots' - self.name = name - self.update() - - @property - def battery_life(self): - """Return battery life.""" - value = int(self._attrs.get('battery_life')) - if value > 100: - value = 100 - return value - - def check_alerts(self): - """Return JSON when motion or ring is detected.""" - url = API_URI + DINGS_ENDPOINT - self.update() - - try: - resp = self._ring.query(url)[0] - except (IndexError, TypeError): - return None - - if resp: - timestamp = resp.get('now') + resp.get('expires_in') - self.alert = resp - self.alert_expires_at = datetime.fromtimestamp(timestamp) - - # save to a pickle data - if self.alert: - _save_cache(self._ring.cache, self._ring.cache_file) - return True - return None - - @property - def existing_doorbell_type(self): - """ - Return existing doorbell type. - - 0: Mechanical - 1: Digital - 2: Not Present - """ - try: - return DOORBELL_EXISTING_TYPE[ - self._attrs.get('settings').get('chime_settings').get('type')] - except AttributeError: - return None - - @existing_doorbell_type.setter - def existing_doorbell_type(self, value): - """ - Return existing doorbell type. - - 0: Mechanical - 1: Digital - 2: Not Present - """ - if value not in DOORBELL_EXISTING_TYPE.keys(): - _LOGGER.error("%s", MSG_EXISTING_TYPE) - return False - params = { - 'doorbot[description]': self.name, - 'doorbot[settings][chime_settings][type]': value} - if self.existing_doorbell_type: - url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) - self._ring.query(url, extra_params=params, method='PUT') - self.update() - return True - return None - - @property - def existing_doorbell_type_enabled(self): - """Return if existing doorbell type is enabled.""" - if self.existing_doorbell_type: - if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[2]: - return None - return \ - self._attrs.get('settings').get('chime_settings').get('enable') - return False - - @existing_doorbell_type_enabled.setter - def existing_doorbell_type_enabled(self, value): - """Enable/disable the existing doorbell if Digital/Mechanical.""" - if self.existing_doorbell_type: - - if not isinstance(value, bool): - _LOGGER.error("%s", MSG_BOOLEAN_REQUIRED) - return None - - if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[2]: - return None - - params = { - 'doorbot[description]': self.name, - 'doorbot[settings][chime_settings][enable]': value} - url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) - self._ring.query(url, extra_params=params, method='PUT') - self.update() - return True - return False - - @property - def existing_doorbell_type_duration(self): - """Return duration for Digital chime.""" - if self.existing_doorbell_type: - if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[1]: - return self._attrs.get('settings').\ - get('chime_settings').get('duration') - return None - - @existing_doorbell_type_duration.setter - def existing_doorbell_type_duration(self, value): - """Set duration for Digital chime.""" - if self.existing_doorbell_type: - - if not ((isinstance(value, int)) and - (value >= DOORBELL_VOL_MIN and value <= DOORBELL_VOL_MAX)): - _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(DOORBELL_VOL_MIN, - DOORBELL_VOL_MAX)) - return False - - if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[1]: - params = { - 'doorbot[description]': self.name, - 'doorbot[settings][chime_settings][duration]': value} - url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) - self._ring.query(url, extra_params=params, method='PUT') - self.update() - return True - return None - - def history(self, limit=30, timezone=None, kind=None): - """Return history with datetime objects.""" - # allow modify the items to return - params = {'limit': str(limit)} - - url = API_URI + URL_DOORBELL_HISTORY.format(self.account_id) - response = self._ring.query(url, extra_params=params) - - # convert for specific timezone - utc = pytz.utc - if timezone: - mytz = pytz.timezone(timezone) - - for entry in response: - dt_at = datetime.strptime(entry['created_at'], - '%Y-%m-%dT%H:%M:%S.000Z') - utc_dt = datetime(dt_at.year, dt_at.month, dt_at.day, dt_at.hour, - dt_at.minute, dt_at.second, tzinfo=utc) - if timezone: - tz_dt = utc_dt.astimezone(mytz) - entry['created_at'] = tz_dt - else: - entry['created_at'] = utc_dt - - if kind: - return list(filter(lambda array: array['kind'] == kind, response)) - - return response - - @property - def last_recording_id(self): - """Return the last recording ID.""" - try: - return self.history(limit=1)[0]['id'] - except (IndexError, TypeError): - return None - - @property - def live_streaming_json(self): - """Return JSON for live streaming.""" - url = API_URI + LIVE_STREAMING_ENDPOINT.format(self.account_id) - req = self._ring.query((url), method='POST', raw=True) - if req.status_code == 204: - url = API_URI + DINGS_ENDPOINT - try: - return self._ring.query(url)[0] - except (IndexError, TypeError): - pass - return None - - def recording_download(self, recording_id, filename=None, override=False): - """Save a recording in MP4 format to a file or return raw.""" - url = API_URI + URL_RECORDING.format(recording_id) - try: - req = self._ring.query(url, raw=True) - if req.status_code == 200: - - if filename: - if os.path.isfile(filename) and not override: - _LOGGER.error("%s", FILE_EXISTS.format(filename)) - return False - - with open(filename, 'wb') as recording: - recording.write(req.content) - return True - else: - return req.content - except IOError as error: - _LOGGER.error("%s", error) - raise - - def recording_url(self, recording_id): - """Return HTTPS recording URL.""" - url = API_URI + URL_RECORDING.format(recording_id) - req = self._ring.query(url, raw=True) - if req.status_code == 200: - return req.url - return False - - @property - def subscribed(self): - """Return if is online.""" - result = self._attrs.get('subscribed') - if result is None: - return False - return True + return self.__devices('chimes') @property - def subscribed_motion(self): - """Return if is subscribed_motion.""" - result = self._attrs.get('subscribed_motions') - if result is None: - return False - return True + def stickup_cams(self): + """Return a list of RingStickUpCam objects.""" + return self.__devices('stickup_cams') @property - def volume(self): - """Return volume.""" - return self._attrs.get('settings').get('doorbell_volume') - - @volume.setter - def volume(self, value): - if not ((isinstance(value, int)) and - (value >= DOORBELL_VOL_MIN and value <= DOORBELL_VOL_MAX)): - _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(DOORBELL_VOL_MIN, - DOORBELL_VOL_MAX)) - return False - - params = { - 'doorbot[description]': self.name, - 'doorbot[settings][doorbell_volume]': str(value)} - url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) - self._ring.query(url, extra_params=params, method='PUT') - self.update() - return True + def doorbells(self): + """Return a list of RingDoorBell objects.""" + return self.__devices('doorbells') diff --git a/ring_doorbell/chime.py b/ring_doorbell/chime.py new file mode 100644 index 00000000..9fa38b23 --- /dev/null +++ b/ring_doorbell/chime.py @@ -0,0 +1,61 @@ +# coding: utf-8 +# vim:sw=4:ts=4:et: +"""Python Ring Chime wrapper.""" +import logging + +from ring_doorbell.generic import RingGeneric +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) + +_LOGGER = logging.getLogger(__name__) + + +class RingChime(RingGeneric): + """Implementation for Ring Chime.""" + + @property + def family(self): + """Return Ring device family type.""" + return 'chimes' + + @property + def battery_life(self): + """Return battery life.""" + return self._health_attrs.get('battery_percentage') + + @property + def volume(self): + """Return if chime volume.""" + return self._attrs.get('settings').get('volume') + + @volume.setter + def volume(self, value): + if not ((isinstance(value, int)) and + (value >= CHIME_VOL_MIN and value <= CHIME_VOL_MAX)): + _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(CHIME_VOL_MIN, + CHIME_VOL_MAX)) + return False + + params = { + 'chime[description]': self.name, + 'chime[settings][volume]': str(value)} + url = API_URI + CHIMES_ENDPOINT.format(self.account_id) + self._ring.query(url, extra_params=params, method='PUT') + self.update() + return True + + @property + def linked_tree(self): + """Return doorbell data linked to chime.""" + url = API_URI + LINKED_CHIMES_ENDPOINT.format(self.account_id) + return self._ring.query(url) + + def test_sound(self, kind=KIND_DING): + """Play chime to test sound.""" + if kind not in CHIME_TEST_SOUND_KINDS: + return False + url = API_URI + TESTSOUND_CHIME_ENDPOINT.format(self.account_id) + self._ring.query(url, method='POST', extra_params={"kind": kind}) + return True diff --git a/ring_doorbell/const.py b/ring_doorbell/const.py index 084ccfe1..f820539b 100644 --- a/ring_doorbell/const.py +++ b/ring_doorbell/const.py @@ -33,13 +33,21 @@ DOORBELLS_ENDPOINT = '/clients_api/doorbots/{0}' PERSIST_TOKEN_ENDPOINT = '/clients_api/device' +HEALTH_DOORBELL_ENDPOINT = DOORBELLS_ENDPOINT + '/health' +HEALTH_CHIMES_ENDPOINT = CHIMES_ENDPOINT + '/health' LINKED_CHIMES_ENDPOINT = CHIMES_ENDPOINT + '/linked_doorbots' LIVE_STREAMING_ENDPOINT = DOORBELLS_ENDPOINT + '/vod' NEW_SESSION_ENDPOINT = '/clients_api/session' +RINGTONES_ENDPOINT = '/ringtones' TESTSOUND_CHIME_ENDPOINT = CHIMES_ENDPOINT + '/play_sound' URL_DOORBELL_HISTORY = DOORBELLS_ENDPOINT + '/history' URL_RECORDING = '/clients_api/dings/{0}/recording' +# chime test sound kinds +KIND_DING = 'ding' +KIND_MOTION = 'motion' +CHIME_TEST_SOUND_KINDS = (KIND_DING, KIND_MOTION) + # default values CHIME_VOL_MIN = 0 CHIME_VOL_MAX = 10 diff --git a/ring_doorbell/doorbot.py b/ring_doorbell/doorbot.py new file mode 100644 index 00000000..56755774 --- /dev/null +++ b/ring_doorbell/doorbot.py @@ -0,0 +1,314 @@ +# coding: utf-8 +# vim:sw=4:ts=4:et: +"""Python Ring Doorbell wrapper.""" +import logging +from datetime import datetime +import os +import pytz + + +from ring_doorbell.generic import RingGeneric + +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) + +_LOGGER = logging.getLogger(__name__) + + +class RingDoorBell(RingGeneric): + """Implementation for Ring Doorbell.""" + + @property + def family(self): + """Return Ring device family type.""" + return 'doorbots' + + @property + def battery_life(self): + """Return battery life.""" + value = int(self._attrs.get('battery_life')) + if value > 100: + value = 100 + return value + + def check_alerts(self): + """Return JSON when motion or ring is detected.""" + url = API_URI + DINGS_ENDPOINT + self.update() + + try: + resp = self._ring.query(url)[0] + except (IndexError, TypeError): + return None + + if resp: + timestamp = resp.get('now') + resp.get('expires_in') + self.alert = resp + self.alert_expires_at = datetime.fromtimestamp(timestamp) + + # save to a pickle data + if self.alert: + _save_cache(self._ring.cache, self._ring.cache_file) + return True + return None + + @property + def existing_doorbell_type(self): + """ + Return existing doorbell type. + + 0: Mechanical + 1: Digital + 2: Not Present + """ + try: + return DOORBELL_EXISTING_TYPE[ + self._attrs.get('settings').get('chime_settings').get('type')] + except AttributeError: + return None + + @existing_doorbell_type.setter + def existing_doorbell_type(self, value): + """ + Return existing doorbell type. + + 0: Mechanical + 1: Digital + 2: Not Present + """ + if value not in DOORBELL_EXISTING_TYPE.keys(): + _LOGGER.error("%s", MSG_EXISTING_TYPE) + return False + params = { + 'doorbot[description]': self.name, + 'doorbot[settings][chime_settings][type]': value} + if self.existing_doorbell_type: + url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) + self._ring.query(url, extra_params=params, method='PUT') + self.update() + return True + return None + + @property + def existing_doorbell_type_enabled(self): + """Return if existing doorbell type is enabled.""" + if self.existing_doorbell_type: + if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[2]: + return None + return \ + self._attrs.get('settings').get('chime_settings').get('enable') + return False + + @existing_doorbell_type_enabled.setter + def existing_doorbell_type_enabled(self, value): + """Enable/disable the existing doorbell if Digital/Mechanical.""" + if self.existing_doorbell_type: + + if not isinstance(value, bool): + _LOGGER.error("%s", MSG_BOOLEAN_REQUIRED) + return None + + if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[2]: + return None + + params = { + 'doorbot[description]': self.name, + 'doorbot[settings][chime_settings][enable]': value} + url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) + self._ring.query(url, extra_params=params, method='PUT') + self.update() + return True + return False + + @property + def existing_doorbell_type_duration(self): + """Return duration for Digital chime.""" + if self.existing_doorbell_type: + if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[1]: + return self._attrs.get('settings').\ + get('chime_settings').get('duration') + return None + + @existing_doorbell_type_duration.setter + def existing_doorbell_type_duration(self, value): + """Set duration for Digital chime.""" + if self.existing_doorbell_type: + + if not ((isinstance(value, int)) and + (value >= DOORBELL_VOL_MIN and value <= DOORBELL_VOL_MAX)): + _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(DOORBELL_VOL_MIN, + DOORBELL_VOL_MAX)) + return False + + if self.existing_doorbell_type == DOORBELL_EXISTING_TYPE[1]: + params = { + 'doorbot[description]': self.name, + 'doorbot[settings][chime_settings][duration]': value} + url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) + self._ring.query(url, extra_params=params, method='PUT') + self.update() + return True + return None + + def history(self, limit=30, timezone=None, kind=None, + enforce_limit=False, retry=8): + """ + Return history with datetime objects. + + :param limit: specify number of objects to be returned + :param timezone: determine which timezone to convert data objects + :param kind: filter by kind (ding, motion, on_demand) + :param enforce_limit: when True, this will enforce the limit and kind + :param retry: determine the max number of attempts to archive the limit + """ + queries = 0 + original_limit = limit + + # set cap for max queries + if retry > 10: + retry = 10 + + while True: + params = {'limit': str(limit)} + + url = API_URI + URL_DOORBELL_HISTORY.format(self.account_id) + response = self._ring.query(url, extra_params=params) + + # cherrypick only the selected kind events + if kind: + response = list(filter( + lambda array: array['kind'] == kind, response)) + + # convert for specific timezone + utc = pytz.utc + if timezone: + mytz = pytz.timezone(timezone) + + for entry in response: + dt_at = datetime.strptime(entry['created_at'], + '%Y-%m-%dT%H:%M:%S.000Z') + utc_dt = datetime(dt_at.year, dt_at.month, dt_at.day, + dt_at.hour, dt_at.minute, dt_at.second, + tzinfo=utc) + if timezone: + tz_dt = utc_dt.astimezone(mytz) + entry['created_at'] = tz_dt + else: + entry['created_at'] = utc_dt + + if enforce_limit: + # return because already matched the number + # of events by kind + if len(response) >= original_limit: + return response[:original_limit] + + # ensure the loop will exit after max queries + queries += 1 + if queries == retry: + _LOGGER.warning("Could not find total of %s of kind %s", + original_limit, kind) + break + + # ensure the kind objects returned to match limit + limit = limit * 2 + + else: + break + + return response + + @property + def last_recording_id(self): + """Return the last recording ID.""" + try: + return self.history(limit=1)[0]['id'] + except (IndexError, TypeError): + return None + + @property + def live_streaming_json(self): + """Return JSON for live streaming.""" + url = API_URI + LIVE_STREAMING_ENDPOINT.format(self.account_id) + req = self._ring.query((url), method='POST', raw=True) + if req.status_code == 204: + url = API_URI + DINGS_ENDPOINT + try: + return self._ring.query(url)[0] + except (IndexError, TypeError): + pass + return None + + def recording_download(self, recording_id, filename=None, override=False): + """Save a recording in MP4 format to a file or return raw.""" + url = API_URI + URL_RECORDING.format(recording_id) + try: + req = self._ring.query(url, raw=True) + if req.status_code == 200: + + if filename: + if os.path.isfile(filename) and not override: + _LOGGER.error("%s", FILE_EXISTS.format(filename)) + return False + + with open(filename, 'wb') as recording: + recording.write(req.content) + return True + else: + return req.content + except IOError as error: + _LOGGER.error("%s", error) + raise + + def recording_url(self, recording_id): + """Return HTTPS recording URL.""" + url = API_URI + URL_RECORDING.format(recording_id) + req = self._ring.query(url, raw=True) + if req.status_code == 200: + return req.url + return False + + @property + def subscribed(self): + """Return if is online.""" + result = self._attrs.get('subscribed') + if result is None: + return False + return True + + @property + def subscribed_motion(self): + """Return if is subscribed_motion.""" + result = self._attrs.get('subscribed_motions') + if result is None: + return False + return True + + @property + def volume(self): + """Return volume.""" + return self._attrs.get('settings').get('doorbell_volume') + + @volume.setter + def volume(self, value): + if not ((isinstance(value, int)) and + (value >= DOORBELL_VOL_MIN and value <= DOORBELL_VOL_MAX)): + _LOGGER.error("%s", MSG_VOL_OUTBOUND.format(DOORBELL_VOL_MIN, + DOORBELL_VOL_MAX)) + return False + + params = { + 'doorbot[description]': self.name, + 'doorbot[settings][doorbell_volume]': str(value)} + url = API_URI + DOORBELLS_ENDPOINT.format(self.account_id) + self._ring.query(url, extra_params=params, method='PUT') + self.update() + return True + + @property + def connection_status(self): + """Return connection status.""" + return self._attrs.get('alerts').get('connection') diff --git a/ring_doorbell/generic.py b/ring_doorbell/generic.py new file mode 100644 index 00000000..edcfb5c6 --- /dev/null +++ b/ring_doorbell/generic.py @@ -0,0 +1,148 @@ +# coding: utf-8 +# vim:sw=4:ts=4:et: +"""Python Ring RingGeneric wrapper.""" +import logging +from datetime import datetime + +from ring_doorbell.utils import _locator, _save_cache +from ring_doorbell.const import ( + API_URI, DEVICES_ENDPOINT, NOT_FOUND, + HEALTH_CHIMES_ENDPOINT, HEALTH_DOORBELL_ENDPOINT) + +_LOGGER = logging.getLogger(__name__) + + +class RingGeneric(object): + """Generic Implementation for Ring Chime/Doorbell.""" + + def __init__(self, ring, name, shared=False): + """Initialize Ring Generic.""" + self._ring = ring + self.debug = self._ring.debug + self.name = name + self.shared = shared + self._attrs = None + self._health_attrs = None + + # alerts notifications + self.alert_expires_at = None + + # force update + self.update() + + def __repr__(self): + """Return __repr__.""" + return "<{0}: {1}>".format(self.__class__.__name__, self.name) + + @property + def family(self): + """Return Ring device family type.""" + return None + + def update(self): + """Refresh attributes.""" + self._get_attrs() + self._get_health_attrs() + self._update_alert() + + @property + def alert(self): + """Return alert attribute.""" + return self._ring.cache['alerts'] + + @alert.setter + def alert(self, value): + """Set attribute to alert.""" + self._ring.cache['alerts'] = value + _save_cache(self._ring.cache, self._ring.cache_file) + return True + + def _update_alert(self): + """Verify if alert received is still valid.""" + # alert is no longer valid + if self.alert and self.alert_expires_at: + if datetime.now() >= self.alert_expires_at: + self.alert = None + self.alert_expires_at = None + _save_cache(self._ring.cache, self._ring.cache_file) + + def _get_attrs(self): + """Return attributes.""" + url = API_URI + DEVICES_ENDPOINT + try: + if self.family == 'doorbots' and self.shared: + lst = self._ring.query(url).get('authorized_doorbots') + else: + lst = self._ring.query(url).get(self.family) + index = _locator(lst, 'description', self.name) + if index == NOT_FOUND: + return None + except AttributeError: + return None + + self._attrs = lst[index] + return True + + def _get_health_attrs(self): + """Return health attributes.""" + if self.family == 'doorbots' or self.family == 'stickup_cams': + url = API_URI + HEALTH_DOORBELL_ENDPOINT.format(self.account_id) + elif self.family == 'chimes': + url = API_URI + HEALTH_CHIMES_ENDPOINT.format(self.account_id) + self._health_attrs = self._ring.query(url).get('device_health') + + @property + def account_id(self): + """Return account ID.""" + return self._attrs.get('id') + + @property + def address(self): + """Return address.""" + return self._attrs.get('address') + + @property + def firmware(self): + """Return firmware.""" + return self._attrs.get('firmware_version') + + # pylint: disable=invalid-name + @property + def id(self): + """Return ID.""" + return self._attrs.get('device_id') + + @property + def latitude(self): + """Return latitude attr.""" + return self._attrs.get('latitude') + + @property + def longitude(self): + """Return longitude attr.""" + return self._attrs.get('longitude') + + @property + def kind(self): + """Return kind attr.""" + return self._attrs.get('kind') + + @property + def timezone(self): + """Return timezone.""" + return self._attrs.get('time_zone') + + @property + def wifi_name(self): + """Return wifi ESSID name.""" + return self._health_attrs.get('wifi_name') + + @property + def wifi_signal_strength(self): + """Return wifi RSSI.""" + return self._health_attrs.get('latest_signal_strength') + + @property + def wifi_signal_category(self): + """Return wifi signal category.""" + return self._health_attrs.get('latest_signal_category') diff --git a/ring_doorbell/stickup_cam.py b/ring_doorbell/stickup_cam.py new file mode 100644 index 00000000..7ad45c5b --- /dev/null +++ b/ring_doorbell/stickup_cam.py @@ -0,0 +1,17 @@ +# coding: utf-8 +# vim:sw=4:ts=4:et: +"""Python Ring Doorbell wrapper.""" +import logging + +from ring_doorbell import RingDoorBell + +_LOGGER = logging.getLogger(__name__) + + +class RingStickUpCam(RingDoorBell): + """Implementation for RingStickUpCam.""" + + @property + def family(self): + """Return Ring device family type.""" + return 'stickup_cams' diff --git a/setup.cfg b/setup.cfg index bec0b5e4..43829428 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -description-file = README.md +description-file = README.rst [tool:pytest] testpaths = tests diff --git a/setup.py b/setup.py index 4708b7cb..54e42144 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='ring_doorbell', packages=['ring_doorbell'], - version='0.1.4', + version='0.1.5', description='A Python library to communicate with Ring' + ' Door Bell (https://ring.com/)', author='Marcelo Moreira de Mello', diff --git a/tests/fixtures/ring_chime_health_attrs.json b/tests/fixtures/ring_chime_health_attrs.json new file mode 100644 index 00000000..a9e5a845 --- /dev/null +++ b/tests/fixtures/ring_chime_health_attrs.json @@ -0,0 +1,19 @@ +{ + "device_health": { + "average_signal_category": "good", + "average_signal_strength": -39, + "battery_percentage": 100, + "battery_percentage_category": null, + "battery_voltage": null, + "battery_voltage_category": null, + "firmware": "1.2.3", + "firmware_out_of_date": false, + "id": 999999, + "latest_signal_category": "good", + "latest_signal_strength": -39, + "updated_at": "2017-09-30T07:05:03Z", + "wifi_is_ring_network": false, + "wifi_name": "ring_mock_wifi" + } +} + diff --git a/tests/fixtures/ring_devices.json b/tests/fixtures/ring_devices.json new file mode 100644 index 00000000..f7f723e3 --- /dev/null +++ b/tests/fixtures/ring_devices.json @@ -0,0 +1,128 @@ +{ + "authorized_doorbots": [ + { + "address": "123 Second St", + "alerts": {"connection": "online"}, + "battery_life": 51, + "description": "Back Door", + "device_id": "aacdef124", + "external_connection": false, + "features": { + "advanced_motion_enabled": false, + "motion_message_enabled": false, + "motions_enabled": true, + "people_only_enabled": false, + "shadow_correction_enabled": false, + "show_recordings": true}, + "firmware_version": "1.4.26", + "id": 987653, + "kind": "lpd_v1", + "latitude": 12.000000, + "longitude": -70.12345, + "motion_snooze": null, + "owned": true, + "owner": { + "email": "foo@bar.org", + "first_name": "Foo", + "id": 999999, + "last_name": "Bar"}, + "settings": { + "chime_settings": { + "duration": 3, + "enable": true, + "type": 1}, + "doorbell_volume": 5, + "enable_vod": true, + "live_view_preset_profile": "highest", + "live_view_presets": [ + "low", + "middle", + "high", + "highest"], + "motion_announcement": false, + "motion_snooze_preset_profile": "low", + "motion_snooze_presets": [ + "none", + "low", + "medium", + "high"]}, + "subscribed": true, + "subscribed_motions": true, + "time_zone": "America/New_York"}], + "chimes": [ + { + "address": "123 Main St", + "alerts": {"connection": "online"}, + "description": "Downstairs", + "device_id": "abcdef123", + "do_not_disturb": {"seconds_left": 0}, + "features": {"ringtones_enabled": true}, + "firmware_version": "1.2.3", + "id": 999999, + "kind": "chime", + "latitude": 12.000000, + "longitude": -70.12345, + "owned": true, + "owner": { + "email": "foo@bar.org", + "first_name": "Marcelo", + "id": 999999, + "last_name": "Assistant"}, + "settings": { + "ding_audio_id": null, + "ding_audio_user_id": null, + "motion_audio_id": null, + "motion_audio_user_id": null, + "volume": 2}, + "time_zone": "America/New_York"}], + "doorbots": [ + { + "address": "123 Main St", + "alerts": {"connection": "online"}, + "battery_life": 4081, + "description": "Front Door", + "device_id": "aacdef123", + "external_connection": false, + "features": { + "advanced_motion_enabled": false, + "motion_message_enabled": false, + "motions_enabled": true, + "people_only_enabled": false, + "shadow_correction_enabled": false, + "show_recordings": true}, + "firmware_version": "1.4.26", + "id": 987652, + "kind": "lpd_v1", + "latitude": 12.000000, + "longitude": -70.12345, + "motion_snooze": null, + "owned": true, + "owner": { + "email": "foo@bar.org", + "first_name": "Home", + "id": 999999, + "last_name": "Assistant"}, + "settings": { + "chime_settings": { + "duration": 3, + "enable": true, + "type": 0}, + "doorbell_volume": 1, + "enable_vod": true, + "live_view_preset_profile": "highest", + "live_view_presets": [ + "low", + "middle", + "high", + "highest"], + "motion_announcement": false, + "motion_snooze_preset_profile": "low", + "motion_snooze_presets": [ + "null", + "low", + "medium", + "high"]}, + "subscribed": true, + "subscribed_motions": true, + "time_zone": "America/New_York"}] +} diff --git a/tests/fixtures/ring_ding_active.json b/tests/fixtures/ring_ding_active.json new file mode 100644 index 00000000..7c9e0b07 --- /dev/null +++ b/tests/fixtures/ring_ding_active.json @@ -0,0 +1,26 @@ +[{ + "audio_jitter_buffer_ms": 0, + "device_kind": "lpd_v1", + "doorbot_description": "Front Door", + "doorbot_id": 987652, + "expires_in": 180, + "id": 123456789, + "id_str": "123456789", + "kind": "ding", + "motion": false, + "now": 1490949469.5498993, + "optimization_level": 1, + "protocol": "sip", + "sip_ding_id": "123456789", + "sip_endpoints": null, + "sip_from": "sip:abc123@ring.com", + "sip_server_ip": "192.168.0.1", + "sip_server_port": "15063", + "sip_server_tls": "false", + "sip_session_id": "28qdvjh-2043", + "sip_to": "sip:28qdvjh-2043@192.168.0.1:15063;transport=tcp", + "sip_token": "adecc24a428ed704b2d80adb621b5775755915529639e", + "snapshot_url": "", + "state": "ringing", + "video_jitter_buffer_ms": 0 +}] diff --git a/tests/fixtures/ring_doorboot_health_attrs.json b/tests/fixtures/ring_doorboot_health_attrs.json new file mode 100644 index 00000000..11f00886 --- /dev/null +++ b/tests/fixtures/ring_doorboot_health_attrs.json @@ -0,0 +1,19 @@ +{ + "device_health": { + "average_signal_category": "good", + "average_signal_strength": -39, + "battery_percentage": 100, + "battery_percentage_category": null, + "battery_voltage": null, + "battery_voltage_category": null, + "firmware": "1.9.2", + "firmware_out_of_date": false, + "id": 987652, + "latest_signal_category": "good", + "latest_signal_strength": -58, + "updated_at": "2017-09-30T07:05:03Z", + "wifi_is_ring_network": false, + "wifi_name": "ring_mock_wifi" + } +} + diff --git a/tests/fixtures/ring_doorboot_health_attrs_id987653.json b/tests/fixtures/ring_doorboot_health_attrs_id987653.json new file mode 100644 index 00000000..063efabd --- /dev/null +++ b/tests/fixtures/ring_doorboot_health_attrs_id987653.json @@ -0,0 +1,19 @@ +{ + "device_health": { + "average_signal_category": "good", + "average_signal_strength": -39, + "battery_percentage": 100, + "battery_percentage_category": null, + "battery_voltage": null, + "battery_voltage_category": null, + "firmware": "1.9.2", + "firmware_out_of_date": false, + "id": 987653, + "latest_signal_category": "good", + "latest_signal_strength": -58, + "updated_at": "2017-09-30T07:05:03Z", + "wifi_is_ring_network": false, + "wifi_name": "ring_mock_wifi" + } +} + diff --git a/tests/fixtures/ring_doorbots.json b/tests/fixtures/ring_doorbots.json new file mode 100644 index 00000000..7ec2d4fd --- /dev/null +++ b/tests/fixtures/ring_doorbots.json @@ -0,0 +1,10 @@ +[{ + "answered": false, + "created_at": "2017-03-05T15:03:40.000Z", + "events": [], + "favorite": false, + "id": 987654321, + "kind": "motion", + "recording": {"status": "ready"}, + "snapshot_url": "" +}] diff --git a/tests/fixtures/ring_session.json b/tests/fixtures/ring_session.json new file mode 100644 index 00000000..21ae51c6 --- /dev/null +++ b/tests/fixtures/ring_session.json @@ -0,0 +1,36 @@ +{ + "profile": { + "authentication_token": "12345678910", + "email": "foo@bar.org", + "features": { + "chime_dnd_enabled": false, + "chime_pro_enabled": true, + "delete_all_enabled": true, + "delete_all_settings_enabled": false, + "device_health_alerts_enabled": true, + "floodlight_cam_enabled": true, + "live_view_settings_enabled": true, + "lpd_enabled": true, + "lpd_motion_announcement_enabled": false, + "multiple_calls_enabled": true, + "multiple_delete_enabled": true, + "nw_enabled": true, + "nw_larger_area_enabled": false, + "nw_user_activated": false, + "owner_proactive_snoozing_enabled": true, + "power_cable_enabled": false, + "proactive_snoozing_enabled": false, + "reactive_snoozing_enabled": false, + "remote_logging_format_storing": false, + "remote_logging_level": 1, + "ringplus_enabled": true, + "starred_events_enabled": true, + "stickupcam_setup_enabled": true, + "subscriptions_enabled": true, + "ujet_enabled": false, + "video_search_enabled": false, + "vod_enabled": false}, + "first_name": "Home", + "id": 999999, + "last_name": "Assistant"} +} diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..5c0a762c --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,9 @@ +"""Helper methods for Ring DoorBell tests.""" +import os + + +def load_fixture(filename): + """Load a fixture.""" + path = os.path.join(os.path.dirname(__file__), 'fixtures', filename) + with open(path) as fdp: + return fdp.read() diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 00000000..dcb4979f --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,39 @@ +# -*- coding:utf-8 -*- +"""Define basic data for unittests.""" +import os +import unittest +import requests_mock +from tests.helpers import load_fixture + +USERNAME = 'foo' +PASSWORD = 'bar' +CACHE = os.path.join(os.path.dirname(__file__), 'cache.db') + + +class RingUnitTestBase(unittest.TestCase): + """Top level Ring Doorbell test class.""" + + @requests_mock.Mocker() + def setUp(self, mock): + """Setup unit test and load mock.""" + from ring_doorbell import Ring + mock.get('https://api.ring.com/clients_api/ring_devices', + text=load_fixture('ring_devices.json')) + mock.post('https://api.ring.com/clients_api/session', + text=load_fixture('ring_session.json')) + mock.put('https://api.ring.com/clients_api/device', + text=load_fixture('ring_devices.json')) + + self.ring = Ring(USERNAME, PASSWORD, cache_file=CACHE) + self.ring_persistent = \ + Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) + + def cleanup(self): + """Cleanup any data created from the tests.""" + self.ring = None + if os.path.isfile(CACHE): + os.remove(CACHE) + + def tearDown(self): + """Stop everything started.""" + self.cleanup() diff --git a/tests/test_ring.py b/tests/test_ring.py index f3edbb2c..3203e832 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -1,271 +1,45 @@ +# -*- coding: utf-8 -*- """The tests for the Ring platform.""" from datetime import datetime +from tests.test_base import RingUnitTestBase +from tests.helpers import load_fixture +import requests_mock -import os -import time -import unittest -try: - import mock -except ImportError: - from unittest import mock -USERNAME = 'foo' -PASSWORD = 'bar' -CACHE = 'tests/cache.db' +class TestRing(RingUnitTestBase): + """Unit test for core Ring.""" - -def mocked_requests_get(*args, **kwargs): - """Mock requests.get invocations.""" - class MockResponse: - """Class to represent a mocked response.""" - - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - if str(args[0]).startswith('https://api.ring.com/clients_api/session'): - return MockResponse({ - "profile": { - "authentication_token": "12345678910", - "email": "foo@bar.org", - "features": { - "chime_dnd_enabled": False, - "chime_pro_enabled": True, - "delete_all_enabled": True, - "delete_all_settings_enabled": False, - "device_health_alerts_enabled": True, - "floodlight_cam_enabled": True, - "live_view_settings_enabled": True, - "lpd_enabled": True, - "lpd_motion_announcement_enabled": False, - "multiple_calls_enabled": True, - "multiple_delete_enabled": True, - "nw_enabled": True, - "nw_larger_area_enabled": False, - "nw_user_activated": False, - "owner_proactive_snoozing_enabled": True, - "power_cable_enabled": False, - "proactive_snoozing_enabled": False, - "reactive_snoozing_enabled": False, - "remote_logging_format_storing": False, - "remote_logging_level": 1, - "ringplus_enabled": True, - "starred_events_enabled": True, - "stickupcam_setup_enabled": True, - "subscriptions_enabled": True, - "ujet_enabled": False, - "video_search_enabled": False, - "vod_enabled": False}, - "first_name": "Foo", - "id": 999999, - "last_name": "Bar"} - }, 201) - elif str(args[0])\ - .startswith("https://api.ring.com/clients_api/ring_devices"): - return MockResponse({ - "authorized_doorbots": [ - { - "address": "123 Second St", - "alerts": {"connection": "online"}, - "battery_life": 51, - "description": "Back Door", - "device_id": "aacdef124", - "external_connection": False, - "features": { - "advanced_motion_enabled": False, - "motion_message_enabled": False, - "motions_enabled": True, - "people_only_enabled": False, - "shadow_correction_enabled": False, - "show_recordings": True}, - "firmware_version": "1.4.26", - "id": 987653, - "kind": "lpd_v1", - "latitude": 12.000000, - "longitude": -70.12345, - "motion_snooze": None, - "owned": True, - "owner": { - "email": "foo@bar.org", - "first_name": "Foo", - "id": 999999, - "last_name": "Bar"}, - "settings": { - "chime_settings": { - "duration": 3, - "enable": True, - "type": 1}, - "doorbell_volume": 5, - "enable_vod": True, - "live_view_preset_profile": "highest", - "live_view_presets": [ - "low", - "middle", - "high", - "highest"], - "motion_announcement": False, - "motion_snooze_preset_profile": "low", - "motion_snooze_presets": [ - "none", - "low", - "medium", - "high"]}, - "subscribed": True, - "subscribed_motions": True, - "time_zone": "America/New_York"}], - "chimes": [ - { - "address": "123 Main St", - "alerts": {"connection": "online"}, - "description": "Downstairs", - "device_id": "abcdef123", - "do_not_disturb": {"seconds_left": 0}, - "features": {"ringtones_enabled": True}, - "firmware_version": "1.2.3", - "id": 999999, - "kind": "chime", - "latitude": 12.000000, - "longitude": -70.12345, - "owned": True, - "owner": { - "email": "foo@bar.org", - "first_name": "Marcelo", - "id": 999999, - "last_name": "Bar"}, - "settings": { - "ding_audio_id": None, - "ding_audio_user_id": None, - "motion_audio_id": None, - "motion_audio_user_id": None, - "volume": 2}, - "time_zone": "America/New_York"}], - "doorbots": [ - { - "address": "123 Main St", - "alerts": {"connection": "online"}, - "battery_life": 4081, - "description": "Front Door", - "device_id": "aacdef123", - "external_connection": False, - "features": { - "advanced_motion_enabled": False, - "motion_message_enabled": False, - "motions_enabled": True, - "people_only_enabled": False, - "shadow_correction_enabled": False, - "show_recordings": True}, - "firmware_version": "1.4.26", - "id": 987652, - "kind": "lpd_v1", - "latitude": 12.000000, - "longitude": -70.12345, - "motion_snooze": None, - "owned": True, - "owner": { - "email": "foo@bar.org", - "first_name": "Foo", - "id": 999999, - "last_name": "Bar"}, - "settings": { - "chime_settings": { - "duration": 3, - "enable": True, - "type": 0}, - "doorbell_volume": 1, - "enable_vod": True, - "live_view_preset_profile": "highest", - "live_view_presets": [ - "low", - "middle", - "high", - "highest"], - "motion_announcement": False, - "motion_snooze_preset_profile": "low", - "motion_snooze_presets": [ - "none", - "low", - "medium", - "high"]}, - "subscribed": True, - "subscribed_motions": True, - "time_zone": "America/New_York"}] - }, 200) - elif str(args[0]).startswith("https://api.ring.com/clients_api/doorbots"): - return MockResponse([{ - "answered": False, - "created_at": "2017-03-05T15:03:40.000Z", - "events": [], - "favorite": False, - "id": 987654321, - "kind": "motion", - "recording": {"status": "ready"}, - "snapshot_url": "" - }], 200) - elif str(args[0])\ - .startswith("https://api.ring.com/clients_api/dings/active"): - return MockResponse([{ - "audio_jitter_buffer_ms": 0, - "device_kind": "lpd_v1", - "doorbot_description": "Front Door", - "doorbot_id": 12345, - "expires_in": 180, - "id": 123456789, - "id_str": "123456789", - "kind": "ding", - "motion": False, - "now": time.time(), - "optimization_level": 1, - "protocol": "sip", - "sip_ding_id": "123456789", - "sip_endpoints": None, - "sip_from": "sip:abc123@ring.com", - "sip_server_ip": "192.168.0.1", - "sip_server_port": "15063", - "sip_server_tls": "false", - "sip_session_id": "28qdvjh-2043", - "sip_to": "sip:28qdvjh-2043@192.168.0.1:15063;transport=tcp", - "sip_token": "adecc24a428ed704b2d80adb621b5775755915529639e", - "snapshot_url": "", - "state": "ringing", - "video_jitter_buffer_ms": 0 - }], 200) - - -class TestRing(unittest.TestCase): - """Test the Ring.""" - - @mock.patch('requests.Session.get', side_effect=mocked_requests_get) - @mock.patch('requests.Session.post', side_effect=mocked_requests_get) - def test_basic_attributes(self, get_mock, post_mock): + @requests_mock.Mocker() + def test_basic_attributes(self, mock): """Test the Ring class and methods.""" - from ring_doorbell import Ring - - myring = Ring(USERNAME, PASSWORD, cache_file=CACHE) - self.assertTrue(myring.is_connected) - self.assertIsInstance(myring.cache, dict) - self.assertFalse(myring.debug) - self.assertEqual(1, len(myring.chimes)) - self.assertEqual(2, len(myring.doorbells)) - self.assertFalse(myring._persist_token) - self.assertEquals('http://localhost/', myring._push_token_notify_url) - - -class TestRingChime(unittest.TestCase): - """Test the Ring Chime object.""" - - @mock.patch('requests.Session.get', side_effect=mocked_requests_get) - @mock.patch('requests.Session.post', side_effect=mocked_requests_get) - def test_chime_attributes(self, get_mock, post_mock): + mock.get('https://api.ring.com/clients_api/ring_devices', + text=load_fixture('ring_devices.json')) + mock.get('https://api.ring.com/clients_api/chimes/999999/health', + text=load_fixture('ring_chime_health_attrs.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/health', + text=load_fixture('ring_doorboot_health_attrs.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987653/health', + text=load_fixture('ring_doorboot_health_attrs_id987653.json')) + + data = self.ring + self.assertTrue(data.is_connected) + self.assertIsInstance(data.cache, dict) + self.assertFalse(data.debug) + self.assertEqual(1, len(data.chimes)) + self.assertEqual(2, len(data.doorbells)) + self.assertFalse(data._persist_token) + self.assertEquals('http://localhost/', data._push_token_notify_url) + + @requests_mock.Mocker() + def test_chime_attributes(self, mock): """Test the Ring Chime class and methods.""" - from ring_doorbell import Ring + mock.get('https://api.ring.com/clients_api/ring_devices', + text=load_fixture('ring_devices.json')) + mock.get('https://api.ring.com/clients_api/chimes/999999/health', + text=load_fixture('ring_chime_health_attrs.json')) - myring = Ring(USERNAME, PASSWORD, cache_file=CACHE) - dev = myring.chimes[0] + data = self.ring + dev = data.chimes[0] self.assertEqual('123 Main St', dev.address) self.assertNotEqual(99999, dev.account_id) @@ -274,28 +48,23 @@ def test_chime_attributes(self, get_mock, post_mock): self.assertIsNotNone(dev.latitude) self.assertEqual('America/New_York', dev.timezone) self.assertEqual(2, dev.volume) - - -class TestRingDoorBell(unittest.TestCase): - """Test the Ring DoorBell object.""" - - def cleanup(self): - """Cleanup any data created from the tests.""" - if os.path.isfile(CACHE): - os.remove(CACHE) - - def tearDown(self): - """Stop everything started.""" - self.cleanup() - - @mock.patch('requests.Session.get', side_effect=mocked_requests_get) - @mock.patch('requests.Session.post', side_effect=mocked_requests_get) - def test_doorbell_attributes(self, get_mock, post_mock): - """Test the Ring DoorBell class and methods.""" - from ring_doorbell import Ring - - myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) - for dev in myring.doorbells: + self.assertEqual('ring_mock_wifi', dev.wifi_name) + self.assertEqual('good', dev.wifi_signal_category) + self.assertNotEqual(100, dev.wifi_signal_strength) + + @requests_mock.Mocker() + def test_doorbell_attributes(self, mock): + mock.get('https://api.ring.com/clients_api/ring_devices', + text=load_fixture('ring_devices.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/history', + text=load_fixture('ring_doorbots.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/health', + text=load_fixture('ring_doorboot_health_attrs.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987653/health', + text=load_fixture('ring_doorboot_health_attrs_id987653.json')) + + data = self.ring_persistent + for dev in data.doorbells: if not dev.shared: self.assertEqual('Front Door', dev.name) self.assertEqual(987652, dev.account_id) @@ -304,22 +73,35 @@ def test_doorbell_attributes(self, get_mock, post_mock): self.assertEqual(-70.12345, dev.longitude) self.assertEqual('America/New_York', dev.timezone) self.assertEqual(1, dev.volume) + self.assertEqual('online', dev.connection_status) self.assertIsInstance(dev.history(limit=1, kind='motion'), list) self.assertEqual(0, len(dev.history(limit=1, kind='ding'))) + self.assertEqual(0, len(dev.history(limit=1, + kind='ding', + enforce_limit=True, + retry=50))) self.assertEqual('Mechanical', dev.existing_doorbell_type) - self.assertTrue(myring._persist_token) - - @mock.patch('requests.Session.get', side_effect=mocked_requests_get) - @mock.patch('requests.Session.post', side_effect=mocked_requests_get) - def test_shared_doorbell_attributes(self, get_mock, post_mock): - """Test the Ring Shared DoorBell class and methods.""" - from ring_doorbell import Ring - - myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) - for dev in myring.doorbells: + self.assertTrue(data._persist_token) + self.assertEqual('ring_mock_wifi', dev.wifi_name) + self.assertEqual('good', dev.wifi_signal_category) + self.assertEqual(-58, dev.wifi_signal_strength) + + @requests_mock.Mocker() + def test_shared_doorbell_attributes(self, mock): + mock.get('https://api.ring.com/clients_api/ring_devices', + text=load_fixture('ring_devices.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/history', + text=load_fixture('ring_doorbots.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/health', + text=load_fixture('ring_doorboot_health_attrs.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987653/health', + text=load_fixture('ring_doorboot_health_attrs_id987653.json')) + + data = self.ring_persistent + for dev in data.doorbells: if dev.shared: self.assertEqual(987653, dev.account_id) self.assertEqual(51, dev.battery_life) @@ -330,29 +112,19 @@ def test_shared_doorbell_attributes(self, get_mock, post_mock): self.assertEqual(5, dev.volume) self.assertEqual('Digital', dev.existing_doorbell_type) - os.remove(CACHE) - - -class TestRingDoorBellAlerts(unittest.TestCase): - """Test the Ring DoorBell alerts.""" - - def cleanup(self): - """Cleanup any data created from the tests.""" - if os.path.isfile(CACHE): - os.remove(CACHE) - - def tearDown(self): - """Stop everything started.""" - self.cleanup() - - @mock.patch('requests.Session.get', side_effect=mocked_requests_get) - @mock.patch('requests.Session.post', side_effect=mocked_requests_get) - def test_doorbell_alerts(self, get_mock, post_mock): - """Test the Ring DoorBell alerts.""" - from ring_doorbell import Ring - - myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) - for dev in myring.doorbells: + @requests_mock.Mocker() + def test_doorbell_alerts(self, mock): + mock.get('https://api.ring.com/clients_api/ring_devices', + text=load_fixture('ring_devices.json')) + mock.get('https://api.ring.com/clients_api/dings/active', + text=load_fixture('ring_ding_active.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987652/health', + text=load_fixture('ring_doorboot_health_attrs.json')) + mock.get('https://api.ring.com/clients_api/doorbots/987653/health', + text=load_fixture('ring_doorboot_health_attrs_id987653.json')) + + data = self.ring_persistent + for dev in data.doorbells: self.assertEqual('America/New_York', dev.timezone) # call alerts @@ -360,5 +132,5 @@ def test_doorbell_alerts(self, get_mock, post_mock): self.assertIsInstance(dev.alert, dict) self.assertIsInstance(dev.alert_expires_at, datetime) - self.assertTrue(datetime.now() <= dev.alert_expires_at) + self.assertTrue(datetime.now() >= dev.alert_expires_at) self.assertIsNotNone(dev._ring.cache_file)