mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-28 05:34:10 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fe131bcf3 | ||
|
|
3260442893 | ||
|
|
e3542bc002 | ||
|
|
fb5f89e051 | ||
|
|
017e8845b9 | ||
|
|
30f1a8cbcd | ||
|
|
22006f7c0f | ||
|
|
166a142de5 | ||
|
|
b68425c5d3 | ||
|
|
9ab6906e81 | ||
|
|
86b6b28803 | ||
|
|
4409b78890 | ||
|
|
e52cd73549 | ||
|
|
10fca69038 | ||
|
|
1f95dd99f6 | ||
|
|
1e16b7ad01 | ||
|
|
cffee9cdca | ||
|
|
ee85226c2f | ||
|
|
db7d34e62e | ||
|
|
7b17304efc | ||
|
|
d0af9c5c91 | ||
|
|
85303bca82 | ||
|
|
c6820b7a0d | ||
|
|
04c5a37b8d |
+4
-2
@@ -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%'
|
||||
@@ -30,8 +30,14 @@ 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 \
|
||||
--ignore 59956 \
|
||||
--ignore 58755
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
@@ -41,6 +47,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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.9.0
|
||||
version = 0.9.5
|
||||
author = James Woglom
|
||||
author_email = j@wogloms.net
|
||||
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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.16.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)
|
||||
|
||||
@@ -30,10 +30,14 @@ class WebUIScraper:
|
||||
return self.controliq.needs_relogin()
|
||||
|
||||
def _get(self, endpoint):
|
||||
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers())
|
||||
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers(), allow_redirects=True)
|
||||
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "WebUIScraper HTTP %s response: %s" % (str(r.status_code), r.text))
|
||||
|
||||
if 'login.aspx' in r.url:
|
||||
raise ApiException(401, "WebUIScraper HTTP %s response for login page, returning 401: %s" % (str(r.status_code), r.url))
|
||||
|
||||
return r
|
||||
|
||||
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:])
|
||||
@@ -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):
|
||||
|
||||
+59
-1
@@ -18,7 +18,7 @@ from .fake import ControlIQApi
|
||||
from tconnectsync.api.controliq import ControlIQApi as RealControlIQApi
|
||||
from tconnectsync.api.common import ApiException, ApiLoginException, base_headers
|
||||
|
||||
class TestWebUIScraper(unittest.TestCase):
|
||||
class TestWebUIScraperMyDevices(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
DEVICES_HTML = """
|
||||
@@ -319,6 +319,64 @@ class TestWebUIScraper(unittest.TestCase):
|
||||
'guid': None
|
||||
})})
|
||||
|
||||
REDIRECT_HTML = """<html><head><title>Object moved</title></head><body>
|
||||
<h2>Object moved to <a href="/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx">here</a>.</h2>
|
||||
</body></html>"""
|
||||
|
||||
LOGIN_HTML = """<html></html>"""
|
||||
|
||||
def test_relogin_when_redirected_to_login_page(self):
|
||||
ciq = ControlIQApi()
|
||||
ciq.needs_relogin = lambda: False
|
||||
ciq.loginSession = requests.Session()
|
||||
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
|
||||
ciq._email = 'EMAIL'
|
||||
ciq._password = 'PASSWORD'
|
||||
|
||||
login_times = []
|
||||
def fake_login(email, password):
|
||||
self.assertEqual(email, ciq._email)
|
||||
self.assertEqual(password, ciq._password)
|
||||
|
||||
login_times.append(1)
|
||||
return True
|
||||
|
||||
ciq.login = fake_login
|
||||
webui = WebUIScraper(ciq)
|
||||
|
||||
ciq.login(ciq._email, ciq._password)
|
||||
self.assertEqual(len(login_times), 1)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/myaccount/my_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
status_code=302,
|
||||
headers={'Location': 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx'},
|
||||
text=self.REDIRECT_HTML)
|
||||
|
||||
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
status_code=200,
|
||||
text=self.LOGIN_HTML)
|
||||
|
||||
self.assertRaises(ApiException, webui.my_devices)
|
||||
self.assertEqual(len(login_times), 2)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/myaccount/my_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
text=self.DEVICES_HTML)
|
||||
|
||||
devices = webui.my_devices()
|
||||
self.assertEqual(len(login_times), 2)
|
||||
|
||||
|
||||
class TestWebUIScraperPumpSettings(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
PUMP_SETTINGS_HTML = """
|
||||
<!DOCTYPE html
|
||||
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
|
||||
+56
-6
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user