Compare commits

..
21 Commits
Author SHA1 Message Date
James Woglom fb5f89e051 version: bump to 0.9.4 2023-06-11 00:28:18 -04:00
James Woglom 017e8845b9 Warn when default nightscout url or serial are being used 2023-06-11 00:24:42 -04:00
James Woglom 30f1a8cbcd Warn when default username or password is being used 2023-06-11 00:24:42 -04:00
James Woglom 22006f7c0f Bump supported software version and display login error message 2023-06-11 00:24:42 -04:00
James Woglom 166a142de5 Update README.md 2023-02-04 19:04:58 -05:00
jwalbergandJames Woglom b68425c5d3 Fixed typo in batch file instructions 2023-02-04 19:04:58 -05:00
jwalbergandJames Woglom 9ab6906e81 Update Readme.md for non-WSL Windows installation
This works in Windows, as-is. Updated the installation instructions with folder paths and scheduling instructions.
2023-02-04 19:04:58 -05:00
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
22 changed files with 366 additions and 47 deletions
+4 -2
View File
@@ -5,10 +5,12 @@ coverage:
project:
default: false
tconnectsync:
paths: "tconnectsync/"
paths:
- "tconnectsync/"
target: '75%'
threshold: '5%'
tests:
paths: "tests/"
paths:
- "tests/"
target: '95%'
threshold: '5%'
+13 -1
View File
@@ -30,8 +30,12 @@ jobs:
pipenv install --system
- name: Run pipenv check
run: |
# DDoS attacks in wheel and setuptools packages, not relevant
# root certificate store, not relevant
pipenv check \
--ignore 51499 # DDoS attack in wheel package, which is unsupported in python 3.7
--ignore 51499 \
--ignore 52495 \
--ignore 52365
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -41,6 +45,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
+21 -5
View File
@@ -122,9 +122,11 @@ First, ensure that you have **Python 3** with **Pip** installed:
- For CentOS/Rocky Linux 8:
- `sudo dnf install python39-pip`
- `sudo alternatives --set python /usr/bin/python3.9`
* **On Windows:** Install Ubuntu under the [Windows Subsystem for Linux](https://ubuntu.com/wsl).
Open the Ubuntu Terminal, then run `sudo apt install python3 python3-pip`.
Perform the remainder of the steps under the Ubuntu environment.
* **On Windows:**
- **With WSL:** Install Ubuntu under the [Windows Subsystem for Linux](https://ubuntu.com/wsl).
Open the Ubuntu Terminal, then run `sudo apt install python3 python3-pip`.
Perform the remainder of the steps under the Ubuntu environment.
- **Native:** Alternatively, you can run tconnectsync in native Windows with no modifications. However, this is less well-tested (open a GitHub issue if you experience any problems).
Now install the `tconnectsync` package with pip:
@@ -136,7 +138,7 @@ To install into a user environment instead of system-wide for a more contained i
$ pip3 install --user tconnectsync
````
- This will place the tconnectsync binary file at ``/home/<username>/.local/bin/tconnectsync``
- For non-WSL Windows, it will be in ``<PYTHON DIRECTORY>\Lib\site-packages\tconnectsync``
If the pip3 command is not found, run `python3 -m pip install tconnectsync` instead.
@@ -167,7 +169,7 @@ Move the `.env` file you created to the following folder:
* **MacOS:** `/Users/<username>/.config/tconnectsync/.env`
* **Linux:** `$HOME/.config/tconnectsync/.env`
* **Windows:** `$HOME/.config/tconnectsync/.env` (inside WSL)
* **Windows:** `$HOME/.config/tconnectsync/.env` (inside WSL) OR `C:\Users\<username>\.config\tconnectsync` (native Windows)
```
$ tconnectsync --check-login
@@ -402,6 +404,20 @@ An example of a user crontab `crontab -e` if not running system-wide, which runs
You can use one of the same `run.sh` files referenced above, but remove the `--auto-update` flag since you are handling the functionality for running the script periodically yourself.
### For Native Windows
Create a batch file 'tconnectsync.bat' file containing:
```
python "C:\Users\<USERNAME>\AppData\Local\Programs\Python\<PYTHONVERSIONDIRECTORY>\Lib\site-packages\tconnectsync\main.py" --auto-update
```
If `python` does not exist in your path, specify the full path to `python.exe`.
If main.py doesn't exist in `C:\Users\<USERNAME>\AppData\Local\Programs\Python\<PYTHONVERSIONDIRECTORY>\Lib\site-packages\tconnectsync\`, create it to match the copy in this repository.
[Use Windows Task Scheduler](https://www.windowscentral.com/how-create-automated-task-using-task-scheduler-windows-10) to run this batch file on a scheduled basis.
## Tandem APIs
This application utilizes three separate Tandem APIs for obtaining t:connect data, referenced here by the identifying part of their URLs:
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = tconnectsync
version = 0.9.0
version = 0.9.4
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
+14 -3
View File
@@ -18,11 +18,12 @@ try:
TCONNECT_PASSWORD,
NS_URL,
NS_SECRET,
NS_SKIP_TLS_VERIFY
NS_SKIP_TLS_VERIFY,
PUMP_SERIAL_NUMBER
)
from . import secret
except Exception:
print('Unable to read secret.py')
except Exception as e:
print('Unable to read secrets from secret.py', e)
sys.exit(1)
@@ -73,6 +74,16 @@ def main(*args, **kwargs):
if time_end < time_start:
raise Exception('time_start must be before time_end')
if TCONNECT_EMAIL == 'email@email.com':
logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.')
if TCONNECT_PASSWORD == 'password':
logging.warn('NO PASSWORD WAS PROVIDED. Ensure you have set TCONNECT_PASSWORD appropriately.')
if NS_URL == 'https://yournightscouturl/':
logging.warn('NO NIGHTSCOUT URL WAS PROVIDED. Ensure your have set NS_URL appropriately.')
if PUMP_SERIAL_NUMBER == '11111111':
logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.')
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
nightscout = NightscoutApi(NS_URL, NS_SECRET, NS_SKIP_TLS_VERIFY)
+28 -1
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,10 +102,32 @@ 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
super().__init__('%s (HTTP %s)' % (text, status_code), *args, **kwargs)
super().__init__('%s%s' % (text, ' (HTTP %s)' % status_code if status_code else ''), *args, **kwargs)
class ApiLoginException(ApiException):
pass
+19 -3
View File
@@ -5,7 +5,7 @@ import logging
from bs4 import BeautifulSoup
from ..util import timeago
from ..util import timeago, cap_length
from .common import parse_date, base_headers, base_session, ApiException, ApiLoginException
logger = logging.getLogger(__name__)
@@ -14,7 +14,7 @@ class ControlIQApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.14.0.1'
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.15.0.1'
userGuid = None
accessToken = None
@@ -34,12 +34,20 @@ class ControlIQApi:
data = self._build_login_data(email, password, soup)
req = s.post(self.LOGIN_URL, data=data, headers={'Referer': self.LOGIN_URL, **base_headers()}, allow_redirects=False)
# HTTP 200 is reported when credentials are incorrect
if req.status_code == 200:
login_error = self._find_login_error(req.text)
if not login_error:
login_error = 'Check your login credentials.'
raise ApiLoginException(None, 'Error logging in to t:connect: %s' % login_error)
if req.status_code != 302:
raise ApiLoginException(req.status_code, 'Error logging in to t:connect. Check your login credentials.')
raise ApiLoginException(req.status_code, 'Error logging in to t:connect')
fwd = s.post(urllib.parse.urljoin(self.LOGIN_URL, req.headers['Location']), cookies=req.cookies, headers=base_headers())
if fwd.status_code != 200:
logger.warn("Received non-HTTP 200: %s" % req.text)
raise ApiException(fwd.status_code, 'Error retrieving t:connect login cookies.')
self.userGuid = req.cookies['UserGUID']
@@ -79,6 +87,14 @@ class ControlIQApi:
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password)
}
def _find_login_error(self, text):
try:
soup = BeautifulSoup(text, features='lxml')
notice_error = soup.select_one(".notice_error").text.strip()
return notice_error
except Exception:
return None
def needs_relogin(self):
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
+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:
+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
+4 -1
View File
@@ -7,11 +7,13 @@ 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 = [
@@ -19,6 +21,7 @@ ALL_FEATURES = [
BOLUS,
IOB,
PUMP_EVENTS,
PUMP_EVENTS_BASAL_SUSPENSION,
PROFILES
]
+2 -2
View File
@@ -114,8 +114,8 @@ class NightscoutEntry:
@staticmethod
def profile_store(profile: Profile, device_settings: DeviceSettings) -> dict:
return {
# insulin duration in hours
"dia": (profile.insulin_duration_min / 60),
# 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),
+14 -8
View File
@@ -31,7 +31,7 @@ from .sync.pump_events import (
)
from .sync.profile import process_profiles
from .parser.tconnect import TConnectEntry
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS, PROFILES
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__)
@@ -146,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)
@@ -192,5 +195,8 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
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
+8 -5
View File
@@ -35,11 +35,14 @@ def process_bolus_events(bolusdata, cgmEvents=None, source=""):
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.start_time:
logger.warning("Skipping non-completed %s extended bolus data with no start time: %s parsed: %s" % (source, b, parsed))
elif not parsed.bolex_start_time and parsed.start_time:
logger.warning("Setting bolex_start_time to start_time for non-completed %s extended bolus: %s parsed: %s" % (source, b, parsed))
parsed.bolex_start_time = parsed.start_time
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
+4
View File
@@ -88,6 +88,10 @@ def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSett
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)
+6 -1
View File
@@ -28,4 +28,9 @@ def removesuffix(input_string, suffix):
def removeprefix(input_string, prefix):
if prefix and input_string.startswith(prefix):
return input_string[len(prefix):]
return input_string
return input_string
def cap_length(text, maxlen):
if not text or len(text) <= maxlen:
return text
return '%s[...]%s' % (text[:maxlen//2], text[maxlen//-2:])
+54 -1
View File
@@ -111,12 +111,65 @@ class TestControlIQApi(unittest.TestCase):
text=post_callback)
self.assertRaises(ApiLoginException, ciq.login, 'email@email.com', 'password')
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: Check your login credentials.', ciq.login, 'email@email.com', 'password')
self.assertIsNone(ciq.userGuid)
self.assertIsNone(ciq.accessToken)
self.assertIsNone(ciq.accessTokenExpiresAt)
def test_login_invalid_credentials_parsed_message(self):
ciq = ControlIQApi()
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
with requests_mock.Mocker() as m:
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers=base_headers(),
text=self.LOGIN_HTML)
def post_callback(request, context):
context.status_code = 200
return '<html><body><div class="notice_error" id="literalMessage" style="">The email address or password you entered is invalid. Please re-enter and try again.</div></body></html>'
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: The email address or password you entered is invalid. Please re-enter and try again.', ciq.login, 'email@email.com', 'password')
self.assertIsNone(ciq.userGuid)
self.assertIsNone(ciq.accessToken)
self.assertIsNone(ciq.accessTokenExpiresAt)
def test_login_unexpected_http_code(self):
ciq = ControlIQApi()
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
with requests_mock.Mocker() as m:
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers=base_headers(),
text=self.LOGIN_HTML)
def post_callback(request, context):
context.status_code = 500
return '<html><body>...</body></html>'
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect \(HTTP 500\)', ciq.login, 'email@email.com', 'password')
self.assertIsNone(ciq.userGuid)
self.assertIsNone(ciq.accessToken)
self.assertIsNone(ciq.accessTokenExpiresAt)
def fake_get_with_http_code(self, http_code, expected_endpoint, num_times):
tries = 0
def fake_get(endpoint, query):
+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()
+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()
+2 -2
View File
@@ -94,7 +94,7 @@ DEVICE_SETTINGS = DeviceSettings(
)
NS_PROFILE_A = {
"dia": 5.0,
"dia": "5.0",
"carbratio": [
{
"time": "00:00",
@@ -184,7 +184,7 @@ NS_PROFILE_A = {
}
NS_PROFILE_B = {
"dia": 5.0,
"dia": "5.0",
"carbratio": [
{
"time": "00:00",
+3 -3
View File
@@ -8,7 +8,7 @@ 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, PROFILES, PUMP_EVENTS
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
@@ -649,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
count = 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), {
@@ -715,7 +715,7 @@ class TestProcessTimeRange(unittest.TestCase):
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = 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), {