diff --git a/.gitignore b/.gitignore index 3fb21d7..ee407af 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ __pycache__ *.pyo *.swp *.swa - +secret.py \ No newline at end of file diff --git a/Pipfile b/Pipfile index 08ece7b..03d2f15 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,8 @@ verify_ssl = true [packages] requests = "*" bs4 = "*" +arrow = "*" +lxml = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index b09c978..6a06b29 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "dd4ac29a565a87289ea1d1f0daa29d2f7310a1f5799f2877e58d2603e689b706" + "sha256": "9a07b247ba95f523a90ebd0d8530416e24afbb20cc62fd265b09c7361b941232" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,14 @@ ] }, "default": { + "arrow": { + "hashes": [ + "sha256:92aac856ea5175c804f7ccb96aca4d714d936f1c867ba59d747a8096ec30e90a", + "sha256:98184d8dd3e5d30b96c2df4596526f7de679ccb467f358b82b0f686436f3a6b8" + ], + "index": "pypi", + "version": "==0.16.0" + }, "beautifulsoup4": { "hashes": [ "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", @@ -52,6 +60,50 @@ ], "version": "==2.10" }, + "lxml": { + "hashes": [ + "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f", + "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", + "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", + "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", + "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3", + "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", + "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", + "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", + "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1", + "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e", + "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d", + "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20", + "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", + "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", + "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", + "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293", + "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", + "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", + "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", + "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed", + "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", + "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", + "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", + "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304", + "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", + "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", + "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1", + "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", + "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", + "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", + "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee" + ], + "index": "pypi", + "version": "==4.5.2" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "version": "==2.8.1" + }, "requests": { "hashes": [ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", @@ -60,6 +112,13 @@ "index": "pypi", "version": "==2.24.0" }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "version": "==1.15.0" + }, "soupsieve": { "hashes": [ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", diff --git a/api.py b/api.py index 40b9252..f873c9e 100644 --- a/api.py +++ b/api.py @@ -2,16 +2,23 @@ import requests import json import urllib import datetime +import csv from bs4 import BeautifulSoup class TConnectApi: - API_BASE_URL = 'https://tdcservices.tandemdiabetes.com/tconnect/controliq/api/' + CONTROLIQ_BASE_URL = 'https://tdcservices.tandemdiabetes.com/tconnect/controliq/api/' LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f' + THERAPYTIMELINE2CSV_URL = 'https://tconnectws2.tandemdiabetes.com/therapytimeline2csv/%s/%s/%s' + userGuid = None accessToken = None accessTokenExpiresAt = None + def __init__(self, email, password): + if not self.login(email, password): + raise Exception('Unable to authenticate') + def login(self, email, password): with requests.Session() as s: initial = s.get(self.LOGIN_URL) @@ -46,16 +53,54 @@ class TConnectApi: raise Exception('No access token provided') return {'Authorization': 'Bearer %s' % self.accessToken} - def api(self, endpoint, query): - r = requests.get(self.API_BASE_URL + endpoint, query, headers=self.api_headers()) + def controliq_api(self, endpoint, query): + r = requests.get(self.CONTROLIQ_BASE_URL + endpoint, query, headers=self.api_headers()) + if r.status_code != 200: + print("API response:", r.status_code, r.text) return r.json() + + def _parse_date(self, date): + if type(date) == str: + return date + return (date or datetime.datetime.now()).strftime('%m-%d-%Y') def therapy_timeline(self, start=None, end=None): - startDate = start if type(start) == str else (start or datetime.datetime.now()).strftime('%m-%d-%Y') - endDate = end if type(end) == str else (end or datetime.datetime.now()).strftime('%m-%d-%Y') + startDate = self._parse_date(start) + endDate = self._parse_date(end) - return self.api('therapytimeline/users/%s' % (self.userGuid), { + return self.controliq_api('therapytimeline/users/%s' % (self.userGuid), { "startDate": startDate, "endDate": endDate }) + + def _split_empty_sections(self, text): + sections = [[]] + sectionIndex = 0 + for line in text.splitlines(): + if len(line.strip()) > 0: + sections[sectionIndex].append(line) + else: + sections.append([]) + sectionIndex += 1 + + return sections + [None] * (4 - len(sections)) + + def _csv_to_dict(self, rawdata): + data = [] + headers = rawdata[0].split(",") + for row in csv.reader(rawdata[1:]): + data.append({headers[i]: row[i] for i in range(len(row)) if i < len(headers)}) + + return data + + + def therapy_timeline_csv(self, start=None, end=None): + startDate = self._parse_date(start) + endDate = self._parse_date(end) + + r = requests.get(self.THERAPYTIMELINE2CSV_URL % (self.userGuid, startDate, endDate)) + + header, readingData, iobData, bolusData = self._split_empty_sections(r.text) + + return self._csv_to_dict(readingData), self._csv_to_dict(iobData), self._csv_to_dict(bolusData) diff --git a/nightscout.py b/nightscout.py new file mode 100644 index 0000000..15a8216 --- /dev/null +++ b/nightscout.py @@ -0,0 +1,47 @@ +import sys +import requests +import hashlib + +try: + from secret import NS_URL, NS_SECRET, TIMEZONE_NAME +except Exception: + print('Unable to import Nightscout secrets from secret.py') + sys.exit(1) + +ENTERED_BY = "Tandem Pump (tconnectsync)" + +class NightscoutEntry: + @staticmethod + def basal(value, duration_mins, created_at, reason=""): + return { + "eventType": "Temp Basal", + "reason": reason, + "duration": int(round(duration_mins)), + "absolute": float(value), + "created_at": created_at, + "carbs": None, + "insulin": None, + "enteredBy": ENTERED_BY + } + + @staticmethod + def bolus(bolus, carbs, created_at, notes=""): + return { + "eventType": "Meal Bolus", + "created_at": created_at, + "carbs": carbs, + "insulin": bolus, + "notes": notes, + "enteredBy": ENTERED_BY, + } + + + +def upload_nightscout(ns_format): + upload = requests.post(NS_URL + 'api/v1/treatments?api_secret=' + NS_SECRET, json=ns_format, headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'api-secret': hashlib.sha1(NS_SECRET.encode()).hexdigest() + }) + print("Nightscout upload status:", upload.status_code, upload.text) + diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..fdd5276 --- /dev/null +++ b/parser.py @@ -0,0 +1,79 @@ +import sys +import arrow + +try: + from secret import TIMEZONE_NAME +except Exception: + print('Unable to import parser secrets from secret.py') + sys.exit(1) + +class TConnectEntry: + @staticmethod + def _epoch_parse(x): + # data["x"] is an integer epoch timestamp which, when read as an equivalent timestamp + # stored in Pacific time (America/Los_Angeles), contains the user's local time, but + # with the wrong timezone data attached. + # + # For example, data["x"] references UTC timestamp 2020-09-01T13:00:00+00:00, + # which when read in Pacific time is equivalent to 2020-09-01T06:00:00-07:00. + # However, the user's timezone is Eastern time, so the timezone of America/Los_Angeles + # is overwritten with America/New_York, resulting in 2020-09-01T06:00:00-04:00, the + # correct timestamp. + return arrow.get(x, tzinfo="America/Los_Angeles").replace(tzinfo=TIMEZONE_NAME) + + @staticmethod + def parse_basal_entry(data, delivery_type=""): + time = TConnectEntry._epoch_parse(data["x"]) + duration_mins = data["duration"] / 60 + basal_rate = data["y"] + + return { + "time": time.format(), + "delivery_type": delivery_type, + "duration_mins": duration_mins, + "basal_rate": basal_rate, + } + + @staticmethod + def parse_suspension_entry(data): + time = TConnectEntry._epoch_parse(data["x"]) + return { + "time": time.format(), + "continuation": data["continuation"], + "suspendReason": data["suspendReason"], + } + + @staticmethod + def _datetime_parse(date): + return arrow.get(date, tzinfo=TIMEZONE_NAME) + + @staticmethod + def parse_cgm_entry(data): + # EventDateTime is stored in the user's timezone. + return { + "time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(), + "reading": data["Readings (CGM / BGM)"], + "reading_type": data["Description"], + } + + @staticmethod + def parse_iob_entry(data): + # EventDateTime is stored in the user's timezone. + return { + "time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(), + "iob": data["IOB"], + "event_id": data["EventID"], + } + + @staticmethod + def parse_bolus_entry(data): + # RequestDateTime and CompletionDateTime are stored in the user's timezone. + return { + "description": data["Description"], + "completion": data["CompletionStatusDesc"], + "request_time": TConnectEntry._datetime_parse(data["RequestDateTime"]).format(), + "completion_time": TConnectEntry._datetime_parse(data["CompletionDateTime"]).format(), + "insulin": data["InsulinDelivered"], + "carbs": data["CarbSize"], + "user_override": data["UserOverride"], + } \ No newline at end of file diff --git a/sync.py b/sync.py new file mode 100644 index 0000000..203b666 --- /dev/null +++ b/sync.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +import sys +import datetime +import json +import hashlib +import requests +import arrow + +from api import TConnectApi +from parser import TConnectEntry +from nightscout import NightscoutEntry, upload_nightscout + +try: + from secret import TCONNECT_EMAIL, TCONNECT_PASSWORD +except Exception: + print('Unable to import secret.py') + sys.exit(1) + +def process_basal_events(data): + suspensionEvents = {} + for s in data["suspensionDeliveryEvents"]: + entry = TConnectEntry.parse_suspension_entry(s) + suspensionEvents[entry["time"]] = entry + + basalEvents = [] + for b in data["basal"]["tempDeliveryEvents"]: + basalEvents.append(TConnectEntry.parse_basal_entry(b, delivery_type="tempDelivery")) + + for b in data["basal"]["algorithmDeliveryEvents"]: + basalEvents.append(TConnectEntry.parse_basal_entry(b, delivery_type="algorithmDelivery")) + + for b in data["basal"]["profileDeliveryEvents"]: + basalEvents.append(TConnectEntry.parse_basal_entry(b, delivery_type="profileDelivery")) + + basalEvents.sort(key=lambda x: arrow.get(x["time"])) + + for i in basalEvents: + print(i) + if i["time"] in suspensionEvents: + i["suspendReason"] = suspensionEvents[i["time"]]["suspendReason"] + + return basalEvents + +def main(): + now = datetime.datetime.now() + + # tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD) + # data = tconnect.therapy_timeline(now - datetime.timedelta(days=1), now) + # open("basaldata.json","w").write(json.dumps(data)) + + data = json.loads(open("basaldata.json").read()) + basalEvents = process_basal_events(data) + + #csvdata = tconnect.therapy_timeline_csv() + #open("csvdata.json", "w").write(json.dumps(csvdata)) + # csvdata = json.loads(open("csvdata.json").read()) + + # for b in csvdata[1]: + # print(TConnectEntry.parse_iob_entry(b)) + + + + + + + + + + + +if __name__ == '__main__': + main() \ No newline at end of file