diff --git a/ring_doorbell/__init__.py b/ring_doorbell/__init__.py index d6ebf6eb..4d875044 100644 --- a/ring_doorbell/__init__.py +++ b/ring_doorbell/__init__.py @@ -14,9 +14,10 @@ import pytz from ring_doorbell.utils import ( - _locator, _clean_cache, _save_cache, _read_cache) + _locator, _exists_cache, _save_cache, _read_cache) from ring_doorbell.const import ( - API_VERSION, API_URI, CHIMES_ENDPOINT, CHIME_VOL_MIN, CHIME_VOL_MAX, + 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, @@ -33,11 +34,10 @@ class Ring(object): """A Python Abstraction object to Ring Door Bell.""" def __init__(self, username, password, debug=False, persist_token=False, - push_token_notify_url="http://localhost/"): + push_token_notify_url="http://localhost/", reuse_session=True, + cache_file=CACHE_FILE): """Initialize the Ring object.""" - self.features = None self.is_connected = None - self._id = None self.token = None self.params = None self._persist_token = persist_token @@ -49,9 +49,50 @@ def __init__(self, username, password, debug=False, persist_token=False, self.session = requests.Session() self.session.auth = (self.username, self.password) - self._authenticate() + self.cache = CACHE_ATTRS + self.cache['account'] = self.username + self.cache_file = cache_file + self._reuse_session = reuse_session - def _authenticate(self, attempts=RETRY_TOKEN): + # tries to re-use old session + if self._reuse_session: + self.cache['token'] = self.token + self._process_cached_session() + else: + self._authenticate() + + def _process_cached_session(self): + """Process cache_file to reuse token instead.""" + if _exists_cache(self.cache_file): + self.cache = _read_cache(self.cache_file) + + # if self.cache['token'] is None, the cache file was corrupted. + # of if self.cache['account'] does not match with self.username + # In both cases, a new auth token is required. + if (self.cache['token'] is None) or \ + (self.cache['account'] is None) or \ + (self.cache['account'] != self.username): + self._authenticate() + else: + # we need to set the self.token and self.params + # to make use of the self.query() method + self.token = self.cache['token'] + self.params = {'api_version': API_VERSION, + 'auth_token': self.token} + + # test if token from cache_file is still valid and functional + # if not, it should continue to get a new auth token + url = API_URI + DEVICES_ENDPOINT + req = self.query(url, raw=True) + if req.status_code == 200: + self._authenticate(session=req) + else: + self._authenticate() + else: + # first time executing, so we have to create a cache file + self._authenticate() + + def _authenticate(self, attempts=RETRY_TOKEN, session=None): """Authenticate user against Ring API.""" url = API_URI + NEW_SESSION_ENDPOINT @@ -59,17 +100,25 @@ def _authenticate(self, attempts=RETRY_TOKEN): while loop <= attempts: loop += 1 try: - req = self.session.post((url), data=POST_DATA, headers=HEADERS) + if session is None: + req = self.session.post((url), + data=POST_DATA, + headers=HEADERS) + else: + req = session except: raise # if token is expired, refresh credentials and try again - if req.status_code == 201: - data = req.json().get('profile') - self.features = data.get('features') - self._id = data.get('id') + if req.status_code == 200 or req.status_code == 201: + + # the only way to get a JSON with token is via POST, + # so we need a special conditional for 201 code + if req.status_code == 201: + data = req.json().get('profile') + self.token = data.get('authentication_token') + self.is_connected = True - self.token = data.get('authentication_token') self.params = {'api_version': API_VERSION, 'auth_token': self.token} @@ -80,6 +129,13 @@ def _authenticate(self, attempts=RETRY_TOKEN): self._push_token_notify_url req = self.session.put((url), headers=HEADERS, data=PERSIST_TOKEN_DATA) + + # update token if reuse_session is True + if self._reuse_session: + self.cache['account'] = self.username + self.cache['token'] = self.token + + _save_cache(self.cache, self.cache_file) return True self.is_connected = False @@ -146,14 +202,6 @@ def query(self, _LOGGER.debug("%s", MSG_GENERIC_FAIL) return response - @property - def has_subscription(self): - """Return if account has subscription.""" - try: - return self.features.get('subscriptions_enabled') - except AttributeError: - return NOT_FOUND - @property def devices(self): """Return all devices.""" @@ -203,13 +251,12 @@ class RingGeneric(object): 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_cache = None - self.alert = None self.alert_expires_at = None def __repr__(self): @@ -221,26 +268,26 @@ def update(self): 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 - elif self._alert_cache: - aux = _read_cache(self._alert_cache) - if ((isinstance(aux, dict)) and - ('now' in aux) and - ('expires_in' in aux)): - aux_expires_at = datetime.fromtimestamp( - aux.get('now') + aux.get('expires_in')) - - # verify if pickle object is still valid - if datetime.now() <= aux_expires_at: - self.alert = aux - self.alert_expires_at = aux_expires_at - else: - _save_cache(None, self._alert_cache) + _save_cache(self._ring.cache, self._ring.cache_file) def _get_attrs(self): """Return attributes.""" @@ -371,14 +418,8 @@ def battery_life(self): value = 100 return value - def check_alerts(self, cache=None): + def check_alerts(self): """Return JSON when motion or ring is detected.""" - # save alerts attributes to an external pickle file - # when multiple resources are checking for alerts - if cache: - _clean_cache(cache) - self._alert_cache = cache - url = API_URI + DINGS_ENDPOINT self.update() @@ -393,8 +434,8 @@ def check_alerts(self, cache=None): self.alert_expires_at = datetime.fromtimestamp(timestamp) # save to a pickle data - if self._alert_cache: - _save_cache(self.alert, self._alert_cache) + if self.alert: + _save_cache(self._ring.cache, self._ring.cache_file) return True return None diff --git a/ring_doorbell/const.py b/ring_doorbell/const.py index 777d4e3b..084ccfe1 100644 --- a/ring_doorbell/const.py +++ b/ring_doorbell/const.py @@ -1,6 +1,7 @@ # coding: utf-8 # vim:sw=4:ts=4:et: """Constants.""" +import os from uuid import uuid4 as uuid HEADERS = {'Content-Type': 'application/x-www-form-urlencoded; charset: UTF-8', @@ -10,6 +11,16 @@ # number of attempts to refresh token RETRY_TOKEN = 3 +# default suffix for session cache file +CACHE_ATTRS = {'account': None, 'alerts': None, 'token': None} + +try: + CACHE_FILE = os.path.join(os.getenv("HOME"), + '.ring_doorbell-session.cache') +except (AttributeError, TypeError): + CACHE_FILE = os.path.join('.', '.ring_doorbell-session.cache') + + # code when item was not found NOT_FOUND = -1 diff --git a/ring_doorbell/utils.py b/ring_doorbell/utils.py index f396a9d4..24797781 100644 --- a/ring_doorbell/utils.py +++ b/ring_doorbell/utils.py @@ -2,7 +2,7 @@ # vim:sw=4:ts=4:et: """Python Ring Doorbell utils.""" import os -from ring_doorbell.const import NOT_FOUND +from ring_doorbell.const import CACHE_ATTRS, NOT_FOUND try: import cPickle as pickle @@ -23,10 +23,19 @@ def _clean_cache(filename): """Remove filename if pickle version mismatch.""" try: if os.path.isfile(filename): - _read_cache(filename) - except ValueError: - os.remove(filename) - return True + os.remove(filename) + except: + raise + + # initialize cache since file was removed + initial_cache_data = CACHE_ATTRS + _save_cache(initial_cache_data, filename) + return initial_cache_data + + +def _exists_cache(filename): + """Check if filename exists and if is pickle object.""" + return bool(os.path.isfile(filename)) def _save_cache(data, filename): @@ -43,6 +52,14 @@ def _read_cache(filename): """Read data from a pickle file.""" try: if os.path.isfile(filename): - return pickle.load(open(filename, 'rb')) + data = pickle.load(open(filename, 'rb')) + + # make sure pickle obj has the expected defined keys + # if not reinitialize cache + if data.keys() != CACHE_ATTRS.keys(): + raise EOFError + return data + except EOFError: + return _clean_cache(filename) except: raise diff --git a/tests/test_ring.py b/tests/test_ring.py index 05049433..ac2912f3 100644 --- a/tests/test_ring.py +++ b/tests/test_ring.py @@ -11,7 +11,7 @@ USERNAME = 'foo' PASSWORD = 'bar' -ALERT_CACHE_DB = 'tests/cache.db' +CACHE = 'tests/cache.db' def mocked_requests_get(*args, **kwargs): @@ -245,9 +245,9 @@ def test_basic_attributes(self, get_mock, post_mock): """Test the Ring class and methods.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE) self.assertTrue(myring.is_connected) - self.assertIsInstance(myring.features, dict) + self.assertIsInstance(myring.cache, dict) self.assertFalse(myring.debug) self.assertEqual(1, len(myring.chimes)) self.assertEqual(2, len(myring.doorbells)) @@ -264,7 +264,7 @@ def test_chime_attributes(self, get_mock, post_mock): """Test the Ring Chime class and methods.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE) dev = myring.chimes[0] self.assertEqual('123 Main St', dev.address) @@ -285,7 +285,7 @@ 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, persist_token=True) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) for dev in myring.doorbells: if not dev.shared: self.assertEqual('Front Door', dev.name) @@ -309,7 +309,7 @@ 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, persist_token=True) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) for dev in myring.doorbells: if dev.shared: self.assertEqual(987653, dev.account_id) @@ -321,6 +321,8 @@ 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.""" @@ -331,16 +333,16 @@ def test_doorbell_alerts(self, get_mock, post_mock): """Test the Ring DoorBell alerts.""" from ring_doorbell import Ring - myring = Ring(USERNAME, PASSWORD, persist_token=True) + myring = Ring(USERNAME, PASSWORD, cache_file=CACHE, persist_token=True) for dev in myring.doorbells: self.assertEqual('America/New_York', dev.timezone) # call alerts - dev.check_alerts(cache=ALERT_CACHE_DB) + dev.check_alerts() self.assertIsInstance(dev.alert, dict) self.assertIsInstance(dev.alert_expires_at, datetime) self.assertTrue(datetime.now() <= dev.alert_expires_at) - self.assertIsNotNone(dev._alert_cache) + self.assertIsNotNone(dev._ring.cache_file) - os.remove(ALERT_CACHE_DB) + os.remove(CACHE) diff --git a/tests/test_ring_utils.py b/tests/test_ring_utils.py new file mode 100644 index 00000000..4dfe64d5 --- /dev/null +++ b/tests/test_ring_utils.py @@ -0,0 +1,49 @@ +"""The tests utils.py for the Ring platform.""" +import os +import unittest +from ring_doorbell.utils import ( + _locator, _clean_cache, _exists_cache, _save_cache, _read_cache) +from ring_doorbell.const import CACHE_ATTRS + +CACHE = 'tests/cache.db' +FAKE = 'tests/fake.db' +DATA = {'key': 'value'} + + +class TestUtils(unittest.TestCase): + """Test utils.py.""" + + def test_locator(self): + """Test _locator method.""" + self.assertEquals(-1, _locator([DATA], 'key', 'bar')) + self.assertEquals(0, _locator([DATA], 'key', 'value')) + + def test_initiliaze_clean_cache(self): + """Test _clean_cache method.""" + self.assertTrue(_save_cache(DATA, CACHE)) + self.assertIsInstance(_clean_cache(CACHE), dict) + os.remove(CACHE) + + def test_exists_cache(self): + """Test _exists_cache method.""" + self.assertTrue(_save_cache(DATA, CACHE)) + self.assertTrue(_exists_cache(CACHE)) + os.remove(CACHE) + + def test_read_cache(self): + """Test _read_cache method.""" + self.assertTrue(_save_cache(DATA, CACHE)) + self.assertIsInstance(_read_cache(CACHE), dict) + os.remove(CACHE) + + def test_read_cache_eoferror(self): + """Test _read_cache method.""" + open(CACHE, 'a').close() + self.assertIsInstance(_read_cache(CACHE), dict) + os.remove(CACHE) + + def test_read_cache_dict(self): + """Test _read_cache with expected dict.""" + self.assertTrue(_save_cache(CACHE_ATTRS, CACHE)) + self.assertIsInstance(_read_cache(CACHE), dict) + os.remove(CACHE)