mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-27 10:13:39 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90bbd5dc63 | ||
|
|
5e56b41b62 | ||
|
|
e8fa6c5a7c | ||
|
|
fae1d4de2c |
@@ -4,6 +4,7 @@ on:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
@@ -21,3 +22,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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user