Compare commits

...
7 Commits
9 changed files with 1454 additions and 42 deletions
+35
View File
@@ -2,8 +2,11 @@ name: Publish Docker image
on:
push:
branches: [ master ]
tags:
- 'v*'
pull_request:
branches: [ master ]
workflow_dispatch:
jobs:
push_to_registry:
@@ -21,3 +24,35 @@ jobs:
registry: docker.pkg.github.com
repository: jwoglom/tconnectsync/tconnectsync
tag_with_ref: true
push_to_dockerhub:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: jwoglom/tconnectsync
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = tconnectsync
version = 0.6.5
version = 0.6.6
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
+12
View File
@@ -3,6 +3,7 @@ import logging
from .android import AndroidApi
from .controliq import ControlIQApi
from .ws2 import WS2Api
from .webui import WebUIScraper
logger = logging.getLogger(__name__)
@@ -17,6 +18,7 @@ class TConnectApi:
self._ciq = None
self._ws2 = None
self._android = None
self._webui = None
@property
@@ -52,4 +54,14 @@ class TConnectApi:
self._android = AndroidApi(self.email, self.password)
return self._android
@property
def webui(self):
if self._webui and not self._webui.needs_relogin():
return self._webui
logger.debug("Instantiating new WebUIScraper")
self._webui = WebUIScraper(self.controliq)
return self._webui
+14 -15
View File
@@ -29,6 +29,8 @@ class AndroidApi:
ANDROID_API_USERNAME = base64.b64decode('QzIzMzFDRDYtRDQ1MC00OTVFLTlDMTktNjcyMTUyMzBDODVD').decode()
ANDROID_API_PASSWORD = base64.b64decode('dHo0MzNLVzVRREM5VjdmIXo2QF4ybyZZNlNHR1lo').decode()
ANDROID_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 12; Pixel 4a Build/SP2A.220305.012)'
# These tokens are separate from the "standard" tdcservices API
accessToken = None
accessTokenExpiresAt = None
@@ -51,7 +53,10 @@ class AndroidApi:
'grant_type': 'password',
'scope': self.OAUTH_SCOPES
},
headers={'Content-Type': 'application/x-www-form-urlencoded'},
headers={
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent': self.ANDROID_USER_AGENT
},
auth=requests.auth.HTTPBasicAuth(self.ANDROID_API_USERNAME, self.ANDROID_API_PASSWORD)
)
@@ -82,7 +87,11 @@ class AndroidApi:
return {'Authorization': 'Bearer %s' % self.accessToken}
def _get(self, endpoint, query={}, **kwargs):
r = requests.get(self.BASE_URL + endpoint, query, headers=self.api_headers(), **kwargs)
r = requests.get(self.BASE_URL + endpoint, query, headers={
'User-Agent': self.ANDROID_USER_AGENT,
'Content-Type': 'application/json',
**self.api_headers()
}, **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "Android API HTTP %s response: %s" % (str(r.status_code), r.text))
@@ -134,6 +143,9 @@ class AndroidApi:
# TODO: these methods are used in the web app, not the Android app,
# but support the same auth tokens and are on this domain. They should
# be moved to a new Api class.
# 3/17/2022: the API appears to be more stringently checking scopes,
# and some of these endpoints no longer work with the API token scoped
# to the Android app.
"""
Returns BG and pump threshold values.
@@ -154,16 +166,3 @@ class AndroidApi:
"""
def user_profile(self):
return self.get('cloud/usersettings/api/UserProfile?userId=%s' % self.userId)
"""
Returns therapy events, used by the webui Therapy Timeline.
{'event': [
{'type': 'Basal', 'basalRate': ...},
{'type': 'Bolus', 'standard': ...},
{'type': 'CGM', 'egv': ...}
]}
"""
def therapy_events(self, start_date=None, end_date=None):
startDate = parse_date(start_date)
endDate = parse_date(end_date)
return self.get('tconnect/therapyevents/api/TherapyEvents/%s/%s/false?userId=%s' % (startDate, endDate, self.userId))
+18 -4
View File
@@ -13,7 +13,7 @@ from .common import parse_date, base_headers, ApiException, ApiLoginException
logger = logging.getLogger(__name__)
class ControlIQApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/tconnect/controliq/api/'
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
userGuid = None
@@ -45,6 +45,7 @@ class ControlIQApi:
self.accessToken = req.cookies['accessToken']
self.accessTokenExpiresAt = req.cookies['accessTokenExpiresAt']
logger.info("Logged in to ControlIQApi successfully (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
self.loginSession = s
return True
def _build_login_data(self, email, password, soup):
@@ -106,7 +107,7 @@ class ControlIQApi:
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('therapytimeline/users/%s' % (self.userGuid), {
return self.get('tconnect/controliq/api/therapytimeline/users/%s' % (self.userGuid), {
"startDate": startDate,
"endDate": endDate
})
@@ -122,7 +123,7 @@ class ControlIQApi:
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('summary/users/%s' % (self.userGuid), {
return self.get('tconnect/controliq/api/summary/users/%s' % (self.userGuid), {
"startDate": startDate,
"endDate": endDate
})
@@ -132,4 +133,17 @@ class ControlIQApi:
[{"serialNumber": "11111111", "features": {"controlIQ": {"feature": 1, "dateTimeFirstDetected": "YYYY-MM-DD:THH:MM:SS", "unixTimestamp": 1111111111}}}]
"""
def pumpfeatures(self):
return self.get('pumpfeatures/users/%s' % self.userGuid, {})
return self.get('tconnect/controliq/api/pumpfeatures/users/%s' % self.userGuid, {})
"""
Returns therapy events, used by the webui Therapy Timeline.
{'event': [
{'type': 'Basal', 'basalRate': ...},
{'type': 'Bolus', 'standard': ...},
{'type': 'CGM', 'egv': ...}
]}
"""
def therapy_events(self, start_date=None, end_date=None):
startDate = parse_date(start_date)
endDate = parse_date(end_date)
return self.get('tconnect/therapyevents/api/TherapyEvents/%s/%s/false?userId=%s' % (startDate, endDate, self.userGuid), {})
+242
View File
@@ -0,0 +1,242 @@
import requests
import urllib
import datetime
import arrow
import time
import logging
from bs4 import BeautifulSoup
from .common import base_headers, ApiException
logger = logging.getLogger(__name__)
"""
WebUIScraper contains data that is scraped from the t:connect Web UI and is
not accessible via any known API.
"""
class WebUIScraper:
BASE_URL = "https://tconnect.tandemdiabetes.com/"
def __init__(self, controliq):
self.controliq = controliq
def needs_relogin(self):
return self.controliq.needs_relogin()
def _get(self, endpoint):
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers())
if r.status_code != 200:
raise ApiException(r.status_code, "WebUIScraper HTTP %s response: %s" % (str(r.status_code), r.text))
return r
def get(self, endpoint, tries=0):
try:
return self._get(endpoint)
except ApiException as e:
logger.warning("Received ApiException in WebUIScraper with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
if tries > 0:
raise ApiException(e.status_code, "WebUIScraper HTTP %d on retry #%d: %s", e.status_code, tries, e)
# Trigger automatic re-login, and try again once
if e.status_code == 401:
logger.info("Performing automatic re-login to ControlIQApi after HTTP 401 for ControlIQApi")
self.controliq.accessTokenExpiresAt = time.time()
self.controliq.login(self.controliq._email, self.controliq._password)
return self.get(endpoint, tries=tries+1)
if e.status_code == 500:
return self.get(endpoint, tries=tries+1)
raise e
def strip(self, txt):
# Remove errant whitespace between litearl newlines (and literal  )
sep = '\r\n'
if sep not in txt and '\n' in txt:
sep = '\n'
return ' '.join([i.replace('\xa0',' ').strip() for i in txt.strip().split(sep)])
"""
Returns a mapping between pump/device IDs and information about that device,
including the GUID used for obtaining pump settings.
"""
def my_devices(self):
devices = {}
r = self.get('myaccount/my_devices.aspx')
soup = BeautifulSoup(r.content, features='lxml')
for device in soup.select('#content > div.box'):
device_name = self.strip(device.select_one('.subTitle').text)
def find_label_value(lbl):
label = device.find(text=lbl)
if label:
tds = label.parent.parent.parent.select('td')
if len(tds) > 1:
return self.strip(tds[1].text)
return None
serial_number = find_label_value('Serial #')
model_number = find_label_value('Model #')
status = find_label_value('Status')
settings_span = device.find(text='View Settings')
settings_guid = None
if settings_span:
settings_a = settings_span.parent.parent
settings_guid = settings_a.attrs['href'].split('?guid=')[1]
if serial_number:
devices[serial_number] = {
'name': device_name,
'model_number': model_number,
'status': status,
'guid': settings_guid
}
return devices
"""
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']
"""
def device_settings_from_guid(self, pump_guid):
profiles = []
settings = {}
r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid)
soup = BeautifulSoup(r.content, features='lxml')
settings["upload_date"] = self.strip(soup.select_one('#lblUploadDate').text)
divxml = soup.select_one('#divXML')
divxmlDiv = divxml.findChild('div')
for tbl in divxmlDiv.findChildren('table', recursive=False):
setting_bg = tbl.select_one('.setting_bg')
if setting_bg and self.strip(setting_bg.text) == 'Profile':
profiles.append(self._parse_profile_tbl(tbl))
else:
settings.update(self._parse_settings_tbl(tbl))
return profiles, settings
def _parse_profile_tbl(self, tbl):
profile = {}
profile["title"] = self.strip(tbl.select_one('.setting_title').text)
profile["active"] = bool(tbl.find(text='Active at the time of upload'))
profile["segments"] = []
for tr in tbl.select('tr'):
# Skip header rows
if tr.select_one('.setting_bg'):
continue
if tr.find(text='Start Time'):
continue
tds = tr.select('td')
def is_time_row(td):
txt = self.strip(td.select_one('strong').text)
return " AM" in txt or " PM" in txt or txt in ("Midnight", "Noon")
if len(tds) > 0 and is_time_row(tds[0]):
display_time = self.strip(tds[0].text)
t = display_time
if display_time == "Midnight":
t = "12:00 AM"
elif display_time == "Noon":
t = "12:00 PM"
segment = {
"display_time": display_time,
"time": t,
"basal_rate": self.strip(tds[1].text),
"correction_factor": self.strip(tds[2].text),
"carb_ratio": self.strip(tds[3].text),
"target_bg": self.strip(tds[4].text)
}
profile["segments"].append(segment)
continue
if tr.find(text='Calculated Total Daily Basal'):
profile["calculated_total_daily_basal"] = self.strip(tds[1].text)
continue
# Last row
if tr.find(text='Duration of Insulin:'):
lastrow = self.strip(tr.text)
for part in lastrow.split(' |'):
if len(part) < 1:
continue
key, val = part.split(': ')
key = self.strip(key)
val = self.strip(val)
if key == 'Duration of Insulin':
profile["insulin_duration"] = val
elif key == 'Carbohydrates':
profile["carbohydrates"] = val
return profile
def _parse_settings_tbl(self, tbl):
outer_tr = tbl.select('tr')[2]
settings = {}
def loop(td, subhead):
settings[subhead] = {}
for tr in td.select('.settingstable > tr'):
if not tr.select_one('strong'):
continue
key = self.strip(tr.select_one('strong').text)
tds = tr.select('td')
if len(tds) == 1:
subhead = key
settings[subhead] = {}
continue
val_text = self.strip(tds[1].text)
val = {}
if tds[1].find(text=' - '):
val['value'] = False
elif tds[1].find(text='Off'):
val['value'] = False
val_text = self.strip(val_text.split('Off', 1)[1])
elif tds[1].find(text='On'):
val['value'] = True
val_text = self.strip(val_text.split('On', 1)[1])
val['text'] = val_text
settings[subhead][key] = val
children = outer_tr.findChildren('td', recursive=False)
loop(children[0], 'Alerts')
loop(children[1], 'Pump Settings')
return settings
"""
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):
devices = self.my_devices()
if str(pump_serial) in devices.keys():
dev = devices[str(pump_serial)]
return self.device_settings_from_guid(dev['guid'])
raise RuntimeError('Unable to find pump with serial number: %s. Known devices: %s' % (pump_serial, devices))
+79 -22
View File
@@ -1,10 +1,15 @@
import sys
import time
import arrow
import logging
import pkg_resources
from datetime import datetime
from pprint import pformat
from .nightscout import NightscoutApi
from .parser.nightscout import BASAL_EVENTTYPE, BOLUS_EVENTTYPE
from .parser.tconnect import TConnectEntry
from .sync.basal import process_ciq_basal_events
try:
__version__ = pkg_resources.require("tconnectsync")[0].version
@@ -16,7 +21,7 @@ Attempts to authenticate with each t:connect API,
and returns the output of a sample API call from each.
Also attempts to connect to the Nightscout API.
"""
def check_login(tconnect, time_start, time_end, verbose=False):
def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
errors = 0
loglines = []
@@ -70,36 +75,58 @@ def check_login(tconnect, time_start, time_end, verbose=False):
log("Logging in to t:connect ControlIQ API...")
try:
summary = tconnect.controliq.dashboard_summary(time_start, time_end)
debug("ControlIQ dashboard summary: %s" % summary)
debug("ControlIQ dashboard summary: \n%s" % pformat(summary))
except Exception as e:
log("Error occurred querying ControlIQ API:")
log("Error occurred querying ControlIQ API for dashboard_summary:")
log(e)
errors += 1
log("Querying ControlIQ therapy_timeline...")
lastBasalTime = None
lastBasalDuration = None
try:
tt = tconnect.controliq.therapy_timeline(time_start, time_end)
debug("ControlIQ therapy_timeline: %s" % tt)
debug("ControlIQ therapy_timeline: \n%s" % pformat(tt))
if tt:
processed_tt = process_ciq_basal_events(tt)
debug("ControlIQ processed therapy_timeline: \n%s" % pformat(processed_tt))
if processed_tt:
log("Last ControlIQ processed therapy_timeline event: \n%s" % pformat(processed_tt[-1]))
lastBasalTime = processed_tt[-1]['time']
lastBasalDuration = processed_tt[-1]['duration_mins']
except Exception as e:
log("Error occurred querying ControlIQ therapy_timeline:")
log(e)
errors += 1
log("Querying ControlIQ therapy_events...")
try:
androidevents = tconnect.controliq.therapy_events(time_start, time_end)
debug("controliq therapy_events: \n%s" % pformat(androidevents))
except Exception as e:
log("Error occurred querying ControlIQ therapy_events:")
log(e)
errors += 1
log("-----")
log("Logging in to t:connect WS2 API...")
try:
summary = tconnect.ws2.basaliqtech(time_start, time_end)
debug("WS2 basaliq status: %s" % summary)
debug("WS2 basaliq status: \n%s" % pformat(summary))
except Exception as e:
log("Error occurred querying WS2 API:")
log(e)
errors += 1
log("Querying WS2 therapy_timeline_csv...")
lastReadingTime = None
try:
ttcsv = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
debug("therapy_timeline_csv: %s", ttcsv)
debug("therapy_timeline_csv: \n%s" % pformat(ttcsv))
if ttcsv and "readingData" in ttcsv and len(ttcsv["readingData"]) > 0:
log("Last therapy_timeline_csv reading: \n%s" % pformat(ttcsv["readingData"][-1]))
lastReadingTime = TConnectEntry._datetime_parse(ttcsv["readingData"][-1]['EventDateTime'])
except Exception as e:
log("Error occurred querying WS2 therapy_timeline_csv:")
log(e)
@@ -108,38 +135,31 @@ def check_login(tconnect, time_start, time_end, verbose=False):
log("-----")
log("Logging in to t:connect Android API...")
summary = None
try:
summary = tconnect.android.user_profile()
debug("Android user profile: %s" % summary)
debug("Android user profile: \n%s" % pformat(summary))
event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
debug("Android last uploaded event: %s" % event)
debug("Android last uploaded event: \n%s" % pformat(event))
except Exception as e:
log("Error occurred querying Android API:")
log(e)
errors += 1
log("Querying Android therapy_events...")
try:
androidevents = tconnect.android.therapy_events(time_start, time_end)
debug("android therapy_events: %s" % androidevents)
except Exception as e:
log("Error occurred querying Android therapy_events:")
log(e)
errors += 1
log("-----")
log("Logging in to Nightscout...")
try:
nightscout = NightscoutApi(NS_URL, NS_SECRET)
status = nightscout.api_status()
debug("Nightscout status: %s" % status)
debug("Nightscout status: \n%s" % pformat(status))
last_upload_basal = nightscout.last_uploaded_entry(BASAL_EVENTTYPE)
debug("Nightscout last uploaded basal: %s" % last_upload_basal)
debug("Nightscout last uploaded basal: \n%s" % pformat(last_upload_basal))
last_upload_bolus = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE)
debug("Nightscout last uploaded bolus: %s" % last_upload_bolus)
debug("Nightscout last uploaded bolus: \n%s" % pformat(last_upload_bolus))
except Exception as e:
log("Error occurred querying Nightscout API:")
log(e)
@@ -147,6 +167,15 @@ def check_login(tconnect, time_start, time_end, verbose=False):
log("-----")
def time_ago(t):
return '%s ago' % (arrow.now() - arrow.get(t)) if t else 'n/a'
log("Last basal start time: %s (%s)" % (lastBasalTime, time_ago(lastBasalTime)))
log("Last basal duration: %s" % lastBasalDuration)
log("Last reading time: %s (%s)" % (lastReadingTime, time_ago(lastReadingTime)))
log("-----")
if errors == 0:
log("No API errors returned!")
else:
@@ -154,9 +183,37 @@ def check_login(tconnect, time_start, time_end, verbose=False):
with open('tconnectsync-check-output.log', 'w') as f:
if sanitize:
sanitizedData = {
'TCONNECT_EMAIL': TCONNECT_EMAIL,
'TCONNECT_PASSWORD': TCONNECT_PASSWORD,
'PUMP_SERIAL_NUMBER': PUMP_SERIAL_NUMBER,
'NS_URL': NS_URL,
'NS_SECRET': NS_SECRET
}
if summary:
sanitizedData.update({
'ANDROID_PROFILE_USERID': summary.get('userID'),
'ANDROID_PROFILE_PATIENT_FULLNAME': summary.get('patientFullName'),
'ANDROID_PROFILE_CAREGIVER_FULLNAME': summary.get('caregiverFullName')
})
loglines = [run_sanitize(i, sanitizedData) for i in loglines]
f.writelines(loglines)
print("Created file tconnectsync-check-output.log containing additional debugging information.")
print("For support, you can upload this file to https://github.com/jwoglom/tconnectsync/issues/new")
print("Before uploading, look through the file and remove any sensitive data, such as")
print("Nightscout URL and pump serial number.")
if sanitize:
print("The file -- but NOT the output printed above -- has been sanitized to remove sensitive data.")
print("Please verify and remove any sensitive data, such as your Nightscout URL/secret and pump serial number,")
print("as necessary.")
def run_sanitize(s, sanitizedData):
ret = str(s)
for k, v in sanitizedData.items():
if v and len(str(v)) > 0:
ret = ret.replace(str(v), '[%s]' % k)
return ret
+4
View File
@@ -38,6 +38,10 @@ class AndroidApi(tconnectsync.api.android.AndroidApi):
def _get(self, endpoint, query={}, **kwargs):
raise NotImplementedError
class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
def __init__(self, controliq):
self.controliq = controliq
class TConnectApi(tconnectsync.api.TConnectApi):
def __init__(self, email=None, password=None):
if email is not None and password is not None:
File diff suppressed because it is too large Load Diff