diff --git a/Pipfile.lock b/Pipfile.lock index 53de670..e5214f2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -144,11 +144,11 @@ }, "requests": { "hashes": [ - "sha256:8e5643905bf20a308e25e4c1dd379117c09000bf8a82ebccc462cfb1b34a16b5", - "sha256:f71a09d7feba4a6b64ffd8e9d9bc60f9bf7d7e19fd0e04362acb1cfc2e3d98df" + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" ], "index": "pypi", - "version": "==2.27.0" + "version": "==2.27.1" }, "requests-mock": { "hashes": [ @@ -229,11 +229,11 @@ }, "pygments": { "hashes": [ - "sha256:59b895e326f0fb0d733fd28c6839bd18ad0687ba20efc26d4277fd1d30b971f4", - "sha256:9135c1af61eec0f650cd1ea1ed8ce298e54d56bcd8cc2ef46edd7702c171337c" + "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65", + "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a" ], "markers": "python_version >= '3.5'", - "version": "==2.11.1" + "version": "==2.11.2" }, "wcwidth": { "hashes": [ diff --git a/tconnectsync/__init__.py b/tconnectsync/__init__.py index 49c8731..8b3d48d 100644 --- a/tconnectsync/__init__.py +++ b/tconnectsync/__init__.py @@ -7,7 +7,7 @@ import pkg_resources from .api import TConnectApi from .process import process_time_range -from .autoupdate import process_auto_update +from .autoupdate import Autoupdate from .check import check_login from .nightscout import NightscoutApi from .features import DEFAULT_FEATURES, ALL_FEATURES @@ -19,6 +19,7 @@ try: NS_URL, NS_SECRET ) + from . import secret except Exception: print('Unable to read secret.py') sys.exit(1) @@ -82,7 +83,8 @@ def main(*args, **kwargs): if args.auto_update: print("Starting auto-update between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "") - process_auto_update(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features) + u = Autoupdate(secret) + sys.exit(u.process(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features)) else: print("Processing data between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "") added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features) diff --git a/tconnectsync/api/__init__.py b/tconnectsync/api/__init__.py index bda69aa..6ee8f41 100644 --- a/tconnectsync/api/__init__.py +++ b/tconnectsync/api/__init__.py @@ -11,13 +11,12 @@ class TConnectApi: email = None password = None - _ciq = None - _ws2 = None - _android = None - def __init__(self, email, password): self.email = email self.password = password + self._ciq = None + self._ws2 = None + self._android = None @property diff --git a/tconnectsync/autoupdate.py b/tconnectsync/autoupdate.py index 125da60..1e30c0e 100644 --- a/tconnectsync/autoupdate.py +++ b/tconnectsync/autoupdate.py @@ -2,100 +2,205 @@ import time import logging import sys -from .api import TConnectApi from .process import process_time_range from .features import DEFAULT_FEATURES -from .secret import ( - TCONNECT_EMAIL, - TCONNECT_PASSWORD, - PUMP_SERIAL_NUMBER, - AUTOUPDATE_DEFAULT_SLEEP_SECONDS, - AUTOUPDATE_MAX_SLEEP_SECONDS, - AUTOUPDATE_USE_FIXED_SLEEP, - AUTOUPDATE_FAILURE_MINUTES, - AUTOUPDATE_RESTART_ON_FAILURE -) +from . import secret logger = logging.getLogger(__name__) -""" -Performs the auto-update functionality. Runs indefinitely in a loop -until stopped (ctrl+c). -""" -def process_auto_update(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES): - # Read from android api, find exact interval to cut down on API calls - # Refresh API token. If failure, die, have wrapper script re-run. +class Autoupdate: + """Wrap access to secrets for easier testing.""" + def __init__(self, secret): + self.secret = secret + self.autoupdate_invocations = 0 + self.last_event_index = None + self.last_event_time = None + self.last_successful_process_time_range = None + self.time_diffs_between_updates = [] + self.last_attempt_time = None + self.time_diffs_between_attempts = [] - last_event_index = None - last_event_time = None - last_process_time_range = None - time_diffs = [] - while True: - last_event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER) - if not last_event_index or last_event['maxPumpEventIndex'] > last_event_index: + """ + Performs the auto-update functionality. Runs indefinitely in a loop + until stopped (ctrl+c), or a maximum of AUTOUPDATE_MAX_LOOP_INVOCATIONS times. + Stops if AUTOUPDATE_RESTART_ON_FAILURE is set and an error occurs. + """ + def process(self, tconnect, nightscout, time_start, time_end, pretend, features=None): + if features is None: + features = DEFAULT_FEATURES + + # Read from android api, find exact interval to cut down on API calls + # Refresh API token. If failure, die, have wrapper script re-run. + + self.autoupdate_start = time.time() + + while True: + logger.debug("autoupdate loop") now = time.time() - logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], last_event_index)) + last_event = tconnect.android.last_event_uploaded(self.secret.PUMP_SERIAL_NUMBER) + if not self.last_event_index or last_event['maxPumpEventIndex'] > self.last_event_index: + logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], self.last_event_index)) - if pretend: - logger.info('Would update now if not in pretend mode') - else: - added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features) - logger.info('Added %d items from process_time_range' % added) - if added == 0: - if last_event_index: - logger.error('An event index change was recorded, but no new data was found via the API. ' + - 'If this error reoccurs, try restarting tconnectsync.') - - logger.info('Resetting TConnectApi') - tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD) + if pretend: + logger.info('Would update now if not in pretend mode') else: - last_process_time_range = now + added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features) + logger.info('Added %d items from process_time_range' % added) + if added == 0: + # If we've been unable to find new events, but the last_event_index is increasing, + # suggesting there are more events being added, we might be in a bugged + # situation where we can't get any more data without restarting. + # We skip this check on the first process cycle, since we might + # just already be in sync with tconnect's pump data. + if self.last_event_index: + + # Find the timestamp of the last time we've successfully obtained data, + # or the time when the autoupdate run started, if we haven't at all. + last_action_or_start = self.last_successful_process_time_range + if not last_action_or_start: + last_action_or_start = self.autoupdate_start + + # If it's been AUTOUPDATE_FAILURE_MINUTES in the state of not seeing + # event index changes reflected in the tconnect data we're pulling, + # raise an error and potentially restart. + # This is likely a tconnectsync problem, not a problem with the pump or app + # (we can see the indexes increasing, so we know something's happening!) + if (now - last_action_or_start) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES: + logger.error(AutoupdateFailureError( + "An event index change was recorded, but no new data was found via the API. " + + "The %s was %d minutes ago. This is a problem with tconnectsync." % + ("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60))) + + if self.secret.AUTOUPDATE_RESTART_ON_FAILURE: + logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE") + return 1 + else: + logger.warn(AutoupdateFailureWarning("An event index change was recorded, but no new data was found via the API. " + + "The %s was %d minutes ago. Resetting TConnectApi to attempt to solve this problem." % + ("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60))) + + # As a stop-gap, try to re-initialize TConnectApi (triggering a re-login) + # Use __class__ instead of direct TConnectApi invocation to avoid initializing a real TConnectApi over a fake + tconnect = tconnect.__class__(self.secret.TCONNECT_EMAIL, self.secret.TCONNECT_PASSWORD) + else: + # Mark the last successful time we got data from tconnect + self.last_successful_process_time_range = now - if last_event_index: - time_diffs.append(now - last_event_time) - logger.debug('Updating tracking of time since last update: %s' % time_diffs) + # Track the time it took to find a new event between runs, + # but skip this calculation the first process cycle (since + # we don't know at what exact point the event index changed) + if self.last_event_index: + self.time_diffs_between_updates.append(now - self.last_event_time) + logger.debug('Updating tracking of time since last update: %s' % self.time_diffs_between_updates) - last_event_index = last_event['maxPumpEventIndex'] - last_event_time = now - else: - logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex']) - now = time.time() + # Mark the last event index uploaded from the pump and timestamp + self.last_event_index = last_event['maxPumpEventIndex'] + self.last_event_time = now + self.last_attempt_time = now + self.time_diffs_between_attempts = [] + else: + logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex']) - if last_event_time and (now - last_event_time) >= 60 * AUTOUPDATE_FAILURE_MINUTES: - logger.error(AutoupdateFailureException("No new data event indexes have been detected for over %d minutes. " % AUTOUPDATE_FAILURE_MINUTES + - "The t:connect app might no longer be functioning.")) + # If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES, + # then trigger an error and potentially restart. + # The most likely case here is that the pump isn't uploading right now. + if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES: + logger.error(AutoupdateNoEventIndexesDetectedError( + "No new data event indexes have been detected for %d minutes. " % ((now - self.last_event_time)//60) + + "The t:connect app might no longer be functioning.")) - if AUTOUPDATE_RESTART_ON_FAILURE: - sys.exit(1) + # TODO: restarting doesn't really help anything here. + # Should we notify the user? + if self.secret.AUTOUPDATE_RESTART_ON_FAILURE: + logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE") + return 1 - elif last_process_time_range and (now - last_process_time_range) >= 60 * AUTOUPDATE_FAILURE_MINUTES: - logger.error(AutoupdateFailureException("No new data has been found via the API for over %d minutes. " % AUTOUPDATE_FAILURE_MINUTES + - "tconnectsync might not be functioning properly.")) + # Similarly, if we HAVE seen pump event indexes update but have not successfully + # found any associated data updates from the tconnect API for AUTOUPDATE_NO_DATA_FAILURE_MINUTES, + # trigger an error and potentially restart. This could either be a tconnectsync problem, + # where we can see the indexes increasing, but it takes us until a period of no index + # update to reach our AUTOUPDATE_FAILURE_MINUTES threshold; or, a side effect of the + # above no indexes warning. + elif self.last_successful_process_time_range and (now - self.last_successful_process_time_range) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES: + logger.error(AutoupdateNoNewDataDetectedError( + "No new data has been detected via the API for %d minutes. " % (now - self.last_successful_process_time_range)//60 + + "tconnectsync might not be functioning properly.")) - if AUTOUPDATE_RESTART_ON_FAILURE: - sys.exit(1) + if self.secret.AUTOUPDATE_RESTART_ON_FAILURE: + logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE") + return 1 + + # Track how long we've been retrying + if self.last_attempt_time: + self.time_diffs_between_attempts.append(now - self.last_attempt_time) + + self.last_attempt_time = now + + # If it's been 3 loops since the last time we found new data, + # then we're not in sync with the rate at which pump data is being + # uploaded, so + if len(self.time_diffs_between_attempts) >= 3: + # The pump hasn't sent us data that, based on previous cadence, we were expecting + logger.warn(AutoupdateNoIndexChangeWarning("Sleeping %d seconds after unexpected no index change based on previous cadence. (New data might be delayed.)" % + int(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS))) + + logger.debug("Last event time: %s, time diffs between attempts: %s" % (self.last_event_time, self.time_diffs_between_attempts)) + + time.sleep(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS) + + # Since we bail early, update the invocations count and potentially exit after sleeping. + self.autoupdate_invocations += 1 + if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS: + return 0 + + continue + + sleep_secs = self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS + + # Sleep for a rolling average of time between updates + if self.secret.AUTOUPDATE_USE_FIXED_SLEEP != 1: + logger.debug("Time diffs between updates: %s" % self.time_diffs_between_updates) + + # Only keep the 10 latest time diffs + if len(self.time_diffs_between_updates) > 10: + self.time_diffs_between_updates = self.time_diffs_between_updates[1:] + + # If we have less than 3 data points, + if len(self.time_diffs_between_updates) > 2: + sleep_secs = sum(self.time_diffs_between_updates) / len(self.time_diffs_between_updates) + + # At minimum, update every AUTOUPDATE_MAX_SLEEP_SECONDS regardless + # of how often we're seeing new data appear + if sleep_secs > self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS: + sleep_secs = self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS + + logger.info('Sleeping for %0.01f sec' % sleep_secs) + time.sleep(sleep_secs) + + self.autoupdate_invocations += 1 + if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS: + return 0 - if len(time_diffs) > 2: - logger.info('Sleeping 60 seconds after unexpected no index change. (New data might be delayed.)') - time.sleep(60) - continue +class AutoupdateError(RuntimeError): + def __str__(self): + return "%s: %s" % (self.__class__.__name__, super().__str__()) - sleep_secs = AUTOUPDATE_DEFAULT_SLEEP_SECONDS - if AUTOUPDATE_USE_FIXED_SLEEP != 1: - if len(time_diffs) > 10: - time_diffs = time_diffs[1:] +class AutoupdateWarning(RuntimeWarning): + def __str__(self): + return "%s: %s" % (self.__class__.__name__, super().__str__()) +class AutoupdateFailureError(AutoupdateError): + pass - if len(time_diffs) > 2: - sleep_secs = sum(time_diffs) / len(time_diffs) +class AutoupdateFailureWarning(AutoupdateWarning): + pass - if sleep_secs > AUTOUPDATE_MAX_SLEEP_SECONDS: - sleep_secs = AUTOUPDATE_MAX_SLEEP_SECONDS +class AutoupdateNoEventIndexesDetectedError(AutoupdateError): + pass - # Sleep for a rolling average of time between updates - logger.info('Sleeping for %d sec' % sleep_secs) - time.sleep(sleep_secs) +class AutoupdateNoNewDataDetectedError(AutoupdateError): + pass -class AutoupdateFailureException(RuntimeError): +class AutoupdateNoIndexChangeWarning(AutoupdateWarning): pass \ No newline at end of file diff --git a/tconnectsync/secret.py b/tconnectsync/secret.py index 75f61c3..79280b3 100644 --- a/tconnectsync/secret.py +++ b/tconnectsync/secret.py @@ -19,7 +19,7 @@ def get(val, default=None): def get_number(name, default): val = get(name, default) try: - return int(val) + return float(val) except ValueError: print("Error: %s must be a number." % name) print("Current value: %s" % val) @@ -50,9 +50,12 @@ if not get('TIMEZONE_NAME') and get('TZ'): AUTOUPDATE_DEFAULT_SLEEP_SECONDS = get_number('AUTOUPDATE_DEFAULT_SLEEP_SECONDS', '300') # 5 minutes AUTOUPDATE_MAX_SLEEP_SECONDS = get_number('AUTOUPDATE_MAX_SLEEP_SECONDS', '1500') # 25 minutes +AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS = get_number('AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS', '60') # 1 minute AUTOUPDATE_USE_FIXED_SLEEP = get_bool('AUTOUPDATE_USE_FIXED_SLEEP', 'false') -AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '180') # 3 hours -AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false') +AUTOUPDATE_NO_DATA_FAILURE_MINUTES = get_number('AUTOUPDATE_NO_DATA_FAILURE_MINUTES', '180') # 3 hours +AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15 minutes +AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true') +AUTOUPDATE_MAX_LOOP_INVOCATIONS = get_number('AUTOUPDATE_MAX_LOOP_INVOCATIONS', '-1') ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false') SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false') diff --git a/tconnectsync/sync/basal.py b/tconnectsync/sync/basal.py index c68c6ae..432306c 100644 --- a/tconnectsync/sync/basal.py +++ b/tconnectsync/sync/basal.py @@ -69,7 +69,7 @@ def process_ciq_basal_events(data): seconds = (existingTime - unprocessedTime).seconds newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds) - logger.debug("Creating basal event for unprocessed suspension: %s" % newEvent) + logger.debug("Adding basal event for unprocessed suspension: %s" % newEvent) newEvents.append(newEvent) # Any remaining suspensions which have not been processed have not ended, diff --git a/tests/api/fake.py b/tests/api/fake.py index 692e2a5..dd23273 100644 --- a/tests/api/fake.py +++ b/tests/api/fake.py @@ -39,8 +39,11 @@ class AndroidApi(tconnectsync.api.android.AndroidApi): raise NotImplementedError class TConnectApi(tconnectsync.api.TConnectApi): - def __init__(self): - pass + def __init__(self, email=None, password=None): + if email is not None and password is not None: + self.with_credentials = True + else: + self.with_credentials = False _ciq = ControlIQApi() _ws2 = WS2Api() diff --git a/tests/test_autoupdate.py b/tests/test_autoupdate.py new file mode 100644 index 0000000..5447de2 --- /dev/null +++ b/tests/test_autoupdate.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 + +import os +import logging +import time +import unittest +import datetime +import importlib +import contextlib + +from unittest.mock import patch + +from tconnectsync.autoupdate import Autoupdate, AutoupdateFailureError, AutoupdateFailureWarning, AutoupdateNoEventIndexesDetectedError, AutoupdateNoIndexChangeWarning +from tconnectsync.secret import AUTOUPDATE_FAILURE_MINUTES, AUTOUPDATE_NO_DATA_FAILURE_MINUTES, AUTOUPDATE_RESTART_ON_FAILURE, TCONNECT_PASSWORD + +from .api.fake import TConnectApi +from .nightscout_fake import NightscoutApi + +logger = logging.getLogger(__name__) + +def stub(*args, **kwargs): + pass + +@contextlib.contextmanager +def build_mock_logger(): + def fake_error(*args, **kwargs): + logger.error(*args, **kwargs) + + def fake_warn(*args, **kwargs): + logger.warn(*args, **kwargs) + + with patch("tconnectsync.autoupdate.logger.error") as mock_error, patch("tconnectsync.autoupdate.logger.warn") as mock_warn: + mock_error.side_effect = fake_error + mock_warn.side_effect = fake_warn + yield (mock_error, mock_warn) + +def num_instances_of(cls, m): + return sum([isinstance(i[0][0], cls) for i in m.call_args_list]) + +class TestAutoupdate(unittest.TestCase): + maxDiff = None + + # datetimes are unused + start = datetime.datetime(2021, 4, 20, 12, 0) + end = datetime.datetime(2021, 4, 21, 12, 0) + + # each time the returned function is called, it returns the next argument + # until the end is reached, at which point it will repeat the last argument + def fake_last_event_uploaded(self, *indexes): + index = 0 + def fake(*args, **kwargs): + nonlocal index + if index < len(indexes): + index += 1 + data = {'maxPumpEventIndex': indexes[index-1], 'processingStatus': 1} + return data + + return fake + + # each time the returned function is called, it returns the next argument + # until the end is reached, at which point it will repeat the last argument + def fake_process_time_range(self, *returns): + index = 0 + def fake(*args, **kwargs): + nonlocal index + if index < len(returns): + index += 1 + return returns[index - 1] + + return fake + + def build_secrets(self, **kwargs): + secret = importlib.reload(importlib.import_module("tconnectsync.secret")) + class FakeSecret(object): + pass + + fake = FakeSecret() + for k in dir(secret): + setattr(fake, k, getattr(secret, k)) + + for k, v in kwargs.items(): + setattr(fake, k, v) + + return fake + + """process_time_range should always be invoked the first time""" + def test_process_time_range_called_on_start(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=1, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0 + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range: + mock_process_time_range.return_value = 0 + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 0) + + self.assertEqual(mock_process_time_range.call_count, 1) + self.assertEqual(u.autoupdate_invocations, 1) + self.assertEqual(u.last_event_index, 1) + + + """process_time_range should never be called with pretend""" + def test_process_time_range_never_called_with_pretend(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1) + + nightscout = NightscoutApi() + + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=1, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0 + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range: + mock_process_time_range.return_value = 0 + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=True) + self.assertEqual(ret, 0) + + self.assertEqual(mock_process_time_range.call_count, 0) + self.assertEqual(u.autoupdate_invocations, 1) + self.assertEqual(u.last_event_index, 1) + + + """ + If the event index increases without process_time_range detecting new data, + AutoupdateFailureWarning should be raised. + """ + def test_autoupdate_failure_warning_on_index_process_time_range_discrepancy(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=2, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0, + TCONNECT_EMAIL="test@email.com", + TCONNECT_PASSWORD="testpassword" + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn): + mock_process_time_range.side_effect = self.fake_process_time_range(0, 0) + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 0) + + self.assertEqual(mock_process_time_range.call_count, 2) + self.assertEqual(u.autoupdate_invocations, 2) + self.assertEqual(u.last_event_index, 2) + + self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 1) + + """ + On the first attempt, an AutoupdateFailureWarning should never be raised. + """ + def test_autoupdate_no_failure_warning_on_index_process_time_range_discrepancy_first_attempt(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=1, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0 + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn): + mock_process_time_range.side_effect = self.fake_process_time_range(0, 0) + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 0) + + self.assertEqual(mock_process_time_range.call_count, 1) + self.assertEqual(u.autoupdate_invocations, 1) + self.assertEqual(u.last_event_index, 1) + + self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 0) + + + """ + If the event index increases without process_time_range detecting new data + for AUTOUPDATE_FAILURE_MINUTES, an AutoupdateFailureError should be raised. + """ + def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=2, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0, + AUTOUPDATE_FAILURE_MINUTES=0, + AUTOUPDATE_RESTART_ON_FAILURE=False, + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn): + mock_process_time_range.side_effect = self.fake_process_time_range(0, 0) + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 0) + + self.assertEqual(mock_process_time_range.call_count, 2) + self.assertEqual(u.autoupdate_invocations, 2) + self.assertEqual(u.last_event_index, 2) + + self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1) + + """ + If the event index increases without process_time_range detecting new data + for AUTOUPDATE_FAILURE_MINUTES, and AUTOUPDATE_RESTART_ON_FAILURE is true, + then an AutoupdateFailureError should be raised AND 1 should be returned. + """ + def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes_performs_restart(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=3, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0, + AUTOUPDATE_FAILURE_MINUTES=0, + AUTOUPDATE_RESTART_ON_FAILURE=True, + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn): + mock_process_time_range.side_effect = self.fake_process_time_range(0, 0) + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 1) + + self.assertEqual(mock_process_time_range.call_count, 2) + self.assertEqual(u.autoupdate_invocations, 1) # exits before invocations is incremented + self.assertEqual(u.last_event_index, 1) # exits before changed to 2 + + self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1) + + + """Validate state after first successful update""" + def test_state_after_first_successful_update(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=1, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0 + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range: + mock_process_time_range.side_effect = self.fake_process_time_range(1) + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 0) + + self.assertEqual(mock_process_time_range.call_count, 1) + self.assertEqual(u.autoupdate_invocations, 1) + self.assertEqual(u.last_event_index, 1) + self.assertTrue(u.last_event_time == u.last_attempt_time == u.last_successful_process_time_range) + self.assertEqual(len(u.time_diffs_between_updates), 0) + + """Validate sleep occurs for the given fixed length when set""" + def test_sleep_for_fixed_length_when_set(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1) + + nightscout = NightscoutApi() + + sleep_length = 3 # sentinel + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=1, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=sleep_length + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep: + mock_process_time_range.side_effect = self.fake_process_time_range(1) + mock_sleep.side_effect = None + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 0) + + self.assertEqual(mock_process_time_range.call_count, 1) + self.assertEqual(u.autoupdate_invocations, 1) + self.assertEqual(u.last_event_index, 1) + self.assertTrue(mock_sleep.called) + self.assertEqual(mock_sleep.call_args[0], (sleep_length,)) + + + """ + If there is no event index update for AUTOUPDATE_NO_DATA_FAILURE_MINUTES, + then a AutoupdateNoEventIndexesDetectedError should be raised and + a restart should be triggered with AUTOUPDATE_RESTART_ON_FAILURE. + """ + def test_autoupdate_no_event_indexes_detected_error_on_no_index_change_less_than_three_iterations(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=100, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1, + AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2, + AUTOUPDATE_RESTART_ON_FAILURE=True, + AUTOUPDATE_NO_DATA_FAILURE_MINUTES=1/600 # 0.1 second + ) + + # with more than 3 iterations, the sleep iteration will change + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn): + mock_process_time_range.side_effect = self.fake_process_time_range(1, 0) + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 1) + + self.assertEqual(num_instances_of(AutoupdateNoEventIndexesDetectedError, mock_error), 1) + + + """ + If there has been 3 failed attempts since the last time we found new data, + we should sleep for AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS and + a AutoupdateNoIndexChangeWarning should be logged. + """ + def test_autoupdate_no_index_change_warning_on_unexpected_no_index_sleep(self): + tconnect = TConnectApi() + tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1) + + nightscout = NightscoutApi() + + secret = self.build_secrets( + AUTOUPDATE_MAX_LOOP_INVOCATIONS=5, + AUTOUPDATE_USE_FIXED_SLEEP=True, + AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1, + AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2 + ) + + with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep, build_mock_logger() as (mock_error, mock_warn): + mock_process_time_range.side_effect = self.fake_process_time_range(0) + mock_sleep.side_effect = None + + u = Autoupdate(secret) + ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False) + self.assertEqual(ret, 0) + + self.assertEqual(u.autoupdate_invocations, 5) + self.assertEqual(u.last_event_index, 1) + self.assertTrue(mock_sleep.called) + self.assertEqual(len(mock_sleep.call_args_list), 5) + self.assertEqual(mock_sleep.call_args_list[0][0], (0.1,)) + self.assertEqual(mock_sleep.call_args_list[1][0], (0.1,)) + self.assertEqual(mock_sleep.call_args_list[2][0], (0.1,)) + self.assertEqual(mock_sleep.call_args_list[3][0], (0.2,)) + self.assertEqual(mock_sleep.call_args_list[4][0], (0.2,)) + + self.assertEqual(num_instances_of(AutoupdateNoIndexChangeWarning, mock_warn), 2) +