mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-24 03:34:12 -05:00
autoupdate: refactor into class and write test
This commit is contained in:
Generated
+6
-6
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+163
-58
@@ -2,39 +2,44 @@ 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):
|
||||
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 = []
|
||||
|
||||
"""
|
||||
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.
|
||||
|
||||
last_event_index = None
|
||||
last_event_time = None
|
||||
last_process_time_range = None
|
||||
time_diffs = []
|
||||
self.autoupdate_start = time.time()
|
||||
|
||||
while True:
|
||||
last_event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
|
||||
if not last_event_index or last_event['maxPumpEventIndex'] > last_event_index:
|
||||
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')
|
||||
@@ -42,60 +47,160 @@ def process_auto_update(tconnect, nightscout, time_start, time_end, pretend, fea
|
||||
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.')
|
||||
# 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:
|
||||
|
||||
logger.info('Resetting TConnectApi')
|
||||
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
|
||||
# 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:
|
||||
last_process_time_range = now
|
||||
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
|
||||
# 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'])
|
||||
now = time.time()
|
||||
|
||||
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 +
|
||||
# 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 +
|
||||
# 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
|
||||
|
||||
if len(time_diffs) > 2:
|
||||
logger.info('Sleeping 60 seconds after unexpected no index change. (New data might be delayed.)')
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
sleep_secs = AUTOUPDATE_DEFAULT_SLEEP_SECONDS
|
||||
if AUTOUPDATE_USE_FIXED_SLEEP != 1:
|
||||
if len(time_diffs) > 10:
|
||||
time_diffs = time_diffs[1:]
|
||||
|
||||
if len(time_diffs) > 2:
|
||||
sleep_secs = sum(time_diffs) / len(time_diffs)
|
||||
|
||||
if sleep_secs > AUTOUPDATE_MAX_SLEEP_SECONDS:
|
||||
sleep_secs = AUTOUPDATE_MAX_SLEEP_SECONDS
|
||||
sleep_secs = self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS
|
||||
|
||||
# Sleep for a rolling average of time between updates
|
||||
logger.info('Sleeping for %d sec' % sleep_secs)
|
||||
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)
|
||||
|
||||
class AutoupdateFailureException(RuntimeError):
|
||||
self.autoupdate_invocations += 1
|
||||
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
|
||||
return 0
|
||||
|
||||
|
||||
class AutoupdateError(RuntimeError):
|
||||
def __str__(self):
|
||||
return "%s: %s" % (self.__class__.__name__, super().__str__())
|
||||
|
||||
class AutoupdateWarning(RuntimeWarning):
|
||||
def __str__(self):
|
||||
return "%s: %s" % (self.__class__.__name__, super().__str__())
|
||||
class AutoupdateFailureError(AutoupdateError):
|
||||
pass
|
||||
|
||||
class AutoupdateFailureWarning(AutoupdateWarning):
|
||||
pass
|
||||
|
||||
class AutoupdateNoEventIndexesDetectedError(AutoupdateError):
|
||||
pass
|
||||
|
||||
class AutoupdateNoNewDataDetectedError(AutoupdateError):
|
||||
pass
|
||||
|
||||
class AutoupdateNoIndexChangeWarning(AutoupdateWarning):
|
||||
pass
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-2
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user