Compare commits

..
31 Commits
Author SHA1 Message Date
James Woglom 86b6b28803 bump to v0.9.3 2023-02-04 00:15:12 -05:00
James Woglom 4409b78890 add completed extended bolus test 2023-02-04 00:09:35 -05:00
James Woglom e52cd73549 Fix incomplete extended bolus parsing 2023-02-03 23:40:15 -05:00
James Woglom 10fca69038 ignore unrelated pipenv check 2023-01-25 01:17:41 -05:00
James Woglom 1f95dd99f6 fix AutoupdateNoNewDataDetectedError 2023-01-25 01:13:53 -05:00
James Woglom 1e16b7ad01 bump version to v0.9.2 2023-01-16 19:59:49 -05:00
James Woglom cffee9cdca fix codecov config 2023-01-16 19:58:44 -05:00
James Woglom ee85226c2f fix test failure 2023-01-16 19:54:16 -05:00
James Woglom db7d34e62e flake8 fix 2023-01-16 19:52:22 -05:00
James Woglom 7b17304efc bump v0.9.1 2023-01-16 19:49:47 -05:00
James Woglom d0af9c5c91 Profile sync bugfix and extra validation logic 2023-01-16 19:35:01 -05:00
James Woglom 85303bca82 transparently split large time range calls to ws2 therapytimeline 2023-01-16 19:25:21 -05:00
James Woglom c6820b7a0d update log message in process when in pretend mode 2023-01-16 18:21:21 -05:00
James Woglom 04c5a37b8d separate basal suspension events into own feature to allow skipping WS2 api 2023-01-16 18:06:29 -05:00
James Woglom 31e6737ca7 bump version to 0.9.0 2023-01-16 17:37:13 -05:00
James Woglom b9fae36dda ensure permutations of the same number in different types are considered the same NS profile 2023-01-16 17:35:31 -05:00
James Woglom 1507398eb0 track count in integration tests 2023-01-16 17:12:46 -05:00
James Woglom 4208ffa709 secret bugfix 2023-01-16 17:06:40 -05:00
James Woglom 3b66718e1f update README with profile sync feature 2023-01-16 17:01:04 -05:00
James Woglom d9e49e1fc8 integration tests for profile upload 2023-01-16 16:58:19 -05:00
James Woglom dd9a9ea72c tests for profile synchronization 2023-01-16 16:00:50 -05:00
James Woglom d69a760f5b Translate Tandem profile information to Nightscout format 2023-01-16 14:25:31 -05:00
James Woglom df4e9e4ed0 fix codecov configuration 2023-01-07 01:17:56 -05:00
James Woglom 98ee986941 bump to 0.8.10 2023-01-07 00:57:52 -05:00
James Woglom 3f6a0e007d wider log output in --check-login 2023-01-07 00:56:58 -05:00
James Woglom 48ec97434f log info about failed software version check 2023-01-07 00:51:07 -05:00
James Woglom cf0a88c53a Show full error tracebacks in --check-login 2023-01-07 00:50:56 -05:00
James Woglom a4ab6398f0 bump v0.8.9 2022-12-25 13:47:11 -05:00
James Woglom f67671e1ef handle cancelled extended bolus with no timestamp: fix #73 2022-12-25 13:46:21 -05:00
James Woglom 5e4d81a618 v0.8.8 2022-12-12 22:37:30 -05:00
James Woglom cabbc783c7 catch api exceptions due to workaround with timestamp format in nightscout (#71) 2022-12-12 22:36:46 -05:00
27 changed files with 1482 additions and 86 deletions
+9 -4
View File
@@ -1,11 +1,16 @@
coverage:
status:
patch: no
changes: no
project:
default: false
tconnectsync:
paths: "tconnectsync/"
target: 75%
paths:
- "tconnectsync/"
target: '75%'
threshold: '5%'
tests:
paths: "tests/"
target: 95%
paths:
- "tests/"
target: '95%'
threshold: '5%'
+11 -1
View File
@@ -30,8 +30,10 @@ jobs:
pipenv install --system
- name: Run pipenv check
run: |
# DDoS attacks in wheel and setuptools packages, not relevant
pipenv check \
--ignore 51499 # DDoS attack in wheel package, which is unsupported in python 3.7
--ignore 51499 \
--ignore 52495
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -41,6 +43,14 @@ jobs:
- name: Test with pytest
run: |
pytest
- name: Check codecov configuration
run: |
curl -X POST --data-binary @.codecov.yml https://codecov.io/validate
if [[ "$(curl -s -o /dev/null -w "%{http_code}" -X POST --data-binary @.codecov.yml https://codecov.io/validate)" != "200" ]]; then
echo Error parsing codecov file
exit 1
fi
- name: Generate Coverage Report
run: |
pip install coverage
+3 -1
View File
@@ -37,6 +37,7 @@ Here are a few examples of reasons why you might want to adjust the enabled sync
* If you currently input boluses into Nightscout manually with comments, then you may wish to _disable the `BOLUS` synchronization feature_ so that there are no duplicated boluses in Nightscout.
* If you want to see Sleep and Exercise Mode data appear in Nightscout, then you may with to _enable the `PUMP_EVENTS` synchronization feature_.
* If you want to automatically update your Nightscout insulin profile settings from your pump, then you may want to _enable the `PROFILES` synchronization feature_.
These synchronization features are enabled by default:
@@ -45,6 +46,7 @@ These synchronization features are enabled by default:
The following synchronization features can be optionally enabled:
* `PROFILES`: Insulin profile information, including segments, basal rates, correction factors, carb ratios, and the profile which is active.
* `PUMP_EVENTS`: Events reported by the pump. Includes support for the following:
* Site/Cartridge Change (occurs for both a site change and a cartridge change)
* Empty Cartridge/Pump Shutdown (from my investigation, occurs either when the cartridge runs out of insulin OR you hard-shut off the pump)
@@ -60,7 +62,7 @@ The following synchronization features are under development, [**but are not yet
To specify custom synchronization features, pass the names of the desired features to the `--features` flag, e.g.:
```bash
$ tconnectsync --features BASAL BOLUS PUMP_EVENTS
$ tconnectsync --features BASAL BOLUS PUMP_EVENTS PROFILES
```
If you're using tconnectsync-heroku, see [this section in its README](https://github.com/jwoglom/tconnectsync-heroku#Updating-synchronization-features).
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = tconnectsync
version = 0.8.7
version = 0.9.3
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
+27
View File
@@ -1,6 +1,8 @@
import datetime
from typing import List, Tuple
import requests
import random
import arrow
from tconnectsync import secret
@@ -9,6 +11,9 @@ def parse_date(date):
return date
return (date or datetime.datetime.now()).strftime('%m-%d-%Y')
def parsed_date_to_arrow(date):
return arrow.get(datetime.datetime.strptime(date, '%m-%d-%Y'))
USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36',
@@ -97,6 +102,28 @@ def base_session():
s.request = wrapped_request.__get__(s, requests.Session)
return s
def days_between(start, end) -> int:
diff = arrow.get(end) - arrow.get(start)
return diff.days
# both inclusive
def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[str, str]]:
ranges = []
start = arrow.get(start_a)
end = arrow.get(end_a)
cur_s = start
cur = start
while cur <= end:
if (cur - cur_s).days >= days-1:
ranges.append((cur_s, cur))
cur_s = cur + datetime.timedelta(days=1)
cur += datetime.timedelta(days=1)
if len(ranges) > 0 and (end - ranges[-1][-1]).days > 0:
ranges.append((cur_s, end))
return ranges
class ApiException(Exception):
def __init__(self, status_code, text, *args, **kwargs):
self.status_code = status_code
+7 -1
View File
@@ -58,7 +58,13 @@ class ControlIQApi:
logger.warning("Newer API version than last confirmed working. Saw %s and expected %s" % (version, self.LAST_CONFIRMED_SOFTWARE_VERSION))
logger.warning("If you experience any issues, please report them to https://github.com/jwoglom/tconnectsync")
except Exception:
logger.warning("Unable to find tconnect software version")
logger.warning("Unable to find tconnect software version.")
contents = "<unknown>"
if soup:
contents = "%s" % soup.encode_contents()
if len(contents) > 1000:
contents = "%s[SNIP]%s" % (contents[:500], contents[-500:])
logger.info("BeautifulSoup parsed contents: %s" % contents)
pass
return {
"__LASTFOCUS": "",
+31 -7
View File
@@ -1,4 +1,4 @@
from typing import List
from typing import Dict, List, Tuple
import requests
import urllib
import datetime
@@ -8,7 +8,7 @@ import logging
from bs4 import BeautifulSoup
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment, DeviceSettings
from tconnectsync.util import removesuffix, removeprefix
from tconnectsync.util.constants import MMOLL_TO_MGDL
@@ -70,7 +70,7 @@ class WebUIScraper:
Returns a mapping between pump/device IDs and information about that device,
including the GUID used for obtaining pump settings.
"""
def my_devices(self):
def my_devices(self) -> Dict[str, DeviceSettings]:
devices = {}
r = self.get('myaccount/my_devices.aspx')
soup = BeautifulSoup(r.content, features='lxml')
@@ -108,9 +108,9 @@ class WebUIScraper:
"""
Returns a parsed representation of a pump's settings.
Note that pump_guid is NOT the serial number of the pump, and
should be obtained from my_devices()[str(serial_number)]['guid']
should be obtained from my_devices()[str(serial_number)].guid
"""
def device_settings_from_guid(self, pump_guid: str) -> List[Profile]:
def device_settings_from_guid(self, pump_guid: str) -> Tuple[List[Profile], DeviceSettings]:
profiles = []
settings = {}
r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid)
@@ -126,7 +126,15 @@ class WebUIScraper:
else:
settings.update(self._parse_settings_tbl(tbl))
return profiles, settings
low_bg_threshold, high_bg_threshold = self._extract_bg_thresholds(settings)
dev_settings = DeviceSettings(
low_bg_threshold=low_bg_threshold,
high_bg_threshold=high_bg_threshold,
raw_settings=settings
)
return profiles, dev_settings
def _parse_profile_tbl(self, tbl) -> Profile:
profile = {}
@@ -246,11 +254,27 @@ class WebUIScraper:
return settings
def _extract_bg_thresholds(self, settings):
# Nightscout needs default values
low_bg_threshold = 70
high_bg_threshold = 180
if 'CGM Alerts' in settings:
if 'Low Alert' in settings['CGM Alerts']:
low = settings['CGM Alerts']['Low Alert']
if low['value']:
low_bg_threshold = int(low['text'].split(' mg/dL')[0])
if 'High Alert' in settings['CGM Alerts']:
high = settings['CGM Alerts']['High Alert']
if high['value']:
high_bg_threshold = int(high['text'].split(' mg/dL')[0])
return low_bg_threshold, high_bg_threshold
"""
Wraps a call to my_devices to identify the device GUID from the
given pump serial, and then returns device_settings_from_guid.
"""
def device_settings(self, pump_serial):
def device_settings(self, pump_serial: str) -> Tuple[List[Profile], DeviceSettings]:
devices = self.my_devices()
if str(pump_serial) in devices.keys():
dev = devices[str(pump_serial)]
+26 -1
View File
@@ -5,7 +5,7 @@ import logging
import time
import json
from .common import base_session, parse_date, base_headers, ApiException
from .common import base_session, parse_date, parsed_date_to_arrow, base_headers, days_between, split_days_range, ApiException
logger = logging.getLogger(__name__)
@@ -75,10 +75,34 @@ class WS2Api:
This has its own built-in retry logic because Tandem's frontend serving
the API returns 500s when its backend times out.
"""
MAX_THERAPY_TIMELINE_DAYS = 2
def therapy_timeline_csv(self, start=None, end=None, tries=0):
startDate = parse_date(start)
endDate = parse_date(end)
pStart = parsed_date_to_arrow(startDate)
pEnd = parsed_date_to_arrow(endDate)
if days_between(pStart, pEnd) > self.MAX_THERAPY_TIMELINE_DAYS:
ranges = split_days_range(pStart, pEnd, self.MAX_THERAPY_TIMELINE_DAYS)
logger.debug("Splitting call to therapy_timeline_csv(%s, %s) into: %s", start, end, ranges)
outputs = []
for rng in ranges:
rStart, rEnd = rng
logger.debug("split therapy_timeline_csv(%s, %s)", rStart, rEnd)
output = self.therapy_timeline_csv(rStart, rEnd, tries=tries)
logger.debug("split therapy_timeline_csv(%s, %s) = %s", rStart, rEnd, ["%s: %s items" % (key, len(val)) for key, val in output.items()])
outputs.append(output)
full = {}
for o in outputs:
for key, val in o.items():
if key not in full:
full[key] = val
elif val is not None:
full[key] += val
logger.debug("therapy_timeline_csv merge: %s", ["%s: %s items" % (key, len(val)) for key, val in full.items()])
return full
try:
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), timeout=10)
except ApiException as e:
@@ -92,6 +116,7 @@ class WS2Api:
return self.therapy_timeline_csv(start, end, tries+1)
raise e
logger.debug('req_text: %s', req_text)
sections = self._split_empty_sections(req_text)
readingData = None
+1 -1
View File
@@ -125,7 +125,7 @@ class Autoupdate:
# 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(
"%s: No new data has been detected via the API for %d minutes. " % (datetime.datetime.now(), now - self.last_successful_process_time_range)//60 +
"%s: No new data has been detected via the API for %d minutes. " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60) +
"tconnectsync might not be functioning properly."))
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
+24 -10
View File
@@ -2,9 +2,10 @@ import sys
import time
import arrow
import logging
import traceback
import pkg_resources
from datetime import datetime
from pprint import pformat
from pprint import pformat as pformat_base
from .nightscout import NightscoutApi
from .parser.nightscout import BASAL_EVENTTYPE, BOLUS_EVENTTYPE
@@ -29,6 +30,13 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
print(*args)
loglines.append(" ".join([str(i) for i in args]) + "\n")
def log_err(e):
try:
out = ''.join(list(traceback.TracebackException.from_exception(e).format()))
log(out)
except Exception:
log("could not log exception traceback: {}".format(e))
def debug(*args):
if verbose:
print(*args)
@@ -46,7 +54,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
except ImportError as e:
log("Error: Unable to load config file. Please check your .env file or environment variables")
log(e)
log_err(e)
if not TCONNECT_EMAIL or TCONNECT_EMAIL == 'email@email.com':
log("Error: You have not specified a TCONNECT_EMAIL")
@@ -79,7 +87,9 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
log("tconnect_software_ver: %s" % tconnect.controliq.tconnect_software_ver)
except Exception as e:
log("Error occurred querying ControlIQ API for dashboard_summary:")
log(e)
log_err(e)
if e and "HTTP 404" in str(e):
log("<!> The API returns a 404 error if there is no data for the provided dates (currently %s - %s). Try re-running with an earlier start date using --start-date and --end-date arguments." % (time_start, time_end))
errors += 1
log("Querying ControlIQ therapy_timeline...")
@@ -97,7 +107,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
lastBasalDuration = processed_tt[-1]['duration_mins']
except Exception as e:
log("Error occurred querying ControlIQ therapy_timeline:")
log(e)
log_err(e)
errors += 1
log("Querying ControlIQ therapy_events...")
@@ -106,7 +116,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
debug("controliq therapy_events: \n%s" % pformat(androidevents))
except Exception as e:
log("Error occurred querying ControlIQ therapy_events:")
log(e)
log_err(e)
errors += 1
log("-----")
@@ -119,7 +129,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
ws2_loggedin = True
except Exception as e:
log("Error occurred querying WS2 API. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
log(e)
log_err(e)
errors += 1
lastReadingTime = None
@@ -133,7 +143,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
lastReadingTime = TConnectEntry._datetime_parse(ttcsv["readingData"][-1]['EventDateTime'])
except Exception as e:
log("Error occurred querying WS2 therapy_timeline_csv. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
log(e)
log_err(e)
errors += 1
else:
log("Not able to log in to WS2 API, so skipping therapy_timeline_csv")
@@ -150,7 +160,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
debug("Android last uploaded event: \n%s" % pformat(event))
except Exception as e:
log("Error occurred querying Android API:")
log(e)
log_err(e)
errors += 1
log("-----")
@@ -168,7 +178,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
debug("Nightscout last uploaded bolus: \n%s" % pformat(last_upload_bolus))
except Exception as e:
log("Error occurred querying Nightscout API:")
log(e)
log_err(e)
errors += 1
log("-----")
@@ -222,4 +232,8 @@ def run_sanitize(s, sanitizedData):
for k, v in sanitizedData.items():
if v and len(str(v)) > 0:
ret = ret.replace(str(v), '[%s]' % k)
return ret
return ret
def pformat(*args, **kwargs):
kwargs['width'] = 160
return pformat_base(*args, **kwargs)
+18 -1
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import List, Optional
@dataclass
@@ -25,3 +25,20 @@ class Profile:
calculated_total_daily_basal: float # in units
insulin_duration_min: int
carbs_enabled: bool
def activeProfile(self):
p = self.copy()
p.active = True
return p
def copy(self):
p = replace(self)
p.segments = [replace(s) for s in p.segments]
return p
# Settings stored globally in the pump that are stored per-profile in Nightscout
@dataclass
class DeviceSettings:
low_bg_threshold: int
high_bg_threshold: int
raw_settings: dict
+3
View File
@@ -19,6 +19,9 @@ class TherapyEvent:
self.eventDateTime = json['eventDateTime']
self.sourceRecId = json['sourceRecId']
self.rawJson = json
def __str__(self):
return "%s(%s)" % (self.type, self.rawJson)
class CGMTherapyEvent(TherapyEvent):
eventID = None
+7 -2
View File
@@ -7,17 +7,22 @@ IOB = "IOB"
BOLUS_BG = "BOLUS_BG"
CGM = "CGM"
PUMP_EVENTS = "PUMP_EVENTS"
PUMP_EVENTS_BASAL_SUSPENSION = "PUMP_EVENTS_BASAL_SUSPENSION"
PROFILES = "PROFILES"
DEFAULT_FEATURES = [
BASAL,
BOLUS
BOLUS,
PUMP_EVENTS
]
ALL_FEATURES = [
BASAL,
BOLUS,
IOB,
PUMP_EVENTS
PUMP_EVENTS,
PUMP_EVENTS_BASAL_SUSPENSION,
PROFILES
]
+32 -9
View File
@@ -43,7 +43,7 @@ class NightscoutApi:
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout upload response: %s" % r.text)
raise ApiException(r.status_code, "Nightscout upload %s response: %s" % (r.status_code, r.text))
def delete_entry(self, entity):
r = requests.delete(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json={}, headers={
@@ -52,7 +52,7 @@ class NightscoutApi:
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout delete response: %s" % r.text)
raise ApiException(r.status_code, "Nightscout delete %s response: %s" % (r.status_code, r.text))
def put_entry(self, ns_format, entity):
r = requests.put(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={
@@ -61,7 +61,7 @@ class NightscoutApi:
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout put response: %s" % r.text)
raise ApiException(r.status_code, "Nightscout put %s response: %s" % (r.status_code, r.text))
def last_uploaded_entry(self, eventType, time_start=None, time_end=None):
def internal(t_to_space):
@@ -70,16 +70,25 @@ class NightscoutApi:
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry response: %s" % latest.text)
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
ret = internal(False)
ret = None
try:
ret = internal(False)
except ApiException as e:
logger.warning("last_uploaded_entry with no t_to_space: %s", e)
ret = None
if ret is None and (time_start or time_end):
ret = internal(True)
try:
ret = internal(True)
except ApiException as e:
logger.warning("last_uploaded_entry with t_to_space: %s", e)
ret = None
if ret is not None:
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
return ret
@@ -91,7 +100,7 @@ class NightscoutApi:
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry response: %s" % latest.text)
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
@@ -112,7 +121,7 @@ class NightscoutApi:
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout activity response: %s" % latest.text)
raise ApiException(latest.status_code, "Nightscout activity %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
@@ -135,4 +144,18 @@ class NightscoutApi:
}, verify=self.verify)
if status.status_code != 200:
raise Exception('HTTP error status code (%d) from Nightscout: %s' % (status.status_code, status.text))
return status.json()
return status.json()
"""
Returns information on the currently configured Nightscout profile data store
(contains all profiles in Nightscout under one mongo object).
"""
def current_profile(self, time_start=None, time_end=None):
r = requests.get(urljoin(self.url, 'api/v1/profile/current?api_secret=' + self.secret), json={}, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout current_profile %s response: %s" % (r.status_code, r.text))
return r.json()
+73
View File
@@ -1,5 +1,8 @@
import arrow
from ..domain.device_settings import Profile, DeviceSettings
from ..secret import TIMEZONE_NAME, NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE
ENTERED_BY = "Pump (tconnectsync)"
BASAL_EVENTTYPE = "Temp Basal"
@@ -106,6 +109,76 @@ class NightscoutEntry:
"created_at": created_at,
"enteredBy": ENTERED_BY
}
# Tandem-scraped profile to Nightscout profile store entry
@staticmethod
def profile_store(profile: Profile, device_settings: DeviceSettings) -> dict:
return {
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
"dia": "%s" % (profile.insulin_duration_min / 60),
"carbratio": [
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.carb_ratio
} for segment in profile.segments
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [ # Correction factor
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.correction_factor
} for segment in profile.segments
],
"basal": [
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.basal_rate
} for segment in profile.segments
],
"target_low": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": device_settings.low_bg_threshold
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": device_settings.high_bg_threshold
}
],
"timezone": TIMEZONE_NAME, # tconnectsync settings timezone
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
def tandem_to_ns_time(tandem_time: str) -> str:
numbers, ampm = tandem_time.split(' ')
hr, min = numbers.split(':')
if ampm.lower().strip() == 'am':
return "%02d:%02d" % (int(hr) % 12, int(min))
elif ampm.lower().strip() == 'pm':
return "%02d:%02d" % (12 + (int(hr) % 12), int(min))
raise InvalidTimeException(tandem_time)
def tandem_to_ns_time_seconds(tandem_time: str) -> int:
numbers, ampm = tandem_time.split(' ')
hr, min = numbers.split(':')
if ampm.lower().strip() == 'am':
return 60 * (60 * (int(hr) % 12) + int(min))
elif ampm.lower().strip() == 'pm':
return 60 * (60 * (12 + (int(hr) % 12)) + int(min))
raise InvalidTimeException(tandem_time)
class InvalidBolusTypeException(RuntimeError):
pass
class InvalidTimeException(RuntimeError):
pass
+35 -20
View File
@@ -29,8 +29,9 @@ from .sync.pump_events import (
process_basalsuspension_events,
ns_write_pump_events
)
from .sync.profile import process_profiles
from .parser.tconnect import TConnectEntry
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS, PROFILES, PUMP_EVENTS_BASAL_SUSPENSION
from tconnectsync.sync import basal
logger = logging.getLogger(__name__)
@@ -41,18 +42,21 @@ cycle of synchronizing data within the time range.
If pretend is true, then doesn't actually write data to Nightscout.
"""
def process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
# device in the time range which is queried. Since it launched in early 2020,
# ignore 404's before February.
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqTherapyTimelineData = None
else:
raise e
ciqTherapyTimelineData = None
if BASAL in features or PUMP_EVENTS in features:
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
# device in the time range which is queried. Since it launched in early 2020,
# ignore 404's before February.
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqTherapyTimelineData = None
else:
raise e
csvReadingData = None
csvIobData = None
@@ -142,19 +146,22 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
logger.debug("Writing basal events")
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
if PUMP_EVENTS in features:
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
logger.debug("CIQ activity events: %s" % pumpEvents)
logger.warning("Using WS2 data source for basalsuspension because PUMP_EVENTS is an enabled feature")
if PUMP_EVENTS_BASAL_SUSPENSION in features:
logger.warning("Using WS2 data source for basalsuspension because PUMP_EVENTS_BASAL_SUSPENSION is an enabled feature")
logger.warning("<!!> The WS2 data source is unreliable and may prevent timely synchronization")
ws2BasalSuspension = tconnect.ws2.basalsuspension(time_start, time_end)
bsPumpEvents = process_basalsuspension_events(ws2BasalSuspension)
logger.debug("basalsuspension events: %s" % bsPumpEvents)
pumpEvents += bsPumpEvents
logger.debug("Writing pump basalsuspension events")
added += ns_write_pump_events(nightscout, bsPumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
if PUMP_EVENTS in features:
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
logger.debug("CIQ activity events: %s" % pumpEvents)
logger.debug("Writing pump events")
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
@@ -182,6 +189,14 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
logger.debug("Writing iob events")
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
logger.debug("Finished writing iob events")
if PROFILES in features:
logger.debug("Running profiles feature")
if process_profiles(tconnect, nightscout, pretend=pretend):
added += 1
logger.info("Wrote %d events to Nightscout this process cycle" % added)
if pretend:
logger.info("Would have written %d events to Nightscout this process cycle (in pretend mode)" % added)
else:
logger.info("Wrote %d events to Nightscout this process cycle" % added)
return added
+14
View File
@@ -16,6 +16,14 @@ else:
def get(val, default=None):
return os.environ.get(val, values.get(val, default))
def get_one_of(name, default=None, options=[]):
val = get(name, default)
if val not in options:
print("Error: %s must be one of: %s" % (name, options))
print("Current value: %s" % val)
sys.exit(1)
return val
def get_number(name, default):
val = get(name, default)
try:
@@ -59,6 +67,12 @@ AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true')
AUTOUPDATE_MAX_LOOP_INVOCATIONS = get_number('AUTOUPDATE_MAX_LOOP_INVOCATIONS', '-1')
NIGHTSCOUT_PROFILE_UPLOAD_MODE = get_one_of('NIGHTSCOUT_PROFILE_UPLOAD_MODE', 'add', ['add', 'replace'])
# Default Nightscout profile segment fields which aren't stored by Tandem
NIGHTSCOUT_PROFILE_CARBS_HR_VALUE = get('NIGHTSCOUT_PROFILE_CARBS_HR_VALUE', '20')
NIGHTSCOUT_PROFILE_DELAY_VALUE = get('NIGHTSCOUT_PROFILE_DELAY_VALUE', '20')
ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false')
SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false')
REQUESTS_PROXY = get('REQUESTS_PROXY', '')
+9
View File
@@ -34,6 +34,15 @@ def process_bolus_events(bolusdata, cgmEvents=None, source=""):
else:
logger.warning("Skipping non-completed %s bolus data (was a bolus in progress?): %s parsed: %s" % (source, b, parsed))
continue
if parsed.is_extended_bolus:
if not parsed.bolex_start_time and not parsed.request_time:
logger.warning("Skipping non-completed %s extended bolus data with no request_time: %s parsed: %s" % (source, b, parsed))
elif not parsed.bolex_start_time and parsed.request_time:
logger.warning("Setting bolex_start_time to request_time for non-completed %s extended bolus: %s parsed: %s" % (source, b, parsed))
parsed.bolex_start_time = parsed.request_time
logger.debug("process_bolus_events for incomplete bolus: %s parsed: %s" % (b, parsed))
elif parsed.is_extended_bolus:
logger.debug("process_bolus_events for complete extended bolus: %s parsed: %s" % (b, parsed))
if parsed.bg and cgmEvents:
requested_at = parsed.request_time if not parsed.extended_bolus else parsed.bolex_start_time
+200
View File
@@ -0,0 +1,200 @@
from typing import List, Tuple
import logging
import json
import copy
import arrow
from ..api import TConnectApi
from ..domain.device_settings import Profile, DeviceSettings
from ..parser.nightscout import NightscoutEntry
from ..nightscout import NightscoutApi
from ..secret import PUMP_SERIAL_NUMBER, NIGHTSCOUT_PROFILE_UPLOAD_MODE
logger = logging.getLogger(__name__)
def _get_default_serial_number():
return PUMP_SERIAL_NUMBER
def _get_default_upload_mode():
return NIGHTSCOUT_PROFILE_UPLOAD_MODE
def get_pump_profiles(tconnect: TConnectApi, serial_number: int = None) -> Tuple[List[Profile], DeviceSettings]:
all_devices = tconnect.webui.my_devices()
if serial_number is None:
serial_number = _get_default_serial_number()
if str(serial_number) not in all_devices:
logger.warn("Could not find entry for provided pump serial number in t:connect device list: %s, received: %s", serial_number, all_devices)
return [], None
device = all_devices[str(serial_number)]
logger.info("Getting profile settings for %s", device)
device_profiles, device_settings = tconnect.webui.device_settings_from_guid(device.guid)
logger.debug("device_profiles: %s", device_profiles)
logger.debug("device_settings: %s", device_settings)
logger.info("Found pump profiles: %s", ["%s%s" % (profile.title, " (active)" if profile.active else "") for profile in device_profiles])
return device_profiles, device_settings
"""
Compare pump device and Nightscout profiles, and return a final dictionary of
Nightscout profile objects, with the pump profile settings overriding what is
currently in Nightscout.
ns_profile_obj is the output from NightscoutApi.current_profile() and should be the most
recent profile object in mongo.
Returns the new Nightscout profile and whether it was changed.
"""
def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSettings, ns_profile_obj: dict) -> Tuple[bool, dict]:
device = {profile.title: profile for profile in device_profiles}
ns = ns_profile_obj.get('store', {})
logger.info("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys())
new_ns_profile = copy.deepcopy(ns_profile_obj)
updated_ns_profile = False
missing_profiles_in_ns = set(device.keys()) - set(ns.keys())
for profile_name in missing_profiles_in_ns:
logger.info("Missing %s profile in Nightscout: %s", profile_name, device.get(profile_name))
pump_configured_profile = device[profile_name]
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
logger.info("Will add %s profile to Nightscout: %s", profile_name, ns_translated_profile)
new_ns_profile['store'][profile_name] = ns_translated_profile
updated_ns_profile = True
existent_profiles_in_ns = set(device.keys()) & set(ns.keys())
for profile_name in existent_profiles_in_ns:
logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name)
pump_configured_profile = device[profile_name]
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
ns_configured_profile = ns[profile_name]
logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile)
if nightscout_profiles_identical(ns_configured_profile, ns_translated_profile):
logger.info("Profile %s identical between pump and nightscout", profile_name)
continue
logger.info("Profile %s needs update in nightscout: %s", profile_name, ns_translated_profile)
new_ns_profile['store'][profile_name] = ns_translated_profile
updated_ns_profile = True
current_pump_profile = None
for profile in device_profiles:
if profile.active:
current_pump_profile = profile.title
if not current_pump_profile:
logger.error('No current pump profile, so skipping profile update: device: %s', device_profiles)
return False, ns_profile_obj
current_ns_profile = ns_profile_obj.get('defaultProfile')
if current_pump_profile != current_ns_profile:
logger.info("Current profile changed: pump: %s nightscout: %s", current_pump_profile, current_ns_profile)
new_ns_profile['defaultProfile'] = current_pump_profile
updated_ns_profile = True
if not updated_ns_profile:
logger.info("No Nightscout profile changes")
return False, ns_profile_obj
logger.info("New Nightscout profile object: %s", new_ns_profile)
return True, new_ns_profile
def nightscout_profiles_identical(configured: dict, translated: dict) -> bool:
if configured == translated:
logger.debug("direct dicts equal")
return True
if json.dumps(configured, sort_keys=True, indent=None) == json.dumps(translated, sort_keys=True, indent=None):
logger.debug("initial JSON dump identical")
return True
# convert all JSON values into strings
def map_nested_dicts_modify(ob, func):
for k, v in ob.items():
if isinstance(v, dict):
map_nested_dicts_modify(v, func)
elif isinstance(v, list):
map_nested_lists_modify(v, func)
else:
ob[k] = func(v)
def map_nested_lists_modify(ob, func):
for i in range(len(ob)):
v = ob[i]
if isinstance(v, dict):
map_nested_dicts_modify(v, func)
elif isinstance(v, list):
map_nested_lists_modify(v, func)
else:
ob[i] = func(v)
def to_numeric(x):
if type(x) in [int, float]:
return '%f' % x
try:
return '%f' % float(x)
except (ValueError, TypeError):
return x
convert_func = lambda x: to_numeric(x)
configured_str = json.loads(json.dumps(configured))
map_nested_dicts_modify(configured_str, convert_func)
translated_str = json.loads(json.dumps(translated))
map_nested_dicts_modify(translated_str, convert_func)
if json.dumps(configured_str, sort_keys=True, indent=None) == json.dumps(translated_str, sort_keys=True, indent=None):
logger.debug("map_nested_dicts JSON dump identical")
return True
logger.debug("profiles not identical")
return False
def setup_new_profile(ns_profile: dict) -> dict:
if '_id' in ns_profile:
del ns_profile['_id']
now = arrow.now().isoformat()
ns_profile['startDate'] = now
ns_profile['created_at'] = now
return ns_profile
def process_profiles(tconnect: TConnectApi, nightscout: NightscoutApi, pretend: bool = False, upload_mode: str = None) -> bool:
if not upload_mode:
upload_mode = _get_default_upload_mode()
logger.debug("Checking for differences between pump and nightscout profiles: %s mode", upload_mode)
ns_profile_obj = nightscout.current_profile()
pump_profiles, pump_settings = get_pump_profiles(tconnect)
diff, ns_profile_new = compare_profiles(pump_profiles, pump_settings, ns_profile_obj)
if not diff:
logger.info("Pump and Nightscout profiles up to date")
return False
if upload_mode == 'add':
profile_to_upload = setup_new_profile(ns_profile_new)
logger.info("Adding new Nightscout profiles object: %s", profile_to_upload)
if not pretend:
nightscout.upload_entry(profile_to_upload, entity='profile')
return True
elif upload_mode == 'replace':
logger.info("Replacing new Nightscout profiles object: %s", ns_profile_new)
if not pretend:
nightscout.put_entry(ns_profile_new, entity='profile')
return True
else:
raise RuntimeError('invalid upload_mode: %s' % upload_mode)
+7
View File
@@ -44,6 +44,12 @@ class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
def __init__(self, controliq):
self.controliq = controliq
def my_devices(self):
raise NotImplementedError
def device_settings(self, pump_guid):
raise NotImplementedError
class TConnectApi(tconnectsync.api.TConnectApi):
def __init__(self, email=None, password=None):
if email is not None and password is not None:
@@ -54,3 +60,4 @@ class TConnectApi(tconnectsync.api.TConnectApi):
_ciq = ControlIQApi()
_ws2 = WS2Api()
_android = AndroidApi()
_webui = WebUIScraper(_ciq)
+3 -2
View File
@@ -953,7 +953,6 @@ class TestWebUIScraper(unittest.TestCase):
with requests_mock.Mocker() as m:
m.get('https://tconnect.tandemdiabetes.com/myaccount/DeviceSettings.aspx?guid=00000000-0000-0000-0000-000000000001',
request_headers=base_headers(),
text=self.PUMP_SETTINGS_HTML)
profiles, settings = webui.device_settings_from_guid('00000000-0000-0000-0000-000000000001')
@@ -994,7 +993,7 @@ class TestWebUIScraper(unittest.TestCase):
'carbs_enabled': True
})])
self.assertDictEqual(settings, {
self.assertDictEqual(settings.raw_settings, {
'Alerts': {
'Alert: Auto-Off': {'text': '18 hrs', 'value': True},
'Alert: Low Insulin': {'text': '35 u'}
@@ -1043,6 +1042,8 @@ class TestWebUIScraper(unittest.TestCase):
},
'upload_date': 'Jan 09, 2022'
})
self.assertEqual(settings.low_bg_threshold, 80)
self.assertEqual(settings.high_bg_threshold, 200)
+56 -6
View File
@@ -2,6 +2,7 @@
import unittest
import itertools
import copy
from .fake import WS2Api
@@ -28,7 +29,7 @@ class TestWS2Api(unittest.TestCase):
ws2.get = self.fake_get_with_http_500(2)
self.assertEqual(
ws2.therapy_timeline_csv('2021-04-01', '2021-04-02'),
ws2.therapy_timeline_csv('04-01-2021', '04-02-2021'),
{
"readingData": [],
"iobData": [],
@@ -41,7 +42,7 @@ class TestWS2Api(unittest.TestCase):
ws2.get = self.fake_get_with_http_500(3)
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '2021-04-01', '2021-04-02')
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '04-01-2021', '04-02-2021')
RAW_DATA_HEADER = """Tandem Diabetes Care Inc.
t:connect Therapy Timeline Data Export
@@ -96,12 +97,12 @@ Report Generated On, 4/24/2021 7:50:04 PM
def fake_get(endpoint, **kwargs):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData
ws2.get = fake_get
tt = ws2.therapy_timeline_csv('2021-04-01', '2021-04-02')
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
self.assertDictEqual(tt, self.PARSED_DATA)
@@ -113,7 +114,7 @@ Report Generated On, 4/24/2021 7:50:04 PM
def fake_get(endpoint, **kwargs):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData
ws2.get = fake_get
@@ -122,8 +123,57 @@ Report Generated On, 4/24/2021 7:50:04 PM
for i in itertools.permutations([self.RAW_DATA_HEADER, self.RAW_DATA_CGM, self.RAW_DATA_IOB, self.RAW_DATA_BOLUS], 4):
rawData = "\n".join(i)
tt = ws2.therapy_timeline_csv('2021-04-01', '2021-04-02')
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
self.assertDictEqual(tt, self.PARSED_DATA)
def test_therapy_timeline_csv_split_past_max_days(self):
ws2 = WS2Api()
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def replace_str(raw, one, two):
return raw.replace('04-01-2021', one).replace('04-02-2021', two)
rawData1 = self.RAW_DATA_FULL
rawData2 = replace_str(self.RAW_DATA_FULL, '04-03-2021', '04-04-2021')
rawData3 = replace_str(self.RAW_DATA_FULL, '04-05-2021', '04-06-2021')
rawData4 = replace_str(self.RAW_DATA_FULL, '04-07-2021', '04-07-2021')
def replace_parsed(one, two):
parsedData = copy.deepcopy(self.PARSED_DATA)
for typ in parsedData.keys():
for i in range(len(parsedData[typ])):
for f in parsedData[typ][i].keys():
if 'datetime' in f.lower():
parsedData[typ][i][f] = replace_str(parsedData[typ][i][f], one, two)
return parsedData
parsedData1 = self.PARSED_DATA
parsedData2 = replace_parsed('04-03-2021', '04-04-2021')
parsedData3 = replace_parsed('04-05-2021', '04-06-2021')
parsedData4 = replace_parsed('04-07-2021', '04-07-2021')
fullParsedData = parsedData1
for d in [parsedData2, parsedData3, parsedData4]:
for typ in d.keys():
fullParsedData[typ] += d[typ]
def fake_get(endpoint, **kwargs):
nonlocal rawData1, rawData2, rawData3, rawData4
print('fake_get call %s' % endpoint)
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData1
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-03-2021/04-04-2021?format=csv':
return rawData2
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-05-2021/04-06-2021?format=csv':
return rawData3
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-07-2021/04-07-2021?format=csv':
return rawData4
ws2.get = fake_get
tt = ws2.therapy_timeline_csv('04-01-2021', '04-07-2021')
self.assertDictEqual(tt, fullParsedData)
if __name__ == '__main__':
unittest.main()
+37 -1
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException, tandem_to_ns_time, tandem_to_ns_time_seconds
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
from ..sync.test_profile import DEVICE_PROFILE_A, DEVICE_SETTINGS, NS_PROFILE_A
class TestNightscoutEntry(unittest.TestCase):
maxDiff = None
def test_basal(self):
self.assertEqual(
NightscoutEntry.basal(
@@ -191,6 +194,39 @@ class TestNightscoutEntry(unittest.TestCase):
}
)
def test_profile_store(self):
self.assertEqual(
NightscoutEntry.profile_store(
profile=DEVICE_PROFILE_A,
device_settings=DEVICE_SETTINGS
),
NS_PROFILE_A
)
class TestTandemNightscoutTime(unittest.TestCase):
def test_tandem_to_ns_time(self):
self.assertEqual(tandem_to_ns_time('12:00 AM'), '00:00')
self.assertEqual(tandem_to_ns_time('12:30 AM'), '00:30')
self.assertEqual(tandem_to_ns_time('6:00 AM'), '06:00')
self.assertEqual(tandem_to_ns_time('6:30 AM'), '06:30')
self.assertEqual(tandem_to_ns_time('11:30 AM'), '11:30')
self.assertEqual(tandem_to_ns_time('12:00 PM'), '12:00')
self.assertEqual(tandem_to_ns_time('12:30 PM'), '12:30')
self.assertEqual(tandem_to_ns_time('06:30 PM'), '18:30')
self.assertEqual(tandem_to_ns_time('11:30 PM'), '23:30')
def test_tandem_to_ns_time_seconds(self):
self.assertEqual(tandem_to_ns_time_seconds('12:00 AM'), 0)
self.assertEqual(tandem_to_ns_time_seconds('12:30 AM'), 30*60)
self.assertEqual(tandem_to_ns_time_seconds('6:00 AM'), 6*60*60)
self.assertEqual(tandem_to_ns_time_seconds('6:30 AM'), 6*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('11:30 AM'), 11*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('12:00 PM'), 12*60*60)
self.assertEqual(tandem_to_ns_time_seconds('12:30 PM'), 12*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('06:30 PM'), 12*60*60 + 6*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('11:30 PM'), 12*60*60 + 11*60*60 + 30*60)
if __name__ == '__main__':
unittest.main()
+65
View File
@@ -442,6 +442,71 @@ class TestTConnectEntryBolus(unittest.TestCase):
"bolex_start_time": None
}))
entryExtendedComplete = {
"Type": "Bolus",
"Description": "Extended 50.00%/0.00",
"BG": "131",
"IOB": "5.87",
"BolusRequestID": "3636.000",
"BolusCompletionID": "3636.000",
"CompletionDateTime": "2022-08-09T23:20:04",
"InsulinDelivered": "0.20",
"FoodDelivered": "0.00",
"CorrectionDelivered": "0.00",
"CompletionStatusID": "3",
"CompletionStatusDesc": "Completed",
"BolusIsComplete": "1",
"BolexCompletionID": "16757133",
"BolexSize": "0.20",
"BolexStartDateTime": "2022-08-09T23:20:04",
"BolexCompletionDateTime": "2022-08-09T23:35:03",
"BolexInsulinDelivered": "0.20",
"BolexIOB": "5.7",
"BolexCompletionStatusID": "3.00",
"BolexCompletionStatusDesc": "Completed",
"ExtendedBolusIsComplete": "1",
"EventDateTime": "2022-08-09T23:19:15",
"RequestDateTime": "2022-08-09T23:19:15",
"BolusType": "Carb",
"BolusRequestOptions": "Extended",
"StandardPercent": "50.00",
"Duration": "15",
"CarbSize": "0",
"UserOverride": "1",
"TargetBG": "110",
"CorrectionFactor": "30.00",
"FoodBolusSize": "0.00",
"CorrectionBolusSize": "0.00",
"ActualTotalBolusRequested": "0.40",
"IsQuickBolus": "0",
"EventHistoryReportEventDesc": "0",
"EventHistoryReportDetails": "Food Bolus: 50% Extended 15 mins",
"NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units",
"IndexID": "0",
"Note": "631597"
}
def test_parse_bolus_entry_extended_complete(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryExtendedComplete),
Bolus(**{
"description": "Extended 50.00%/0.00",
"complete": "1",
"completion": "Completed",
"request_time": None,
"completion_time": None,
"insulin": "0.20",
"requested_insulin": "0.40",
"carbs": "0",
"bg": "131",
"user_override": "1",
"extended_bolus": "1",
"bolex_completion_time": "2022-08-09 23:35:03-04:00",
"bolex_start_time": "2022-08-09 23:20:04-04:00"
}))
class TestTConnectEntryReading(unittest.TestCase):
entry1 = {
"DeviceType": "t:slim X2 Insulin Pump",
+18
View File
@@ -172,5 +172,23 @@ class TestBolusSync(unittest.TestCase):
for d in zeroData:
self.assertNotIn(TConnectEntry.parse_bolus_entry(d), bolusEvents)
def test_process_bolus_events_ciq_extended_bolus(self):
stdData = [
TestTConnectEntryBolus.entryExtendedComplete,
]
zeroData = [
]
bolusData = stdData + zeroData
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), len(stdData))
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in stdData
])
for d in zeroData:
self.assertNotIn(TConnectEntry.parse_bolus_entry(d), bolusEvents)
if __name__ == '__main__':
unittest.main()
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
import json
import unittest
from typing import Dict
import copy
from tconnectsync.sync.profile import get_pump_profiles, compare_profiles, nightscout_profiles_identical
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
from tconnectsync.secret import NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE, TIMEZONE_NAME
from ..parser.test_tconnect import TestTConnectEntryReading
DEVICE_PROFILE_A = Profile(
title='A',
active=False,
segments=[
ProfileSegment(
display_time='Midnight',
time='12:00 AM',
basal_rate=0.8,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='6:00 AM',
time='6:00 AM',
basal_rate=1.25,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='11:00 AM',
time='11:00 AM',
basal_rate=1.0,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='Noon',
time='12:00 PM',
basal_rate=0.8,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0)
],
calculated_total_daily_basal=21.65,
insulin_duration_min=300,
carbs_enabled=True
)
DEVICE_PROFILE_B = Profile(
title='B',
active=False,
segments=[
ProfileSegment(
display_time='Midnight',
time='12:00 AM',
basal_rate=0.8,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='6:00 AM',
time='6:00 AM',
basal_rate=1.25,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='11:00 AM',
time='11:00 AM',
basal_rate=1.0,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='Noon',
time='12:00 PM',
basal_rate=0.9,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0)
],
calculated_total_daily_basal=22.85,
insulin_duration_min=300,
carbs_enabled=True
)
DEVICE_SETTINGS = DeviceSettings(
low_bg_threshold=80,
high_bg_threshold=200,
raw_settings={}
)
NS_PROFILE_A = {
"dia": "5.0",
"carbratio": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 6.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 6.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 6.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 6.0
}
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 30.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 30.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 30.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 30.0
}
],
"basal": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 0.8
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 1.25
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 1.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 0.8
}
],
"target_low": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 80
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 200
}
],
"timezone": TIMEZONE_NAME,
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
NS_PROFILE_B = {
"dia": "5.0",
"carbratio": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 12.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 12.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 12.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 12.0
}
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 30.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 30.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 30.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 30.0
}
],
"basal": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 0.8
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 1.25
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 1.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 0.9
}
],
"target_low": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 80
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 200
}
],
"timezone": TIMEZONE_NAME,
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
NS_PROFILE_STORE = {
'A': NS_PROFILE_A,
'B': NS_PROFILE_B
}
def build_ns_profile(profiles: Dict[str, dict], current_profile: str) -> dict:
return copy.deepcopy({
"store": profiles,
"defaultProfile": current_profile,
"startDate": "1970-01-01T00:00:00.000Z",
"mills": 0,
"units": "mg/dl",
})
class TestCompareProfiles(unittest.TestCase):
maxDiff = None
def test_compare_profiles_identical_a(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertFalse(changed)
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_identical_b(self):
pump_profiles = [DEVICE_PROFILE_B.activeProfile()]
ns_profile_obj = build_ns_profile({'B': NS_PROFILE_B}, 'B')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertFalse(changed)
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_extra_profile_in_nightscout_ignored(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A, 'B': NS_PROFILE_B}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertFalse(changed)
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_current_nightscout_profile_changed(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A, 'B': NS_PROFILE_B}, 'B')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['defaultProfile'] = 'A'
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_new_pump_profile_added(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile(), DEVICE_PROFILE_B]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['B'] = new_profiles['store']['B']
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_new_pump_profile_added_and_active(self):
pump_profiles = [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['B'] = new_profiles['store']['B']
ns_profile_obj['defaultProfile'] = 'B'
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_existing_profile_edited_basal(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
pump_profiles[0].segments[0].basal_rate = 0.1
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['A']['basal'][0]['value'] = 0.1
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_existing_profile_edited_new_chunk(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
pump_profiles[0].segments.append(ProfileSegment(
display_time='06:00 PM',
time='06:00 PM',
basal_rate=0.7,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0)
)
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['A']['basal'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 0.7})
ns_profile_obj['store']['A']['carbratio'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 6})
ns_profile_obj['store']['A']['sens'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 30})
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_existing_profile_edited_removed_chunk(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
pump_profiles[0].segments.pop()
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['A']['basal'].pop()
ns_profile_obj['store']['A']['carbratio'].pop()
ns_profile_obj['store']['A']['sens'].pop()
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def ensure_stabilized(self, pump_profiles, new_profiles):
changed, _ = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
new_profiles
)
self.assertFalse(changed, 'did not stabilize')
class TestNightscoutProfilesIdentical(unittest.TestCase):
def test_same_dict(self):
self.assertTrue(nightscout_profiles_identical(NS_PROFILE_A, NS_PROFILE_A))
def test_different_dict(self):
self.assertFalse(nightscout_profiles_identical(NS_PROFILE_A, NS_PROFILE_B))
def test_same_re_jsoned(self):
self.assertTrue(nightscout_profiles_identical(NS_PROFILE_A, json.loads(json.dumps(NS_PROFILE_A))))
def test_same_different_number_types(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = 5
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = 5.0
self.assertTrue(nightscout_profiles_identical(a, b))
def test_same_different_number_strings_1(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = 5
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = '5.0'
self.assertTrue(nightscout_profiles_identical(a, b))
def test_same_different_number_strings_2(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = '5'
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = 5.0
self.assertTrue(nightscout_profiles_identical(a, b))
def test_same_different_number_strings_3(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = '5'
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = '5.000'
self.assertTrue(nightscout_profiles_identical(a, b))
def test_different_numbers(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = 5
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = 5.1
self.assertFalse(nightscout_profiles_identical(a, b))
def test_different_numbers_strings(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = '5'
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = '5.01'
self.assertFalse(nightscout_profiles_identical(a, b))
if __name__ == '__main__':
unittest.main()
+241 -18
View File
@@ -4,17 +4,22 @@ import unittest
import datetime
import pprint
import copy
from unittest.mock import patch
from tconnectsync.process import process_time_range
from tconnectsync.parser.nightscout import EXERCISE_EVENTTYPE, IOB_ACTIVITYTYPE, SLEEP_EVENTTYPE, NightscoutEntry
from tconnectsync.features import BASAL, BOLUS, IOB, PUMP_EVENTS
from tests.domain.test_therapy_event import BOLUS_FULL_EXAMPLES, TestCGMTherapyEvent
from tconnectsync.features import BASAL, BOLUS, IOB, PROFILES, PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION
from tconnectsync.domain.device_settings import Device
from tests.secrets import build_secrets
from tests.sync.test_profile import DEVICE_PROFILE_A, DEVICE_PROFILE_B, DEVICE_SETTINGS, NS_PROFILE_A, NS_PROFILE_B, build_ns_profile
from .api.fake import TConnectApi
from .nightscout_fake import NightscoutApi
from .sync.test_basal import TestBasalSync
from .sync.test_bolus import TestBolusSync
from .sync.test_iob import TestIOBSync
from .domain.test_therapy_event import BOLUS_FULL_EXAMPLES, TestCGMTherapyEvent
class TestProcessTimeRange(unittest.TestCase):
maxDiff = None
@@ -67,7 +72,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 4)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
@@ -79,6 +84,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 4)
"""No data in Nightscout. Nothing should be updated in Nightscout without the BASAL feature."""
def test_basal_data_not_updated_without_feature(self):
@@ -103,11 +109,12 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, IOB])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, IOB])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Two basal entries in Nightscout. Two new basal entries in tconnect."""
def test_partial_ciq_basal_data(self):
@@ -139,7 +146,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
@@ -149,6 +156,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 2)
"""
@@ -184,7 +192,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(nightscout.uploaded_entries, {
@@ -202,6 +210,7 @@ class TestProcessTimeRange(unittest.TestCase):
]
})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 3)
"""No data in Nightscout. Uploads all bolus data from tconnect via the WS2 API."""
def test_new_ciq_bolus_data_from_ws2(self):
@@ -228,7 +237,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), len(bolusData))
@@ -241,6 +250,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 4)
"""No data in Nightscout. Uploads all bolus data from tconnect via CIQ therapy_events."""
def test_new_ciq_bolus_data_from_ciq_therapy_events(self):
@@ -265,7 +275,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), len(BOLUS_FULL_EXAMPLES))
@@ -281,6 +291,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 3)
"""No data in Nightscout. Nothing should be updated in Nightscout without the BOLUS feature."""
def test_bolus_data_not_updated_without_feature(self):
@@ -306,12 +317,13 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, IOB])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, IOB])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""No data in Nightscout. Uploads new iob reading from tconnect."""
def test_new_ciq_iob_data(self):
@@ -338,7 +350,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 1)
@@ -349,6 +361,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""No data in Nightscout. Nothing should be updated in Nightscout without the IOB feature."""
def test_iob_data_not_updated_without_feature(self):
@@ -375,11 +388,12 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, BOLUS])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, BOLUS])
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Existing IOB in Nightscout. Uploads new iob reading and deletes old IOB."""
def test_updates_ciq_iob_data(self):
@@ -414,7 +428,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_activity = fake_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[IOB])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[IOB])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 1)
@@ -427,6 +441,7 @@ class TestProcessTimeRange(unittest.TestCase):
self.assertListEqual(nightscout.deleted_entries, [
"activity/sentinel_existing_iob_id"
])
self.assertEqual(count, 1)
"""No pump activity events in Nightscout. New CIQ activity events."""
def test_new_ciq_activity_events(self):
@@ -466,7 +481,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
@@ -476,6 +491,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 2)
"""No pump activity events in Nightscout. New CIQ activity events, but feature is disabled."""
def test_no_ciq_activity_events_without_feature(self):
@@ -516,11 +532,12 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""
@@ -578,7 +595,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
@@ -593,6 +610,7 @@ class TestProcessTimeRange(unittest.TestCase):
self.assertListEqual(nightscout.deleted_entries, [
"treatments/old_sleep"
])
self.assertEqual(count, 1)
"""No pump activity events in Nightscout. New WS2 activity events."""
def test_new_ws2_activity_events(self):
@@ -631,7 +649,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 3)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
@@ -642,6 +660,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 3)
"""Existing pump activity events in Nightscout. New WS2 activity events. Only adds new events."""
def test_existing_ws2_activity_events(self):
@@ -696,7 +715,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
@@ -705,6 +724,7 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""No pump activity events in nightscout. New WS2 activity events, but only of skipped types. None should be added."""
def test_skipped_ws2_activity_events(self):
@@ -744,11 +764,214 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Profile present on pump and not in Nightscout with PROFILES feature enabled, adds profile."""
def test_pump_profile_added(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({}, '')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'add'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1)
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""Profile present on pump and in Nightscout with PROFILES feature enabled, does not add profile."""
def test_pump_profile_not_updated(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'add'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump, adds new profile object."""
def test_pump_profile_new_entry_added(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'add'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1)
self.assertEqual(len(nightscout.uploaded_entries["profile"][0]['store']), 2)
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A)
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['B'], NS_PROFILE_B)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""
Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump,
replaces existing profile object with NIGHTSCOUT_PROFILE_UPLOAD_MODE=replace.
"""
def test_pump_profile_new_entry_replaced(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'replace'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertDictEqual(nightscout.uploaded_entries, {})
self.assertEqual(len(nightscout.put_entries["profile"]), 1)
self.assertEqual(len(nightscout.put_entries["profile"][0]['store']), 2)
self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['A'], NS_PROFILE_A)
self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['B'], NS_PROFILE_B)
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
if __name__ == '__main__':