Compare commits

...
48 Commits
Author SHA1 Message Date
James Woglom 25b8dee9d8 bugfix: autoupdate 2021-05-04 02:03:26 -04:00
James Woglom f9952acaed bug: fix string format 2021-05-04 02:00:07 -04:00
James Woglom dc32812ede parser/nightscout: add rate field for temp basals for autotune compatibility (fixes #7) 2021-05-03 23:57:58 -04:00
James Woglom 73757801f8 tests/controliq: fix fake access token expiration in tests. remove warns for warnings 2021-05-03 23:48:26 -04:00
James Woglom 1d937114c7 autoupdate: add new config options if no data received, log pretty timestamps 2021-05-03 23:20:23 -04:00
James Woglom ae7f07e288 logging: use logger instead of prints, improve logging throughout 2021-05-03 22:28:18 -04:00
James Woglom 87fee28419 add .codecov.yml 2021-04-26 23:15:18 -04:00
James Woglom c03b89d707 add .codecov.yml 2021-04-26 23:15:18 -04:00
James Woglom a78d716990 Add codecov badge 2021-04-26 23:15:18 -04:00
James Woglom 8e46103433 Add codecov 2021-04-26 23:15:18 -04:00
James Woglom bb7fc4b153 api/android: add tests for last_event_uploaded and retry behavior 2021-04-26 23:07:15 -04:00
James Woglom 99c68d86a6 api/controliq: add login tests 2021-04-26 22:48:07 -04:00
James Woglom a91631f3a0 api/controliq: retry/relogin on http errors, add tests 2021-04-26 21:12:03 -04:00
James Woglom cd092bb2cc tests/api: test ws2 therapy_timeline_csv parsing 2021-04-24 23:12:53 -04:00
James Woglom b4267560b4 tests: add ws2 api tests for retries 2021-04-24 23:12:53 -04:00
James Woglom df9e87c7e3 api/ws2: add retry logic for http 500s in therapy_timeline_csv (fix #9) 2021-04-24 22:23:41 -04:00
James Woglom a3c7aa69a9 tests: add bolus parsing tests 2021-04-21 01:38:53 -04:00
James Woglom 093a7f0627 tests: add bolus tests 2021-04-21 01:27:13 -04:00
James Woglom e1921ccac7 tests/api: override needs_relogin in ControlIQApi 2021-04-21 00:53:58 -04:00
James Woglom 52f7972153 api/controliq: check access token expiration time 2021-04-21 00:51:27 -04:00
James Woglom 2130099f70 sync/basal: fix duplicate upload error uncovered by tests 2021-04-21 00:45:36 -04:00
James Woglom 4c6e05b2c1 tests: add Nightscout fake, tests for basal processing 2021-04-21 00:44:50 -04:00
James Woglom cda01894dc add basic fake for tconnect api classes 2021-04-20 00:52:11 -04:00
James Woglom 7a18a5d4be run CI on develop branch 2021-04-20 00:27:52 -04:00
James Woglom 4b8ddd0cec nightscout.py: move methods into class, initialize NightscoutApi in main
Changes all code which interacts with the nightscout api to
invoke it via a NightscoutApi object which is passed to it
and defined in the main, rather than having utility methods
in nightscout.py which rely on the global secret file state
of nightscout url/secret.
2021-04-20 00:26:18 -04:00
James Woglom 1efe532a2f tests: add parser tests for nightscout 2021-04-20 00:07:09 -04:00
James Woglom 8dcbee4280 bugfix: use access token expire date in android api
Before an instance of the android api is invoked via the TConnectApi
wrapper, the needs_relogin method is called to see whether the
current access token has expired. That check was using the refresh
token expiration date instead of the access token expiration date.
(The refresh tokens in the android api are currently unused.)
2021-04-19 23:53:55 -04:00
James Woglom c7aac1dca3 update pipfile.lock: fix CVE-2021-28957 2021-04-06 13:18:19 -04:00
James WoglomandGitHub 739c01f876 Merge pull request #4 from LegendaryGeek/master
Update README.md to correct the docker run commands
2021-04-03 23:30:19 -04:00
James WoglomandGitHub 8ce8b42bfc Update README.md 2021-04-03 23:26:38 -04:00
James WoglomandGitHub c14298384a Update README.md - clarify folder for .env 2021-04-03 23:21:54 -04:00
LegendaryGeekandGitHub 84067015e3 Update README.md to correct the docker run commands 2021-04-03 18:08:20 -04:00
James Woglom 0b7e806a40 check: add --check-login option, which checks that the APIs can be queried 2021-03-22 22:38:07 -04:00
James Woglom 89b40dcecb nightscout: use urljoin so trailing backslash is not required in URL. add api status endpoint 2021-03-22 22:37:32 -04:00
James Woglom 539516aeae api: add API endpoints for checking general status (basaliq, controliq) 2021-03-22 22:37:05 -04:00
James Woglom 332f54e85e api: add some unused api endpoints from reverse engineering 2021-03-22 21:30:57 -04:00
James Woglom 19f4f9363a autoupdate: add custom env options to control update rate
Also updates secret.py to use simpler code for parsing numbers
2021-03-22 01:27:27 -04:00
James Woglom d804510633 .github/workflows: update workflow, add readme badge 2021-03-18 02:17:44 -04:00
James WoglomandGitHub 713d2ee79e Create python-package.yml
Auto-run linter, pytest, and pipenv check
2021-03-18 02:11:54 -04:00
James Woglom e0bfab8759 tests/parser: add start of TConnectEntry test 2021-03-18 02:07:54 -04:00
James Woglom 209292dd21 tests/sync: complete basal ciq process test 2021-03-18 02:07:39 -04:00
James Woglom 1a9fb36b88 refactor: move tconnect and nightscout API parsers into subpackage 2021-03-18 01:53:03 -04:00
James Woglom 2e0120fdfc tests: add first test for basal processing 2021-03-18 01:42:44 -04:00
James Woglom 3911da7d37 refactor: update file links in readme 2021-03-18 01:10:51 -04:00
James Woglom 7cfd486ac5 refactor: move auto-update code to separate file 2021-03-18 01:04:24 -04:00
James Woglom 8c6719c6a6 refactor: fix secret imports, move to pkg folder 2021-03-18 01:04:24 -04:00
James Woglom d00d8e59dd refactor: move process and synchronization code outside of main file
Splits up basal, bolus, and iob parsing code into
separate files in the sync folder. Moves the single-
cycle processing code into process.py
2021-03-18 01:04:24 -04:00
James Woglom 63ca6eda43 refactor: move into a standardized python packaging format
In the current directory structure, py files inside of
a folder cannot import files inside a different folder.
2021-03-18 00:34:28 -04:00
43 changed files with 2326 additions and 637 deletions
+11
View File
@@ -0,0 +1,11 @@
status:
patch: no
changes: no
project:
default: false
tconnectsync:
paths: "tconnectsync/"
target: 75%
tests:
paths: "tests/"
target: 95%
+50
View File
@@ -0,0 +1,50 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python package
on:
push:
branches: [ master, develop ]
pull_request:
branches: [ master, develop ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest pipenv
pipenv install --system
- name: Run pipenv check
run: |
pipenv check
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
- name: Generate Coverage Report
run: |
pip install coverage
coverage run -m unittest
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true
+3 -1
View File
@@ -11,6 +11,8 @@ bs4 = "*"
arrow = "*"
lxml = "*"
python-dotenv = "*"
requests-mock = "*"
[scripts]
tconnectsync = "python3 main.py"
tconnectsync = "python3 main.py"
test = "python3 -m unittest discover -vv"
Generated
+55 -48
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "071afba23de7f532f99a0611caed63951c6f7ba968ba877ad5c76c244e78577e"
"sha256": "979feca3aba1b7f43890a94862347738158af5847959c14dc1d5f23ce5db8cdc"
},
"pipfile-spec": 6,
"requires": {},
@@ -62,46 +62,45 @@
},
"lxml": {
"hashes": [
"sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d",
"sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37",
"sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01",
"sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2",
"sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644",
"sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75",
"sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80",
"sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2",
"sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780",
"sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98",
"sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308",
"sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf",
"sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388",
"sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d",
"sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3",
"sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8",
"sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af",
"sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2",
"sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e",
"sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939",
"sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03",
"sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d",
"sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a",
"sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5",
"sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a",
"sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711",
"sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf",
"sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089",
"sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505",
"sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b",
"sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f",
"sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc",
"sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e",
"sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931",
"sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc",
"sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe",
"sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e"
"sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d",
"sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3",
"sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2",
"sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f",
"sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927",
"sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3",
"sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7",
"sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f",
"sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade",
"sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468",
"sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b",
"sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4",
"sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83",
"sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04",
"sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791",
"sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51",
"sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1",
"sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a",
"sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f",
"sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee",
"sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec",
"sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969",
"sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28",
"sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a",
"sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa",
"sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106",
"sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d",
"sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4",
"sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0",
"sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4",
"sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2",
"sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0",
"sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654",
"sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2",
"sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23",
"sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"
],
"index": "pypi",
"version": "==4.6.2"
"version": "==4.6.3"
},
"python-dateutil": {
"hashes": [
@@ -113,11 +112,11 @@
},
"python-dotenv": {
"hashes": [
"sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e",
"sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0"
"sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a",
"sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"
],
"index": "pypi",
"version": "==0.15.0"
"version": "==0.17.0"
},
"requests": {
"hashes": [
@@ -127,6 +126,14 @@
"index": "pypi",
"version": "==2.25.1"
},
"requests-mock": {
"hashes": [
"sha256:11215c6f4df72702aa357f205cf1e537cffd7392b3e787b58239bde5fb3db53b",
"sha256:e68f46844e4cee9d447150343c9ae875f99fa8037c6dcf5f15bf1fe9ab43d226"
],
"index": "pypi",
"version": "==1.8.0"
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
@@ -137,19 +144,19 @@
},
"soupsieve": {
"hashes": [
"sha256:407fa1e8eb3458d1b5614df51d9651a1180ea5fedf07feb46e45d7e25e6d6cdd",
"sha256:d3a5ea5b350423f47d07639f74475afedad48cf41c0ad7a82ca13a3928af34f6"
"sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc",
"sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"
],
"markers": "python_version >= '3.0'",
"version": "==2.2"
"version": "==2.2.1"
},
"urllib3": {
"hashes": [
"sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80",
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
"sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
"sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.3"
"version": "==1.26.4"
}
},
"develop": {}
+13 -8
View File
@@ -1,5 +1,8 @@
# tconnectsync
![Python Package workflow](https://github.com/jwoglom/tconnectsync/actions/workflows/python-package.yml/badge.svg)
[![codecov](https://codecov.io/gh/jwoglom/tconnectsync/branch/master/graph/badge.svg)](https://codecov.io/gh/jwoglom/tconnectsync)
Tconnectsync synchronizes data one-way from the Tandem Diabetes t:connect web/mobile application to Nightscout.
If you have a t:slim X2 pump with the companion t:connect mobile Android or iOS app, this will allow your pump bolus and basal data to be uploaded to [Nightscout](https://github.com/nightscout/cgm-remote-monitor) automatically. The t:connect Android app, by default, uploads pump data to Tandem's servers every hour, [but using this tool you can update the frequency to as low as every five minutes](https://github.com/jwoglom/tconnectpatcher)! This allows for nearly real-time (but not instantaneous) pump data updates, almost like your pump uploads data directly to Nightscout!
@@ -12,13 +15,13 @@ At a high level, tconnectsync works by querying Tandem's undocumented APIs to re
This application utilizes three separate Tandem APIs for obtaining t:connect data, referenced here by the identifying part of their URLs:
* [**controliq**](https://github.com/jwoglom/tconnectsync/blob/master/api/controliq.py) - Contains Control:IQ related data, namely a timeline of all Basal events uploaded by the pump, separated by type (temp basals, algorithmically-updated basals, or profile-updated basals).
* [**android**](https://github.com/jwoglom/tconnectsync/blob/master/api/android.py) - Used internally by the t:connect Android app, these API endpoints were discovered by reverse-engineering the Android app. Most of the API endpoints are used for uploading pump data, and tconnectsync uses one endpoint which returns the most recent event ID uploaded by the pump, so we know when more data has been uploaded.
* [**tconnectws2**](https://github.com/jwoglom/tconnectsync/blob/master/api/ws2.py) - More legacy than the others, this seems to power the bulk of the main t:connect website. It is used to retrieve a CSV export of non-ControlIQ basal data, as well as bolus and IOB data. (I haven't found any mentions of bolus or IOB data in the Control:IQ-specific API.)
* [**controliq**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/controliq.py) - Contains Control:IQ related data, namely a timeline of all Basal events uploaded by the pump, separated by type (temp basals, algorithmically-updated basals, or profile-updated basals).
* [**android**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/android.py) - Used internally by the t:connect Android app, these API endpoints were discovered by reverse-engineering the Android app. Most of the API endpoints are used for uploading pump data, and tconnectsync uses one endpoint which returns the most recent event ID uploaded by the pump, so we know when more data has been uploaded.
* [**tconnectws2**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/ws2.py) - More legacy than the others, this seems to power the bulk of the main t:connect website. It is used to retrieve a CSV export of non-ControlIQ basal data, as well as bolus and IOB data. (I haven't found any mentions of bolus or IOB data in the Control:IQ-specific API.)
## Setup
Create a file named `.env` containing configuration values. You should specify the following parameters:
Create a file named `.env` containing configuration values inside the checked-out tconnectsync folder (the same folder as `main.py`). You should specify the following parameters:
```bash
# Your credentials for t:connect
@@ -36,9 +39,11 @@ NS_SECRET='apisecret'
TIMEZONE_NAME='America/New_York'
```
This file contains your t:connect username and password, Tandem pump serial number (which is utilized in API calls to t:connect), your Nightscout URL and secret token (for uploading data to Nightscout), and local timezone (the timezone used in t:connect).
These values can alternatively be specified via environment variables.
I have only tested tconnectsync with a Tandem pump set in the US Eastern timezone. Tandem's (to us, undocumented) APIs are [a bit loose with timezones](https://github.com/jwoglom/tconnectsync/blob/d841c3811aeff3671d941a7d3ff4b80cce6a219e/parser.py#L16), so please let me know if you notice any timezone-related bugs.
The .env file contains your t:connect username and password, Tandem pump serial number (which is utilized in API calls to t:connect), your Nightscout URL and secret token (for uploading data to Nightscout), and local timezone (the timezone used in t:connect).
I have only tested tconnectsync with a Tandem pump set in the US Eastern timezone. Tandem's (to us, undocumented) APIs are [a bit loose with timezones](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/parser.py#L15), so please let me know if you notice any timezone-related bugs.
When you run the program with no arguments, it performs a single cycle of the following, and exits after completion:
@@ -59,7 +64,7 @@ If run with the `--auto-update` flag, then the application performs the followin
You can run the application using Pipenv. Assuming you have only Python 3 and pip installed, install pipenv with `pip3 install pipenv`. Then install tconnectsync's dependencies with `pipenv install`, and you can launch the program with `pipenv run tconnectsync` (which, through an alias defined in `Pipfile`, runs ``pipenv run python3 main.py`).
```bash
$ git clone https://github.com/jwoglom/tconnectsync
$ git clone https://github.com/jwoglom/tconnectsync && cd tconnectsync
$ pip3 install pipenv
$ pipenv install
$ pipenv run tconnectsync --help
@@ -92,7 +97,7 @@ To download and run the `jwoglom/tconnectsync` prebuilt Docker image from [Docke
```bash
$ docker pull jwoglom/tconnectsync:latest
$ docker run tconnectsync --help
$ docker run jwoglom/tconnectsync --help
```
To instead build the image locally and launch the project:
-86
View File
@@ -1,86 +0,0 @@
import requests
import json
import urllib
import datetime
import csv
import base64
import arrow
from bs4 import BeautifulSoup
from .common import ApiException, ApiLoginException
class AndroidApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
OAUTH_TOKEN_PATH = 'cloud/oauth2/token'
OAUTH_SCOPES = 'cloud.account cloud.upload cloud.accepttcpp cloud.email cloud.password'
# These credentials are found in source code
ANDROID_API_USERNAME = base64.b64decode('QzIzMzFDRDYtRDQ1MC00OTVFLTlDMTktNjcyMTUyMzBDODVD').decode()
ANDROID_API_PASSWORD = base64.b64decode('dHo0MzNLVzVRREM5VjdmIXo2QF4ybyZZNlNHR1lo').decode()
# These tokens are separate from the "standard" tdcservices API
accessToken = None
accessTokenExpiresAt = None
refreshToken = None
refreshTokenExpiresAt = None
userId = None
patientObjectId = None
def __init__(self, email, password):
self.login(email, password)
def login(self, email, password):
r = requests.post(
self.BASE_URL + self.OAUTH_TOKEN_PATH,
{
'username': email,
'password': password,
'grant_type': 'password',
'scope': self.OAUTH_SCOPES
},
headers={'Content-Type': 'application/x-www-form-urlencoded'},
auth=requests.auth.HTTPBasicAuth(self.ANDROID_API_USERNAME, self.ANDROID_API_PASSWORD)
)
if r.status_code != 200:
raise ApiLoginException(r.status_code, 'Received HTTP %s during login: %s' % (r.status_code, r.text))
j = r.json()
if "user" not in j or not j["user"]:
raise ApiException(r.status_code, 'No user details present in AndroidApi oauth response: %s' % r.text)
self.accessToken = j["accessToken"]
self.accessTokenExpiresAt = j["accessTokenExpiresAt"]
self.refreshToken = j["refreshToken"]
self.refreshTokenExpiresAt = j["refreshTokenExpiresAt"]
self.userId = j["user"]["id"]
self.patientObjectId = j["user"]["patientObjectId"]
def needs_relogin(self):
diff = (arrow.get(self.refreshTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
if not self.accessToken:
raise Exception('No access token')
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)
if r.status_code != 200:
raise ApiException(r.status_code, "Internal API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
def post(self, endpoint, query={}, **kwargs):
r = requests.post(self.BASE_URL + endpoint, query, headers=self.api_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "Internal API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
"""
Returns the most recent event ID that was uploaded for the given pump.
{'maxPumpEventIndex': <integer>, 'processingStatus': 1}
"""
def last_event_uploaded(self, pump_serial_number):
return self.get('cloud/upload/getlasteventuploaded?sn=%d' % pump_serial_number)
-67
View File
@@ -1,67 +0,0 @@
import requests
import urllib
import datetime
from bs4 import BeautifulSoup
from .common import parse_date, base_headers, ApiException, ApiLoginException
class ControlIQApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/tconnect/controliq/api/'
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
userGuid = None
accessToken = None
accessTokenExpiresAt = None
def __init__(self, email, password):
self.login(email, password)
def login(self, email, password):
with requests.Session() as s:
initial = s.get(self.LOGIN_URL, headers=base_headers())
soup = BeautifulSoup(initial.content, features='lxml')
data = {
"__LASTFOCUS": "",
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
"__EVENTARGUMENT": "",
"__VIEWSTATE": soup.select_one("#__VIEWSTATE")["value"],
"__VIEWSTATEGENERATOR": soup.select_one("#__VIEWSTATEGENERATOR")["value"],
"__EVENTVALIDATION": soup.select_one("#__EVENTVALIDATION")["value"],
"ctl00$ContentBody$LoginControl$txtLoginEmailAddress": email,
"txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (email, email, email),
"ctl00$ContentBody$LoginControl$txtLoginPassword": password,
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password)
}
req = s.post(self.LOGIN_URL, data=data, headers={'Referer': self.LOGIN_URL, **base_headers()}, allow_redirects=False)
if req.status_code != 302:
raise ApiLoginException(req.status_code, 'Error logging in to t:connect. Check your login credentials.')
fwd = s.post(urllib.parse.urljoin(self.LOGIN_URL, req.headers['Location']), cookies=req.cookies, headers=base_headers())
if fwd.status_code != 200:
raise ApiException(fwd.status_code, 'Error retrieving t:connect login cookies.')
self.userGuid = req.cookies['UserGUID']
self.accessToken = req.cookies['accessToken']
self.accessTokenExpiresAt = req.cookies['accessTokenExpiresAt']
return True
def api_headers(self):
if not self.accessToken:
raise Exception('No access token provided')
return {'Authorization': 'Bearer %s' % self.accessToken, **base_headers()}
def get(self, endpoint, query):
r = requests.get(self.BASE_URL + endpoint, query, headers=self.api_headers())
if r.status_code != 200:
raise ApiException(r.status_code, "ControlIQ API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
def therapy_timeline(self, start=None, end=None):
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('therapytimeline/users/%s' % (self.userGuid), {
"startDate": startDate,
"endDate": endDate
})
+26 -304
View File
@@ -2,291 +2,48 @@
import sys
import datetime
import json
import hashlib
import requests
import arrow
import argparse
import time
import logging
from api import TConnectApi
from api.common import ApiException
from parser import TConnectEntry
from nightscout import (
NightscoutEntry,
upload_nightscout,
delete_nightscout,
put_nightscout,
last_uploaded_nightscout_entry,
last_uploaded_nightscout_activity,
BASAL_EVENTTYPE,
BOLUS_EVENTTYPE,
IOB_ACTIVITYTYPE
)
from tconnectsync.api import TConnectApi
from tconnectsync.process import process_time_range
from tconnectsync.autoupdate import process_auto_update
from tconnectsync.check import check_login
from tconnectsync.nightscout import NightscoutApi
try:
from secret import (
from tconnectsync.secret import (
TCONNECT_EMAIL,
TCONNECT_PASSWORD,
PUMP_SERIAL_NUMBER,
TIMEZONE_NAME
NS_URL,
NS_SECRET
)
except Exception:
print('Unable to import secret.py')
print('Unable to read secret.py')
sys.exit(1)
"""
Merges together input from the therapy timeline API into a digestable format of basal data.
"""
def process_ciq_basal_events(data):
if data is None:
return []
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_ciq_basal_entry(b, delivery_type="tempDelivery"))
for b in data["basal"]["algorithmDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="algorithmDelivery"))
for b in data["basal"]["profileDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="profileDelivery"))
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
for i in basalEvents:
if i["time"] in suspensionEvents:
i["suspendReason"] = suspensionEvents[i["time"]]["suspendReason"]
return basalEvents
"""
Processes basal data input from the therapy timeline CSV (which only exists for pre Control-IQ data) into a digestable format.
"""
def add_csv_basal_events(basalEvents, data):
last_entry = None
for row in data:
entry = TConnectEntry.parse_csv_basal_entry(row)
if last_entry:
diff_mins = (arrow.get(entry["time"]) - arrow.get(last_entry["time"])).seconds // 60
entry["duration_mins"] = diff_mins
basalEvents.append(entry)
last_entry = entry
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
return basalEvents
"""
Given processed basal data, adds basal events to Nightscout.
"""
def ns_write_basal_events(basalEvents, pretend=False):
last_upload = last_uploaded_nightscout_entry(BASAL_EVENTTYPE)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
print("Last Nightscout basal upload:", last_upload_time)
add_count = 0
for event in basalEvents:
if last_upload_time and arrow.get(event["time"]) < last_upload_time:
if pretend:
print("Skipping basal event before last upload time:", event)
continue
recent_needs_update = False
if last_upload_time and arrow.get(event["time"]) == last_upload_time:
# If this entry has the same time as the most recent upload, but
# has newer info, then delete and recreate it.
recent_needs_update = (round(last_upload["duration"]) < round(event["duration_mins"]))
reason = event["delivery_type"]
if "suspendReason" in reason:
reason += " (" + reason["suspendReason"] + ")"
entry = NightscoutEntry.basal(
value=event["basal_rate"],
duration_mins=event["duration_mins"],
created_at=event["time"],
reason=reason
)
add_count += 1
print(" Processing basal:", event, "entry:", entry)
if recent_needs_update:
print("Replacing last uploaded entry:", last_upload)
if not pretend:
entry['_id'] = last_upload['_id']
put_nightscout(entry, entity='treatments')
elif not pretend:
upload_nightscout(entry)
return add_count
"""
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_bolus_events(bolusdata):
bolusEvents = []
for b in bolusdata:
parsed = TConnectEntry.parse_bolus_entry(b)
if parsed["completion"] != "Completed":
if parsed["insulin"] and float(parsed["insulin"]) > 0:
# Count non-completed bolus if any insulin was delivered (vs. the amount of insulin requested)
parsed["description"] += " (%s)" % parsed["completion"]
else:
print("Skipping non-completed bolus data:", b, "parsed:", parsed)
continue
bolusEvents.append(parsed)
bolusEvents.sort(key=lambda event: arrow.get(event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]))
return bolusEvents
"""
Given processed bolus data, adds bolus events to Nightscout.
"""
def ns_write_bolus_events(bolusEvents, pretend=False):
last_upload = last_uploaded_nightscout_entry(BOLUS_EVENTTYPE)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
print("Last Nightscout bolus upload:", last_upload_time)
add_count = 0
for event in bolusEvents:
if last_upload_time and arrow.get(event["completion_time"]) <= last_upload_time:
if pretend:
print("Skipping basal event before last upload time:", event)
continue
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
created_at=event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"],
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
)
add_count += 1
print(" Processing bolus:", event, "entry:", entry)
if not pretend:
upload_nightscout(entry)
return add_count
"""
Given IOB data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_iob_events(iobdata):
iobEvents = []
for d in iobdata:
iobEvents.append(TConnectEntry.parse_iob_entry(d))
iobEvents.sort(key=lambda x: arrow.get(x["time"]))
return iobEvents
"""
Given processed IOB data, creates a single Nightscout activity definition to store IOB.
"""
def ns_write_iob_events(iobEvents, pretend=False):
last_upload = last_uploaded_nightscout_activity(IOB_ACTIVITYTYPE)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
print("Last Nightscout iob upload:", last_upload_time)
if not iobEvents or len(iobEvents) == 0:
print("No IOB events: skipping")
return 0
event = iobEvents[-1]
if last_upload_time and arrow.get(event["time"]) <= last_upload_time:
print(" Skipping already uploaded iob event:", event)
return 0
entry = NightscoutEntry.iob(
iob=event["iob"],
created_at=event["time"]
)
print(" Processing iob:", event, "entry:", entry)
if not pretend:
upload_nightscout(entry, entity='activity')
# Delete the previous activity
if last_upload and '_id' in last_upload:
print(" Deleting old iob entry:", last_upload)
if not pretend:
delete_nightscout('activity/{}'.format(last_upload['_id']))
return 1
def process_time_range(tconnect, time_start, time_end, pretend):
print("Downloading t:connect ControlIQ data")
try:
ciqBasalData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
# device in the time range which is queried. Since it launched in early 2020,
# ignore 404's before February.
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
print("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqBasalData = None
else:
raise e
print("Downloading t:connect CSV data")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
readingData = csvdata["readingData"]
iobData = csvdata["iobData"]
csvBasalData = csvdata["basalData"]
bolusData = csvdata["bolusData"]
if readingData and len(readingData) > 0:
print("Last CGM reading from t:connect:", readingData[-1]['EventDateTime'] if 'EventDateTime' in readingData[-1] else readingData)
added = 0
basalEvents = process_ciq_basal_events(ciqBasalData)
if csvBasalData:
add_csv_basal_events(basalEvents, csvBasalData)
added += ns_write_basal_events(basalEvents, pretend=pretend)
bolusEvents = process_bolus_events(bolusData)
added += ns_write_bolus_events(bolusEvents, pretend=pretend)
iobEvents = process_iob_events(iobData)
added += ns_write_iob_events(iobEvents, pretend=pretend)
return added
def parse_args():
parser = argparse.ArgumentParser(description="Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.")
parser.add_argument('--pretend', dest='pretend', action='store_const', const=True, default=False, help='Pretend mode: do not upload any data to Nightscout.')
parser.add_argument('-v', '--verbose', dest='verbose', action='store_const', const=True, default=False, help='Verbose mode: show extra logging details')
parser.add_argument('--start-date', dest='start_date', type=str, default=None, help='The oldest date to process data from. Must be specified with --end-date.')
parser.add_argument('--end-date', dest='end_date', type=str, default=None, help='The newest date to process data until (inclusive). Must be specified with --start-date.')
parser.add_argument('--days', dest='days', type=int, default=1, help='The number of days of t:connect data to read in. Cannot be used with --from-date and --until-date.')
parser.add_argument('--auto-update', dest='auto_update', action='store_const', const=True, default=False, help='If set, continuously checks for updates from t:connect and syncs with Nightscout.')
parser.add_argument('--check-login', dest='check_login', action='store_const', const=True, default=False, help='If set, checks that the provided t:connect credentials can be used to log in.')
return parser.parse_args()
def main():
args = parse_args()
if args.pretend:
print("Pretend mode: will not write to Nightscout")
if args.verbose:
logging.basicConfig(level=logging.DEBUG)
logging.root.debug("Set logging level to DEBUG")
else:
logging.basicConfig(level=logging.INFO)
if args.auto_update and (args.start_date or args.end_date):
raise Exception('Auto-update cannot be used with start/end date')
@@ -303,52 +60,17 @@ def main():
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
nightscout = NightscoutApi(NS_URL, NS_SECRET)
if args.check_login:
return check_login(tconnect, time_start, time_end)
if args.auto_update:
# Read from android api, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
last_event_index = None
last_event_time = None
time_diffs = []
while True:
last_event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
if not last_event_index or last_event['maxPumpEventIndex'] > last_event_index:
now = time.time()
print('New event index:', last_event['maxPumpEventIndex'], 'last:', last_event_index)
if args.pretend:
print('Would update now')
else:
added = process_time_range(tconnect, time_start, time_end, args.pretend)
print('Added', added, 'items')
if last_event_index:
time_diffs.append(now - last_event_time)
print('Time diffs:', time_diffs)
last_event_index = last_event['maxPumpEventIndex']
last_event_time = now
else:
print('No event index change:', last_event['maxPumpEventIndex'])
if len(time_diffs) > 2:
print('Sleeping 60 seconds after unexpected no index change')
time.sleep(60)
continue
sleep_secs = 60
if len(time_diffs) > 10:
time_diffs = time_diffs[1:]
if len(time_diffs) > 2:
sleep_secs = sum(time_diffs) / len(time_diffs)
# Sleep for a rolling average of time between updates
print('Sleeping for', sleep_secs, 'sec')
time.sleep(sleep_secs)
print("Starting auto-update between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
process_auto_update(tconnect, nightscout, time_start, time_end, args.pretend)
else:
print("Processing data between", time_start, "and", time_end)
added = process_time_range(tconnect, time_start, time_end, args.pretend)
print("Processing data between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend)
print("Added", added, "items")
if __name__ == '__main__':
-92
View File
@@ -1,92 +0,0 @@
import sys
import requests
import hashlib
import time
import urllib.parse
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 = "Pump (tconnectsync)"
BASAL_EVENTTYPE = "Temp Basal"
BOLUS_EVENTTYPE = "Combo Bolus"
IOB_ACTIVITYTYPE = "tconnect_iob"
class NightscoutEntry:
@staticmethod
def basal(value, duration_mins, created_at, reason=""):
return {
"eventType": BASAL_EVENTTYPE,
"reason": reason,
"duration": float(duration_mins) if duration_mins else None,
"absolute": float(value),
"created_at": created_at,
"carbs": None,
"insulin": None,
"enteredBy": ENTERED_BY
}
@staticmethod
def bolus(bolus, carbs, created_at, notes=""):
return {
"eventType": BOLUS_EVENTTYPE,
"created_at": created_at,
"carbs": carbs,
"insulin": bolus,
"notes": notes,
"enteredBy": ENTERED_BY,
}
@staticmethod
def iob(iob, created_at):
return {
"activityType": IOB_ACTIVITYTYPE,
"iob": iob,
"created_at": created_at,
"enteredBy": ENTERED_BY
}
def upload_nightscout(ns_format, entity='treatments'):
upload = requests.post(NS_URL + 'api/v1/' + entity + '?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)
def delete_nightscout(entity):
upload = requests.delete(NS_URL + 'api/v1/' + entity + '?api_secret=' + NS_SECRET, json={}, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(NS_SECRET.encode()).hexdigest()
})
print("Nightscout delete status:", upload.status_code, upload.text)
def put_nightscout(ns_format, entity):
upload = requests.put(NS_URL + 'api/v1/' + entity + '?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 put status:", upload.status_code, upload.text)
def last_uploaded_nightscout_entry(eventType):
latest = requests.get(NS_URL + 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + '&ts=' + str(time.time()), headers={
'api-secret': hashlib.sha1(NS_SECRET.encode()).hexdigest()
})
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
def last_uploaded_nightscout_activity(activityType):
latest = requests.get(NS_URL + 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + '&ts=' + str(time.time()), headers={
'api-secret': hashlib.sha1(NS_SECRET.encode()).hexdigest()
})
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
-26
View File
@@ -1,26 +0,0 @@
import os, sys
from dotenv import load_dotenv
load_dotenv()
TCONNECT_EMAIL = os.environ.get('TCONNECT_EMAIL', 'email@email.com')
TCONNECT_PASSWORD = os.environ.get('TCONNECT_PASSWORD', 'password')
try:
PUMP_SERIAL_NUMBER = int(os.environ.get('PUMP_SERIAL_NUMBER', '11111111'))
except ValueError as e:
print("Error: PUMP_SERIAL_NUMBER must be a number.")
print("Current value: {}".format(PUMP_SERIAL_NUMBER))
sys.exit(1)
NS_URL = os.environ.get('NS_URL', 'https://yournightscouturl/')
NS_SECRET = os.environ.get('NS_SECRET', 'apisecret')
TIMEZONE_NAME = os.environ.get('TIMEZONE_NAME', 'America/New_York')
_config = ['TCONNECT_EMAIL', 'TCONNECT_PASSWORD', 'PUMP_SERIAL_NUMBER',
'NS_URL', 'NS_SECRET', 'TIMEZONE_NAME']
if __name__ == '__main__':
for k in locals():
print("{}={}".format(k, locals().get(k)))
View File
@@ -1,7 +1,11 @@
import logging
from .android import AndroidApi
from .controliq import ControlIQApi
from .ws2 import WS2Api
logger = logging.getLogger(__name__)
"""A wrapper for the three different t:connect API types."""
class TConnectApi:
email = None
@@ -18,9 +22,11 @@ class TConnectApi:
@property
def controliq(self):
if self._ciq:
if self._ciq and not self._ciq.needs_relogin():
return self._ciq
logger.debug("Instantiating new ControlIQApi")
self._ciq = ControlIQApi(self.email, self.password)
return self._ciq
@@ -29,6 +35,12 @@ class TConnectApi:
if self._ws2:
return self._ws2
logger.debug("Instantiating new WS2Api")
# Trigger login or re-login via controliq api if necessary
# so userGuid can be accessed from it
self.controliq
self._ws2 = WS2Api(self._ciq.userGuid)
return self._ws2
@@ -37,6 +49,8 @@ class TConnectApi:
if self._android and not self._android.needs_relogin():
return self._android
logger.debug("Instantiating new AndroidApi")
self._android = AndroidApi(self.email, self.password)
return self._android
+157
View File
@@ -0,0 +1,157 @@
import requests
import json
import urllib
import datetime
import csv
import base64
import arrow
import time
import logging
from bs4 import BeautifulSoup
from ..util import timeago
from .common import ApiException, ApiLoginException
logger = logging.getLogger(__name__)
"""
The AndroidApi class contains methods which are queried in the t:connect
Android application. These methods are a part of the tdc API which require
Android specific credentials.
"""
class AndroidApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
OAUTH_TOKEN_PATH = 'cloud/oauth2/token'
OAUTH_SCOPES = 'cloud.account cloud.upload cloud.accepttcpp cloud.email cloud.password'
# These credentials are found in source code
ANDROID_API_USERNAME = base64.b64decode('QzIzMzFDRDYtRDQ1MC00OTVFLTlDMTktNjcyMTUyMzBDODVD').decode()
ANDROID_API_PASSWORD = base64.b64decode('dHo0MzNLVzVRREM5VjdmIXo2QF4ybyZZNlNHR1lo').decode()
# These tokens are separate from the "standard" tdcservices API
accessToken = None
accessTokenExpiresAt = None
refreshToken = None
refreshTokenExpiresAt = None
userId = None
patientObjectId = None
def __init__(self, email, password):
self.login(email, password)
self._email = email
self._password = password
def login(self, email, password):
r = requests.post(
self.BASE_URL + self.OAUTH_TOKEN_PATH,
{
'username': email,
'password': password,
'grant_type': 'password',
'scope': self.OAUTH_SCOPES
},
headers={'Content-Type': 'application/x-www-form-urlencoded'},
auth=requests.auth.HTTPBasicAuth(self.ANDROID_API_USERNAME, self.ANDROID_API_PASSWORD)
)
if r.status_code != 200:
raise ApiLoginException(r.status_code, 'Received HTTP %s during login: %s' % (r.status_code, r.text))
j = r.json()
if "user" not in j or not j["user"]:
raise ApiException(r.status_code, 'No user details present in AndroidApi oauth response: %s' % r.text)
self.accessToken = j["accessToken"]
self.accessTokenExpiresAt = j["accessTokenExpiresAt"]
# NOTE: the refresh token is currently unused, instead a new access
# token is obtained from scratch by re-logging in when it expires.
self.refreshToken = j["refreshToken"]
self.refreshTokenExpiresAt = j["refreshTokenExpiresAt"]
self.userId = j["user"]["id"]
self.patientObjectId = j["user"]["patientObjectId"]
logger.info("Logged in to AndroidApi successfully (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
def needs_relogin(self):
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
if not self.accessToken:
raise Exception('No access token')
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)
if r.status_code != 200:
raise ApiException(r.status_code, "Android API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
def get(self, endpoint, query={}, tries=0, **kwargs):
try:
return self._get(endpoint, query, **kwargs)
except ApiException as e:
if tries > 0:
raise ApiException(e.status_code, "Android API HTTP %s on retry #%d: %s" % (e.status_code, tries, e))
# Trigger automatic re-login, and try again once
if e.status_code == 401:
self.accessTokenExpiresAt = time.time()
self.login(self._email, self._password)
return self.get(endpoint, query, tries=tries+1, **kwargs)
if e.status_code == 500:
return self.get(endpoint, query, tries=tries+1, **kwargs)
raise e
def post(self, endpoint, query={}, **kwargs):
r = requests.post(self.BASE_URL + endpoint, query, headers=self.api_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "Internal API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
"""
Returns the most recent event ID that was uploaded for the given pump.
{'maxPumpEventIndex': <integer>, 'processingStatus': 1}
"""
def last_event_uploaded(self, pump_serial_number):
return self.get('cloud/upload/getlasteventuploaded?sn=%d' % pump_serial_number)
"""
Returns user login information about a tconnect account.
{'firstName': <string>, 'lastName': <string>, 'birthDate': 'YYYY-MM-DDT00:00:00.000Z',
'emailAddress': <string>, 'secretQuestion': <string>, 'secretAnswer': <string>,
'secretQuestionId': <integer>}
"""
def patient_info(self):
return self.get('cloud/account/patient_info')
# 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.
"""
Returns BG and pump threshold values.
{'targetBGHigh': <integer>, 'targetBGLow': <integer>, 'hypoThreshold': <integer>,
'hyperThreshold': <integer>, 'siteChangeThreshold': <integer>,
'cartridgeChangeThreshold': <integer>, 'tubingChangeThreshold': <integer>}
"""
def therapy_thresholds(self):
return self.get('cloud/usersettings/api/therapythresholds?userId=%s' % self.userId)
"""
Returns therapy-related user information about a tconnect account.
{'userID': <string>, 'targetBgHigh': <integer>, 'targetBgLow': <integer>,
'hypoThreshold': <integer>, 'hyperThreshold': <integer>,
'dateOfBirth': 'YYYY-MM-DDT00:00:00', 'age': <integer>,
'patientFullName': <string>, 'caregiverDateOfBirth': <string>,
'hasCGM': <bool>, 'hasBASALIQ': <bool>, 'hasControlIQ': <bool>}
"""
def user_profile(self):
return self.get('cloud/usersettings/api/UserProfile?userId=%s' % self.userId)
+1 -1
View File
@@ -11,7 +11,7 @@ def base_headers():
class ApiException(Exception):
def __init__(self, status_code, text, *args, **kwargs):
self.status_code = status_code
super().__init__(text, *args, **kwargs)
super().__init__('%s (HTTP %s)' % (text, status_code), *args, **kwargs)
class ApiLoginException(ApiException):
pass
+128
View File
@@ -0,0 +1,128 @@
import requests
import urllib
import datetime
import arrow
import time
import logging
from bs4 import BeautifulSoup
from ..util import timeago
from .common import parse_date, base_headers, ApiException, ApiLoginException
logger = logging.getLogger(__name__)
class ControlIQApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/tconnect/controliq/api/'
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
userGuid = None
accessToken = None
accessTokenExpiresAt = None
def __init__(self, email, password):
self.login(email, password)
self._email = email
self._password = password
def login(self, email, password):
logger.info("Logging in to ControlIQApi...")
with requests.Session() as s:
initial = s.get(self.LOGIN_URL, headers=base_headers())
soup = BeautifulSoup(initial.content, features='lxml')
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)
if req.status_code != 302:
raise ApiLoginException(req.status_code, 'Error logging in to t:connect. Check your login credentials.')
fwd = s.post(urllib.parse.urljoin(self.LOGIN_URL, req.headers['Location']), cookies=req.cookies, headers=base_headers())
if fwd.status_code != 200:
raise ApiException(fwd.status_code, 'Error retrieving t:connect login cookies.')
self.userGuid = req.cookies['UserGUID']
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)))
return True
def _build_login_data(self, email, password, soup):
return {
"__LASTFOCUS": "",
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
"__EVENTARGUMENT": "",
"__VIEWSTATE": soup.select_one("#__VIEWSTATE")["value"],
"__VIEWSTATEGENERATOR": soup.select_one("#__VIEWSTATEGENERATOR")["value"],
"__EVENTVALIDATION": soup.select_one("#__EVENTVALIDATION")["value"],
"ctl00$ContentBody$LoginControl$txtLoginEmailAddress": email,
"txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (email, email, email),
"ctl00$ContentBody$LoginControl$txtLoginPassword": password,
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password)
}
def needs_relogin(self):
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
if not self.accessToken:
raise Exception('No access token provided')
return {'Authorization': 'Bearer %s' % self.accessToken, **base_headers()}
def _get(self, endpoint, query):
r = requests.get(self.BASE_URL + endpoint, query, headers=self.api_headers())
if r.status_code != 200:
raise ApiException(r.status_code, "ControlIQ API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
def get(self, endpoint, query, tries=0):
try:
return self._get(endpoint, query)
except ApiException as e:
logger.warning("Received ApiException in ControlIQApi with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
if tries > 0:
raise ApiException(e.status_code, "ControlIQ API 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 after HTTP 401 for ControlIQApi")
self.accessTokenExpiresAt = time.time()
self.login(self._email, self._password)
return self.get(endpoint, query, tries=tries+1)
if e.status_code == 500:
return self.get(endpoint, query, tries=tries+1)
raise e
"""
Returns detailed basal event information and reasons for delivery suspension.
"""
def therapy_timeline(self, start=None, end=None):
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('therapytimeline/users/%s' % (self.userGuid), {
"startDate": startDate,
"endDate": endDate
})
"""
Returns a summary of pump and cgm activity.
{'averageReading': <integer>, 'timeInUseMinutes': <integer>, 'controlIqSetToOffMinutes': <integer>,
'cgmInactiveMinutes': <integer>, 'pumpInactiveMinutes': <integer>, 'averageDailySleepMinutes': <integer>,
'weeklyExerciseEvents': <integer>, 'timeInUsePercent': <integer>, 'controlIqOffPercent': <integer>,
'cgmInactivePercent': <integer>, 'pumpInactivePercent': <integer>, 'totalDays': <integer>}
"""
def dashboard_summary(self, start, end):
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('summary/users/%s' % (self.userGuid), {
"startDate": startDate,
"endDate": endDate
})
+40 -2
View File
@@ -1,12 +1,18 @@
import requests
import datetime
import csv
import logging
import time
from .common import parse_date, base_headers, ApiException
logger = logging.getLogger(__name__)
class WS2Api:
BASE_URL = 'https://tconnectws2.tandemdiabetes.com/'
MAX_RETRIES = 2
userGuid = None
def __init__(self, userGuid):
@@ -18,6 +24,18 @@ class WS2Api:
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.text
def get_jsonp(self, endpoint):
r = requests.get(self.BASE_URL + endpoint, {'callback': 'cb'}, headers=base_headers())
if r.status_code != 200:
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
t = r.text.strip()
if t.startswith('cb('):
t = t[3:]
if t.endswith(')'):
t = t[:-1]
return t
def _split_empty_sections(self, text):
sections = [[]]
@@ -42,11 +60,22 @@ class WS2Api:
return data
def therapy_timeline_csv(self, start=None, end=None):
def therapy_timeline_csv(self, start=None, end=None, tries=0):
startDate = parse_date(start)
endDate = parse_date(end)
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), {})
try:
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), {})
except ApiException as e:
# This seems to occur as some kind of soft rate-limit.
logger.warning("Received ApiException in therapy_timeline_csv: (retry count %d) %s" % (tries, e))
if e.status_code == 500:
sleep_seconds = (tries+1) * 60
logger.error("Retrying in %d seconds after HTTP 500 in therapy_timeline_csv (retry count %d): %s" % (sleep_seconds, tries, e))
time.sleep(sleep_seconds)
if tries < self.MAX_RETRIES:
return self.therapy_timeline_csv(start, end, tries+1)
raise e
sections = self._split_empty_sections(req_text)
@@ -74,3 +103,12 @@ class WS2Api:
"basalData": self._csv_to_dict(basalData),
"bolusData": self._csv_to_dict(bolusData)
}
"""
Returns info on BasalIQ in JSONP format.
"""
def basaliqtech(self, start=None, end=None):
startDate = parse_date(start)
endDate = parse_date(end)
return self.get_jsonp('basaliqtech/%s/%s/%s' % (self.userGuid, startDate, endDate))
+92
View File
@@ -0,0 +1,92 @@
import time
import logging
from .process import process_time_range
from .secret import (
PUMP_SERIAL_NUMBER,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS,
AUTOUPDATE_MAX_SLEEP_SECONDS,
AUTOUPDATE_USE_FIXED_SLEEP,
AUTOUPDATE_FAILURE_MINUTES,
AUTOUPDATE_RESTART_ON_FAILURE
)
logger = logging.getLogger(__name__)
"""
Performs the auto-update functionality. Runs indefinitely in a loop
until stopped (ctrl+c).
"""
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
# Read from android api, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
last_event_index = None
last_event_time = None
last_process_time_range = None
time_diffs = []
while True:
last_event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
if not last_event_index or last_event['maxPumpEventIndex'] > last_event_index:
now = time.time()
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], last_event_index))
if pretend:
logger.info('Would update now if not in pretend mode')
else:
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend)
logger.info('Added %d items from process_time_range' % added)
if added == 0:
if last_event_index:
logger.error('An event index change was recorded, but no new data was found via the API. ' +
'If this error reoccurs, try restarting tconnectsync.')
else:
last_process_time_range = now
if last_event_index:
time_diffs.append(now - last_event_time)
logger.debug('Updating tracking of time since last update: %s' % time_diffs)
last_event_index = last_event['maxPumpEventIndex']
last_event_time = now
else:
logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex'])
now = time.time()
if (now - last_event_time) >= 60 * AUTOUPDATE_FAILURE_MINUTES:
logger.error("No new data event indexes have been detected for over %d minutes. " % AUTOUPDATE_FAILURE_MINUTES +
"The t:connect app might no longer be functioning.")
if AUTOUPDATE_RESTART_ON_FAILURE:
raise AutoupdateFailureException
elif (now - last_process_time_range) >= 60 * AUTOUPDATE_FAILURE_MINUTES:
logger.error("No new data has been found via the API for over %d minutes. " % AUTOUPDATE_FAILURE_MINUTES +
"tconnectsync might not be functioning properly.")
if AUTOUPDATE_RESTART_ON_FAILURE:
raise AutoupdateFailureException
if len(time_diffs) > 2:
logger.info('Sleeping 60 seconds after unexpected no index change. (New data might be delayed.)')
time.sleep(60)
continue
sleep_secs = AUTOUPDATE_DEFAULT_SLEEP_SECONDS
if AUTOUPDATE_USE_FIXED_SLEEP != 1:
if len(time_diffs) > 10:
time_diffs = time_diffs[1:]
if len(time_diffs) > 2:
sleep_secs = sum(time_diffs) / len(time_diffs)
if sleep_secs > AUTOUPDATE_MAX_SLEEP_SECONDS:
sleep_secs = AUTOUPDATE_MAX_SLEEP_SECONDS
# Sleep for a rolling average of time between updates
logger.info('Sleeping for %d sec' % sleep_secs)
time.sleep(sleep_secs)
class AutoupdateFailureException(RuntimeError):
pass
+58
View File
@@ -0,0 +1,58 @@
from .nightscout import NightscoutApi
"""
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):
errors = 0
print("Logging in to t:connect ControlIQ API...")
try:
summary = tconnect.controliq.dashboard_summary(time_start, time_end)
print("ControlIQ dashboard summary: %s" % summary)
except Exception as e:
print("Error occurred querying ControlIQ API: %s" % e)
errors += 1
print("\nLogging in to t:connect WS2 API...")
try:
summary = tconnect.ws2.basaliqtech(time_start, time_end)
print("WS2 basaliq status: %s" % summary)
except Exception as e:
print("Error occurred querying WS2 API: %s" % e)
errors += 1
print("\nLogging in to t:connect Android API...")
try:
summary = tconnect.android.user_profile()
print("Android user profile: %s" % summary)
from .secret import PUMP_SERIAL_NUMBER
event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
print("\nAndroid last uploaded event: %s" % event)
except ImportError:
print("Error: Unable to load config file.")
except Exception as e:
print("Error occurred querying Android API: %s" % e)
errors += 1
print("\nLogging in to Nightscout...")
try:
from .secret import NS_URL, NS_SECRET
status = NightscoutApi(NS_URL, NS_SECRET).api_status()
print("\nNightscout status: %s" % status)
except ImportError:
print("Error: Unable to load config file.")
except Exception as e:
print("Error occurred querying Nightscout API: %s" % e)
errors += 1
if errors == 0:
print("\nNo API errors returned!")
else:
print("\nAPI errors occurred. Please check the errors above.")
+84
View File
@@ -0,0 +1,84 @@
import sys
import requests
import hashlib
import time
import urllib.parse
from urllib.parse import urljoin
from .api.common import ApiException
from .parser.nightscout import ENTERED_BY
# try:
# from .secret import NS_URL, NS_SECRET
# except Exception:
# print('Unable to import Nightscout secrets from secret.py')
# sys.exit(1)
class NightscoutApi:
def __init__(self, url, secret):
self.url = url
self.secret = secret
def upload_entry(self, ns_format, entity='treatments'):
r = requests.post(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout upload response: %s" % r.text)
def delete_entry(self, entity):
r = requests.delete(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json={}, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout delete response: %s" % r.text)
def put_entry(self, ns_format, entity):
r = requests.put(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout put response: %s" % r.text)
def last_uploaded_entry(self, eventType):
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout treatments response: %s" % latest.text)
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
def last_uploaded_activity(self, activityType):
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout activity response: %s" % latest.text)
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
"""
Returns general status information about the Nightscout server.
"""
def api_status(self):
status = requests.get(urljoin(self.url, 'api/v1/status.json'), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if status.status_code != 200:
raise Exception('HTTP error status code (%d) from Nightscout: %s' % (status.status_code, status.text))
return status.json()
View File
+42
View File
@@ -0,0 +1,42 @@
ENTERED_BY = "Pump (tconnectsync)"
BASAL_EVENTTYPE = "Temp Basal"
BOLUS_EVENTTYPE = "Combo Bolus"
IOB_ACTIVITYTYPE = "tconnect_iob"
"""
Conversion methods for parsing data into Nightscout objects.
"""
class NightscoutEntry:
@staticmethod
def basal(value, duration_mins, created_at, reason=""):
return {
"eventType": BASAL_EVENTTYPE,
"reason": reason,
"duration": float(duration_mins) if duration_mins else None,
"absolute": float(value),
"rate": float(value),
"created_at": created_at,
"carbs": None,
"insulin": None,
"enteredBy": ENTERED_BY
}
@staticmethod
def bolus(bolus, carbs, created_at, notes=""):
return {
"eventType": BOLUS_EVENTTYPE,
"created_at": created_at,
"carbs": int(carbs),
"insulin": float(bolus),
"notes": notes,
"enteredBy": ENTERED_BY,
}
@staticmethod
def iob(iob, created_at):
return {
"activityType": IOB_ACTIVITYTYPE,
"iob": iob,
"created_at": created_at,
"enteredBy": ENTERED_BY
}
@@ -2,11 +2,15 @@ import sys
import arrow
try:
from secret import TIMEZONE_NAME
from ..secret import TIMEZONE_NAME
except Exception:
print('Unable to import parser secrets from secret.py')
sys.exit(1)
"""
Conversion methods for parsing raw t:connect data into
a more digestable format, which is used internally.
"""
class TConnectEntry:
BASAL_EVENTS = { 0: "Suspension", 1: "Profile", 2: "TempRate", 3: "Algorithm" }
ACTIVITY_EVENTS = { 1: "Sleep", 2: "Exercise", 3: "AutoBolus", 4: "CarbOnly" }
+78
View File
@@ -0,0 +1,78 @@
import logging
import datetime
import arrow
import time
from .util import timeago
from .api.common import ApiException
from .sync.basal import (
process_ciq_basal_events,
add_csv_basal_events,
ns_write_basal_events
)
from .sync.bolus import (
process_bolus_events,
ns_write_bolus_events
)
from .sync.iob import (
process_iob_events,
ns_write_iob_events
)
from .parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given a TConnectApi object and start/end range, performs a single
cycle of synchronizing data within the time range.
If pretend is true, then doesn't actually write data to Nightscout.
"""
def process_time_range(tconnect, nightscout, time_start, time_end, pretend):
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
# device in the time range which is queried. Since it launched in early 2020,
# ignore 404's before February.
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqTherapyTimelineData = None
else:
raise e
logger.info("Downloading t:connect CSV data")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
readingData = csvdata["readingData"]
iobData = csvdata["iobData"]
csvBasalData = csvdata["basalData"]
bolusData = csvdata["bolusData"]
if readingData and len(readingData) > 0:
lastReading = readingData[-1]['EventDateTime'] if 'EventDateTime' in readingData[-1] else 0
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(readingData[-1])
logger.info("Last CGM reading from t:connect: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined")
added = 0
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
if csvBasalData:
logger.debug("CSV basal data found: processing it")
add_csv_basal_events(basalEvents, csvBasalData)
else:
logger.debug("No CSV basal data found")
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend)
bolusEvents = process_bolus_events(bolusData)
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend)
iobEvents = process_iob_events(iobData)
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
logger.info("Wrote %d events to Nightscout this process cycle")
return added
+47
View File
@@ -0,0 +1,47 @@
import os, sys
from dotenv import load_dotenv
load_dotenv()
def get(*args):
return os.environ.get(*args)
def get_number(name, default):
val = get(name, default)
try:
return int(val)
except ValueError:
print("Error: %s must be a number." % name)
print("Current value: %s" % val)
sys.exit(1)
def get_bool(name, default):
return str(get(name, default) or '').lower() in ('true', '1')
TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com')
TCONNECT_PASSWORD = get('TCONNECT_PASSWORD', 'password')
PUMP_SERIAL_NUMBER = get_number('PUMP_SERIAL_NUMBER', '11111111')
NS_URL = get('NS_URL', 'https://yournightscouturl/')
NS_SECRET = get('NS_SECRET', 'apisecret')
TIMEZONE_NAME = get('TIMEZONE_NAME', 'America/New_York')
# Optional configuration
AUTOUPDATE_DEFAULT_SLEEP_SECONDS = get_number('AUTOUPDATE_DEFAULT_SLEEP_SECONDS', '300') # 5 minutes
AUTOUPDATE_MAX_SLEEP_SECONDS = get_number('AUTOUPDATE_MAX_SLEEP_SECONDS', '1500') # 25 minutes
AUTOUPDATE_USE_FIXED_SLEEP = get_bool('AUTOUPDATE_USE_FIXED_SLEEP', 'false')
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '180') # 3 hours
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
_config = ['TCONNECT_EMAIL', 'TCONNECT_PASSWORD', 'PUMP_SERIAL_NUMBER',
'NS_URL', 'NS_SECRET', 'TIMEZONE_NAME',
'AUTOUPDATE_DEFAULT_SLEEP_SECONDS', 'AUTOUPDATE_MAX_SLEEP_SECONDS',
'AUTOUPDATE_USE_FIXED_SLEEP', 'AUTOUPDATE_FAILURE_MINUTES',
'AUTOUPDATE_RESTART_ON_FAILURE']
if __name__ == '__main__':
for k in locals():
print("{} = {}".format(k, locals().get(k)))
View File
+113
View File
@@ -0,0 +1,113 @@
import arrow
import logging
from ..parser.nightscout import (
BASAL_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Merges together input from the therapy timeline API
into a digestable format of basal data.
"""
def process_ciq_basal_events(data):
if data is None:
return []
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_ciq_basal_entry(b, delivery_type="tempDelivery"))
for b in data["basal"]["algorithmDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="algorithmDelivery"))
for b in data["basal"]["profileDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="profileDelivery"))
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
for i in basalEvents:
if i["time"] in suspensionEvents:
i["suspendReason"] = suspensionEvents[i["time"]]["suspendReason"]
return basalEvents
"""
Processes basal data input from the therapy timeline CSV (which only
exists for pre Control-IQ data) into a digestable format.
"""
def add_csv_basal_events(basalEvents, data):
last_entry = {}
for row in data:
entry = TConnectEntry.parse_csv_basal_entry(row)
if last_entry:
diff_mins = (arrow.get(entry["time"]) - arrow.get(last_entry["time"])).seconds // 60
entry["duration_mins"] = diff_mins
basalEvents.append(entry)
last_entry = entry
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
return basalEvents
"""
Given processed basal data, adds basal events to Nightscout.
"""
def ns_write_basal_events(nightscout, basalEvents, pretend=False):
logger.debug("ns_write_basal_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BASAL_EVENTTYPE)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout basal upload: %s" % last_upload_time)
add_count = 0
for event in basalEvents:
if last_upload_time and arrow.get(event["time"]) < last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s" % event)
continue
recent_needs_update = False
if last_upload_time and arrow.get(event["time"]) == last_upload_time:
# If this entry has the same time as the most recent upload, but
# has newer info, then delete and recreate it.
recent_needs_update = (round(last_upload["duration"]) < round(event["duration_mins"]))
# If the timestamps are identical, and the duration is identical,
# then don't upload a duplicate entry of what we already have.
if not recent_needs_update:
continue
reason = event["delivery_type"]
if "suspendReason" in reason:
reason += " (" + reason["suspendReason"] + ")"
entry = NightscoutEntry.basal(
value=event["basal_rate"],
duration_mins=event["duration_mins"],
created_at=event["time"],
reason=reason
)
add_count += 1
logger.info(" Processing basal: %s entry: %s" % (event, entry))
if recent_needs_update:
logger.info("Replacing last uploaded entry: %s" % last_upload)
if not pretend:
entry['_id'] = last_upload['_id']
nightscout.put_entry(entry, entity='treatments')
elif not pretend:
nightscout.upload_entry(entry)
logger.debug("ns_write_basal_events: added %d events" % add_count)
return add_count
+64
View File
@@ -0,0 +1,64 @@
import arrow
import logging
from ..parser.nightscout import (
BOLUS_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_bolus_events(bolusdata):
bolusEvents = []
for b in bolusdata:
parsed = TConnectEntry.parse_bolus_entry(b)
if parsed["completion"] != "Completed":
if parsed["insulin"] and float(parsed["insulin"]) > 0:
# Count non-completed bolus if any insulin was delivered (vs. the amount of insulin requested)
parsed["description"] += " (%s)" % parsed["completion"]
else:
logger.warning("Skipping non-completed bolus data (was a bolus in progress?): %s parsed: %s" % (b, parsed))
continue
bolusEvents.append(parsed)
bolusEvents.sort(key=lambda event: arrow.get(event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]))
return bolusEvents
"""
Given processed bolus data, adds bolus events to Nightscout.
"""
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False):
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout bolus upload: %s" % last_upload_time)
add_count = 0
for event in bolusEvents:
if last_upload_time and arrow.get(event["completion_time"]) <= last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s" % event)
continue
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
created_at=event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"],
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
)
add_count += 1
logger.info(" Processing bolus: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry)
return add_count
+59
View File
@@ -0,0 +1,59 @@
import arrow
import logging
from ..parser.nightscout import (
IOB_ACTIVITYTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given IOB data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_iob_events(iobdata):
iobEvents = []
for d in iobdata:
iobEvents.append(TConnectEntry.parse_iob_entry(d))
iobEvents.sort(key=lambda x: arrow.get(x["time"]))
return iobEvents
"""
Given processed IOB data, creates a single Nightscout activity definition to store IOB.
"""
def ns_write_iob_events(nightscout, iobEvents, pretend=False):
logger.debug("ns_write_iob_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_activity(IOB_ACTIVITYTYPE)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout iob upload: %s" % last_upload_time)
if not iobEvents or len(iobEvents) == 0:
logger.info("No IOB events present from API: skipping")
return 0
event = iobEvents[-1]
if last_upload_time and arrow.get(event["time"]) <= last_upload_time:
logger.info(" Skipping already uploaded iob event: %s" % event)
return 0
entry = NightscoutEntry.iob(
iob=event["iob"],
created_at=event["time"]
)
logger.info(" Processing iob: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry, entity='activity')
# Delete the previous activity
if last_upload and '_id' in last_upload:
logger.info(" Deleting old iob entry: %s" % last_upload)
if not pretend:
nightscout.delete_entry('activity/{}'.format(last_upload['_id']))
return 1
+17
View File
@@ -0,0 +1,17 @@
import arrow
def timeago(timestamp):
seconds = (arrow.get() - arrow.get(timestamp)).total_seconds()
fmt = '%s ago' if seconds >= 0 else 'in %s'
seconds = abs(seconds)
ret = ''
if seconds//86400 > 0:
ret += '%d days, ' % (seconds//86400)
seconds = seconds % 86400
if seconds//3600 > 0:
ret += '%d hours, ' % (seconds//3600)
seconds = seconds % 3600
ret += '%d minutes' % (seconds//60)
return fmt % ret
View File
View File
+46
View File
@@ -0,0 +1,46 @@
import tconnectsync.api
class ControlIQApi(tconnectsync.api.controliq.ControlIQApi):
def __init__(self):
self.BASE_URL = 'invalid://'
self.LOGIN_URL = 'invalid://'
def login(self, email, password):
raise NotImplementedError
def needs_relogin(self):
return False
def _get(self, endpoint, query):
raise NotImplementedError
class WS2Api(tconnectsync.api.ws2.WS2Api):
def __init__(self):
self.BASE_URL = 'invalid://'
def get(self, endpoint, query):
raise NotImplementedError
def get_jsonp(self, endpoint):
raise NotImplementedError
class AndroidApi(tconnectsync.api.android.AndroidApi):
def __init__(self):
self.BASE_URL = 'invalid://'
def login(self, email, password):
raise NotImplementedError
def needs_relogin(self):
return False
def _get(self, endpoint, query={}, **kwargs):
raise NotImplementedError
class TConnectApi(tconnectsync.api.TConnectApi):
def __init__(self):
pass
_ciq = ControlIQApi()
_ws2 = WS2Api()
_android = AndroidApi()
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
import unittest
import itertools
import datetime
from .fake import AndroidApi
from tconnectsync.api.common import ApiException
class TestAndroidApi(unittest.TestCase):
def fake_get_with_http_code(self, http_code, expected_endpoint, num_times):
tries = 0
def fake_get(endpoint, query):
nonlocal http_code, expected_endpoint, num_times, tries
if endpoint.endswith(expected_endpoint):
if tries < num_times:
tries += 1
raise ApiException(http_code, "fake HTTP %d" % http_code)
return {"faked_json": True}
raise NotImplementedError
return fake_get
def test_last_event_uploaded_works_after_single_http_500(self):
android = AndroidApi()
android._get = self.fake_get_with_http_code(500, "cloud/upload/getlasteventuploaded?sn=1111111", 1)
self.assertEqual(
android.last_event_uploaded(1111111),
{
"faked_json": True
})
def test_last_event_uploaded_fails_after_two_http_500s(self):
android = AndroidApi()
android._get = self.fake_get_with_http_code(500, "cloud/upload/getlasteventuploaded?sn=1111111", 2)
self.assertRaises(ApiException, android.last_event_uploaded, 1111111)
def test_last_event_uploaded_triggers_relogin_after_single_http_401(self):
android = AndroidApi()
android._email = 'email'
android._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
android.login = stub_login
android._get = self.fake_get_with_http_code(401, "cloud/upload/getlasteventuploaded?sn=1111111", 1)
self.assertEqual(
android.last_event_uploaded(1111111),
{
"faked_json": True
})
self.assertListEqual(hit_login, [
('email', 'password')
])
def test_last_event_uploaded_fails_after_two_http_401s(self):
android = AndroidApi()
android._email = 'email'
android._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
android.login = stub_login
android._get = self.fake_get_with_http_code(401, "cloud/upload/getlasteventuploaded?sn=1111111", 2)
self.assertRaises(ApiException, android.last_event_uploaded, 1111111)
self.assertListEqual(hit_login, [
('email', 'password')
])
if __name__ == '__main__':
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
import unittest
import itertools
import datetime
import json
import requests_mock
from bs4 import BeautifulSoup
from .fake import ControlIQApi
from tconnectsync.api.controliq import ControlIQApi as RealControlIQApi
from tconnectsync.api.common import ApiException, ApiLoginException, base_headers
class TestControlIQApi(unittest.TestCase):
LOGIN_HTML = """
<html>
<body>
<form method="post" action="./login.aspx?ReturnUrl=%2f" onsubmit="javascript:return WebForm_OnSubmit();" id="form1">
<div class="aspNetHidden">
<input type="hidden" name="__LASTFOCUS" id="__LASTFOCUS" value="" />
<input type="hidden" name="__EVENTTARGET" id="__EVENTTARGET" value="" />
<input type="hidden" name="__EVENTARGUMENT" id="__EVENTARGUMENT" value="" />
<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="AAAAA" />
</div>
<div class="aspNetHidden">
<input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="BBBBB" />
<input type="hidden" name="__EVENTVALIDATION" id="__EVENTVALIDATION" value="CCCCC" />
</div>
</form>
</body>
</html>
"""
LOGIN_POST_DATA = {
"__LASTFOCUS": "",
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
"__EVENTARGUMENT": "",
"__VIEWSTATE": "AAAAA",
"__VIEWSTATEGENERATOR": "BBBBB",
"__EVENTVALIDATION": "CCCCC",
"ctl00$ContentBody$LoginControl$txtLoginEmailAddress": "email@email.com",
"txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % ("email@email.com", "email@email.com", "email@email.com"),
"ctl00$ContentBody$LoginControl$txtLoginPassword": "password",
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % ("password", "password", "password")
}
def test_build_login_data(self):
ciq = ControlIQApi()
soup = BeautifulSoup(self.LOGIN_HTML, features='lxml')
self.assertDictEqual(
ciq._build_login_data('email@email.com', 'password', soup),
self.LOGIN_POST_DATA)
def test_login_successful(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 = 302
context.headers['Location'] = '/newlocation'
context.cookies['UserGUID'] = 'user_guid'
context.cookies['accessToken'] = 'access_tok'
context.cookies['accessTokenExpiresAt'] = '2021-05-04T11:18:08.381Z'
return ''
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
m.post('https://tconnect.tandemdiabetes.com/newlocation',
cookies={'cookie': 'value'},
headers=base_headers(),
status_code=200)
self.assertTrue(ciq.login('email@email.com', 'password'))
self.assertEqual(ciq.userGuid, 'user_guid')
self.assertEqual(ciq.accessToken, 'access_tok')
self.assertEqual(ciq.accessTokenExpiresAt, '2021-05-04T11:18:08.381Z')
def test_login_invalid_credentials(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>...</body></html>'
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
self.assertRaises(ApiLoginException, 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):
nonlocal http_code, expected_endpoint, num_times, tries
if endpoint.endswith(expected_endpoint):
if tries < num_times:
tries += 1
raise ApiException(http_code, "fake HTTP %d" % http_code)
return {"faked_json": True}
raise NotImplementedError
return fake_get
def test_therapy_timeline_works_after_single_http_500(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._get = self.fake_get_with_http_code(500, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 1)
self.assertEqual(
ciq.therapy_timeline('2021-04-01', '2021-04-02'),
{
"faked_json": True
})
def test_therapy_timeline_fails_after_two_http_500s(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._get = self.fake_get_with_http_code(500, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 2)
self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02')
def test_therapy_timeline_triggers_relogin_after_single_http_401(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._email = 'email'
ciq._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
ciq.login = stub_login
ciq._get = self.fake_get_with_http_code(401, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 1)
self.assertEqual(
ciq.therapy_timeline('2021-04-01', '2021-04-02'),
{
"faked_json": True
})
self.assertListEqual(hit_login, [
('email', 'password')
])
def test_therapy_timeline_fails_after_two_http_401s(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._email = 'email'
ciq._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
ciq.login = stub_login
ciq._get = self.fake_get_with_http_code(401, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 2)
self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02')
self.assertListEqual(hit_login, [
('email', 'password')
])
def test_therapy_timeline_parses_date(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def fake_get(endpoint, query):
self.assertTrue(endpoint.endswith("therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
self.assertEqual(query, {
"startDate": "04-01-2021",
"endDate": "04-02-2021"
})
return {"faked_json": True}
ciq._get = fake_get
self.assertEqual(
ciq.therapy_timeline(datetime.date(2021, 4, 1), datetime.date(2021, 4, 2)),
{
"faked_json": True
})
def test_dashboard_summary_parses_date(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def fake_get(endpoint, query):
self.assertTrue(endpoint.endswith("summary/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
self.assertEqual(query, {
"startDate": "04-01-2021",
"endDate": "04-02-2021"
})
return {"faked_json": True}
ciq._get = fake_get
self.assertEqual(
ciq.dashboard_summary(datetime.date(2021, 4, 1), datetime.date(2021, 4, 2)),
{
"faked_json": True
})
if __name__ == '__main__':
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
import unittest
import itertools
from .fake import WS2Api
from tconnectsync.api.common import ApiException
class TestWS2Api(unittest.TestCase):
def fake_get_with_http_500(self, num_times):
tries = 0
def fake_get(endpoint, query):
nonlocal tries, num_times
if "therapytimeline2csv" in endpoint:
if tries < num_times:
tries += 1
raise ApiException(500, "fake HTTP 500")
return ""
raise NotImplementedError
return fake_get
def test_therapy_timeline_csv_works_after_two_retries(self):
ws2 = WS2Api()
ws2.get = self.fake_get_with_http_500(2)
self.assertEqual(
ws2.therapy_timeline_csv('2021-04-01', '2021-04-02'),
{
"readingData": [],
"iobData": [],
"basalData": [],
"bolusData": []
})
def test_therapy_timeline_csv_fails_after_three_retries(self):
ws2 = WS2Api()
ws2.get = self.fake_get_with_http_500(3)
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '2021-04-01', '2021-04-02')
RAW_DATA_HEADER = """Tandem Diabetes Care Inc.
t:connect Therapy Timeline Data Export
Patient Name, Sample Name
Patient DOB, 1/1/1990
Report Generated On, 4/24/2021 7:50:04 PM
"""
RAW_DATA_CGM = """DeviceType,SerialNumber,Description,EventDateTime,Readings (CGM / BGM)
"t:slim X2 Insulin Pump","11111111","EGV","2021-04-01T00:01:33","235",
"t:slim X2 Insulin Pump","11111111","EGV","2021-04-01T00:06:33","230",
"t:slim X2 Insulin Pump","11111111","EGV","2021-04-02T23:31:36","181",
"""
RAW_DATA_IOB = """Type,EventID,EventDateTime,IOB
"IOB","81","2021-04-01T00:00:19","13.24"
"IOB","9","2021-04-01T00:03:12","12.80"
"IOB","81","2021-04-02T23:58:19","4.25"
"""
RAW_DATA_BOLUS = """Type,Description,BG,IOB,BolusRequestID,BolusCompletionID,CompletionDateTime,InsulinDelivered,FoodDelivered,CorrectionDelivered,CompletionStatusID,CompletionStatusDesc,BolusIsComplete,BolexCompletionID,BolexSize,BolexStartDateTime,BolexCompletionDateTime,BolexInsulinDelivered,BolexIOB,BolexCompletionStatusID,BolexCompletionStatusDesc,ExtendedBolusIsComplete,EventDateTime,RequestDateTime,BolusType,BolusRequestOptions,StandardPercent,Duration,CarbSize,UserOverride,TargetBG,CorrectionFactor,FoodBolusSize,CorrectionBolusSize,ActualTotalBolusRequested,IsQuickBolus,EventHistoryReportEventDesc,EventHistoryReportDetails,NoteID,IndexID,Note
"Bolus","Standard/Correction","141",,"7001.000","7001.000","2021-04-01T12:58:26","13.53","12.50","1.03","3","Completed","1",,,,,,,,,,"2021-04-01T12:53:36","2021-04-01T12:53:36","Carb","Standard/Correction","100.00","0","75","0","110","30.00","12.50","1.03","13.53","0","0","Correction & Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110","0","1181649","",
"Bolus","Standard","131","0.71","7003.000","7003.000","2021-04-01T16:03:25","1.50","0.00","0.00","3","Completed","1",,,,,,,,,,"2021-04-01T16:02:04","2021-04-01T16:02:04","Carb","Standard","100.00","0","0","1","110","30.00","0.00","0.00","1.50","0","0","Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units","0","1182026","",
"Bolus","Standard/Correction","168","1.71","7004.000","7004.000","2021-04-01T16:24:08","2.00","0.00","0.00","3","Completed","1",,,,,,,,,,"2021-04-01T16:22:21","2021-04-01T16:22:21","Carb","Standard/Correction","100.00","0","0","1","110","30.00","0.00","0.22","2.00","0","0","Correction & Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.2 units","0","1182082","",
"Bolus","Standard","220","3.98","7032.000","7032.000","2021-04-02T23:16:24","2.50","0.00","0.00","3","Completed","1",,,,,,,,,,"2021-04-02T23:14:33","2021-04-02T23:14:33","Carb","Standard","100.00","0","0","1","110","30.00","0.00","0.00","2.50","0","0","Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units","0","1185846","",
"""
RAW_DATA_FULL = RAW_DATA_HEADER + "\n" + RAW_DATA_CGM + "\n" + RAW_DATA_IOB + "\n" + RAW_DATA_BOLUS
PARSED_DATA = {
'readingData': [
{"DeviceType": "t:slim X2 Insulin Pump", "SerialNumber": "11111111", "Description": "EGV", "EventDateTime": "2021-04-01T00:01:33", "Readings (CGM / BGM)": "235"},
{"DeviceType": "t:slim X2 Insulin Pump", "SerialNumber": "11111111", "Description": "EGV", "EventDateTime": "2021-04-01T00:06:33", "Readings (CGM / BGM)": "230"},
{"DeviceType": "t:slim X2 Insulin Pump", "SerialNumber": "11111111", "Description": "EGV", "EventDateTime": "2021-04-02T23:31:36", "Readings (CGM / BGM)": "181"}
],
'iobData': [
{"Type": "IOB", "EventID": "81", "EventDateTime": "2021-04-01T00:00:19", "IOB": "13.24"},
{"Type": "IOB", "EventID": "9", "EventDateTime": "2021-04-01T00:03:12", "IOB": "12.80"},
{"Type": "IOB", "EventID": "81", "EventDateTime": "2021-04-02T23:58:19", "IOB": "4.25"},
],
'basalData': [],
'bolusData': [
{"Type": "Bolus", "Description": "Standard/Correction", "BG": "141", "IOB": "", "BolusRequestID": "7001.000", "BolusCompletionID": "7001.000", "CompletionDateTime": "2021-04-01T12:58:26", "InsulinDelivered": "13.53", "FoodDelivered": "12.50", "CorrectionDelivered": "1.03", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-01T12:53:36", "RequestDateTime": "2021-04-01T12:53:36", "BolusType": "Carb", "BolusRequestOptions": "Standard/Correction", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "75", "UserOverride": "0", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "12.50", "CorrectionBolusSize": "1.03", "ActualTotalBolusRequested": "13.53", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Correction & Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110", "IndexID": "0", "Note": "1181649"},
{"Type": "Bolus", "Description": "Standard", "BG": "131", "IOB": "0.71", "BolusRequestID": "7003.000", "BolusCompletionID": "7003.000", "CompletionDateTime": "2021-04-01T16:03:25", "InsulinDelivered": "1.50", "FoodDelivered": "0.00", "CorrectionDelivered": "0.00", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-01T16:02:04", "RequestDateTime": "2021-04-01T16:02:04", "BolusType": "Carb", "BolusRequestOptions": "Standard", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "0", "UserOverride": "1", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "0.00", "CorrectionBolusSize": "0.00", "ActualTotalBolusRequested": "1.50", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units", "IndexID": "0", "Note": "1182026"},
{"Type": "Bolus", "Description": "Standard/Correction", "BG": "168", "IOB": "1.71", "BolusRequestID": "7004.000", "BolusCompletionID": "7004.000", "CompletionDateTime": "2021-04-01T16:24:08", "InsulinDelivered": "2.00", "FoodDelivered": "0.00", "CorrectionDelivered": "0.00", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-01T16:22:21", "RequestDateTime": "2021-04-01T16:22:21", "BolusType": "Carb", "BolusRequestOptions": "Standard/Correction", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "0", "UserOverride": "1", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "0.00", "CorrectionBolusSize": "0.22", "ActualTotalBolusRequested": "2.00", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Correction & Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.2 units", "IndexID": "0", "Note": "1182082"},
{"Type": "Bolus", "Description": "Standard", "BG": "220", "IOB": "3.98", "BolusRequestID": "7032.000", "BolusCompletionID": "7032.000", "CompletionDateTime": "2021-04-02T23:16:24", "InsulinDelivered": "2.50", "FoodDelivered": "0.00", "CorrectionDelivered": "0.00", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-02T23:14:33", "RequestDateTime": "2021-04-02T23:14:33", "BolusType": "Carb", "BolusRequestOptions": "Standard", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "0", "UserOverride": "1", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "0.00", "CorrectionBolusSize": "0.00", "ActualTotalBolusRequested": "2.50", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units", "IndexID": "0", "Note": "1185846"}
]
}
def test_therapy_timeline_csv_parses_full(self):
ws2 = WS2Api()
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
rawData = self.RAW_DATA_FULL
def fake_get(endpoint, query):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
return rawData
ws2.get = fake_get
tt = ws2.therapy_timeline_csv('2021-04-01', '2021-04-02')
self.assertDictEqual(tt, self.PARSED_DATA)
def test_therapy_timeline_csv_parses_random_order(self):
ws2 = WS2Api()
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
rawData = ""
def fake_get(endpoint, query):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
return rawData
ws2.get = fake_get
# Randomize the order of all sections
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')
self.assertDictEqual(tt, self.PARSED_DATA)
if __name__ == '__main__':
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
import collections
import tconnectsync.nightscout
class NightscoutApi(tconnectsync.nightscout.NightscoutApi):
def __init__(self):
self.url = 'invalid://'
self.secret = 'invalid'
self.uploaded_entries = collections.defaultdict(list)
self.deleted_entries = collections.defaultdict(list)
self.put_entries = collections.defaultdict(list)
def upload_entry(self, ns_format, entity='treatments'):
self.uploaded_entries[entity].append(ns_format)
def delete_entry(self, ns_format, entity):
self.deleted_entries[entity].append(ns_format)
def put_entry(self, ns_format, entity):
self.put_entries[entity].append(ns_format)
def last_uploaded_entry(self, eventType):
raise NotImplementedError
def last_uploaded_activity(self, activityType):
raise NotImplementedError
def api_status(self):
raise NotImplementedError
View File
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.parser.nightscout import NightscoutEntry
class TestNightscoutEntry(unittest.TestCase):
def test_basal(self):
self.assertEqual(
NightscoutEntry.basal(
value=1.05,
duration_mins=30,
created_at="2021-03-16 00:25:21-04:00"),
{
"eventType": "Temp Basal",
"reason": "",
"duration": 30,
"absolute": 1.05,
"rate": 1.05,
"created_at": "2021-03-16 00:25:21-04:00",
"carbs": None,
"insulin": None,
"enteredBy": "Pump (tconnectsync)"
}
)
self.assertEqual(
NightscoutEntry.basal(
value=0.95,
duration_mins=5,
created_at="2021-03-16 12:25:21-04:00",
reason="Correction"),
{
"eventType": "Temp Basal",
"reason": "Correction",
"duration": 5,
"absolute": 0.95,
"rate": 0.95,
"created_at": "2021-03-16 12:25:21-04:00",
"carbs": None,
"insulin": None,
"enteredBy": "Pump (tconnectsync)"
}
)
def test_bolus(self):
self.assertEqual(
NightscoutEntry.bolus(
bolus=7.5,
carbs=45,
created_at="2021-03-16 00:25:21-04:00"),
{
"eventType": "Combo Bolus",
"created_at": "2021-03-16 00:25:21-04:00",
"carbs": 45,
"insulin": 7.5,
"notes": "",
"enteredBy": "Pump (tconnectsync)"
}
)
self.assertEqual(
NightscoutEntry.bolus(
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00"),
{
"eventType": "Combo Bolus",
"created_at": "2021-03-16 12:25:21-04:00",
"carbs": 5,
"insulin": 0.5,
"notes": "",
"enteredBy": "Pump (tconnectsync)"
}
)
def test_iob(self):
self.assertEqual(
NightscoutEntry.iob(
iob=2.05,
created_at="2021-03-16 00:25:21-04:00"),
{
"activityType": "tconnect_iob",
"iob": 2.05,
"created_at": "2021-03-16 00:25:21-04:00",
"enteredBy": "Pump (tconnectsync)"
}
)
if __name__ == '__main__':
unittest.main()
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.parser.tconnect import TConnectEntry
class TestTConnectEntryBasal(unittest.TestCase):
def test_parse_ciq_basal_entry(self):
self.assertEqual(
TConnectEntry.parse_ciq_basal_entry({
"y": 0.8,
"duration": 1221,
"x": 1615878000
}),
{
"time": "2021-03-16 00:00:00-04:00",
"delivery_type": "",
"duration_mins": 1221/60,
"basal_rate": 0.8,
}
)
self.assertEqual(
TConnectEntry.parse_ciq_basal_entry({
"y": 0.797,
"duration": 300,
"x": 1615879521
}, delivery_type="algorithmDelivery"),
{
"time": "2021-03-16 00:25:21-04:00",
"delivery_type": "algorithmDelivery",
"duration_mins": 5,
"basal_rate": 0.797,
}
)
class TestTConnectEntryBolus(unittest.TestCase):
entryStdCorrection = {
"Type": "Bolus",
"Description": "Standard/Correction",
"BG": "141",
"IOB": "",
"BolusRequestID": "7001.000",
"BolusCompletionID": "7001.000",
"CompletionDateTime": "2021-04-01T12:58:26",
"InsulinDelivered": "13.53",
"FoodDelivered": "12.50",
"CorrectionDelivered": "1.03",
"CompletionStatusID": "3",
"CompletionStatusDesc": "Completed",
"BolusIsComplete": "1",
"BolexCompletionID": "",
"BolexSize": "",
"BolexStartDateTime": "",
"BolexCompletionDateTime": "",
"BolexInsulinDelivered": "",
"BolexIOB": "",
"BolexCompletionStatusID": "",
"BolexCompletionStatusDesc": "",
"ExtendedBolusIsComplete": "",
"EventDateTime": "2021-04-01T12:53:36",
"RequestDateTime": "2021-04-01T12:53:36",
"BolusType": "Carb",
"BolusRequestOptions": "Standard/Correction",
"StandardPercent": "100.00",
"Duration": "0",
"CarbSize": "75",
"UserOverride": "0",
"TargetBG": "110",
"CorrectionFactor": "30.00",
"FoodBolusSize": "12.50",
"CorrectionBolusSize": "1.03",
"ActualTotalBolusRequested": "13.53",
"IsQuickBolus": "0",
"EventHistoryReportEventDesc": "0",
"EventHistoryReportDetails": "Correction & Food Bolus",
"NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"IndexID": "0",
"Note": "1181649"
}
def test_parse_bolus_entry_std_correction(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdCorrection),
{
"description": "Standard/Correction",
"complete": "1",
"completion": "Completed",
"request_time": "2021-04-01 12:53:36-04:00",
"completion_time": "2021-04-01 12:58:26-04:00",
"insulin": "13.53",
"carbs": "75",
"user_override": "0",
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
entryStd = {
"Type": "Bolus",
"Description": "Standard",
"BG": "159",
"IOB": "2.13",
"BolusRequestID": "7007.000",
"BolusCompletionID": "7007.000",
"CompletionDateTime": "2021-04-01T23:23:17",
"InsulinDelivered": "1.25",
"FoodDelivered": "0.00",
"CorrectionDelivered": "0.00",
"CompletionStatusID": "3",
"CompletionStatusDesc": "Completed",
"BolusIsComplete": "1",
"BolexCompletionID": "",
"BolexSize": "",
"BolexStartDateTime": "",
"BolexCompletionDateTime": "",
"BolexInsulinDelivered": "",
"BolexIOB": "",
"BolexCompletionStatusID": "",
"BolexCompletionStatusDesc": "",
"ExtendedBolusIsComplete": "",
"EventDateTime": "2021-04-01T23:21:58",
"RequestDateTime": "2021-04-01T23:21:58",
"BolusType": "Carb",
"BolusRequestOptions": "Standard",
"StandardPercent": "100.00",
"Duration": "0",
"CarbSize": "0",
"UserOverride": "1",
"TargetBG": "110",
"CorrectionFactor": "30.00",
"FoodBolusSize": "0.00",
"CorrectionBolusSize": "0.00",
"ActualTotalBolusRequested": "1.25",
"IsQuickBolus": "0",
"EventHistoryReportEventDesc": "0",
"EventHistoryReportDetails": "Food Bolus",
"NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units",
"IndexID": "0",
"Note": "1182867"
}
def test_parse_bolus_entry_std(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStd),
{
"description": "Standard",
"complete": "1",
"completion": "Completed",
"request_time": "2021-04-01 23:21:58-04:00",
"completion_time": "2021-04-01 23:23:17-04:00",
"insulin": "1.25",
"carbs": "0",
"user_override": "1",
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
entryStdAutomatic = {
"Type": "Bolus",
"Description": "Automatic Bolus/Correction",
"BG": "",
"IOB": "3.24",
"BolusRequestID": "7010.000",
"BolusCompletionID": "7010.000",
"CompletionDateTime": "2021-04-02T01:00:47",
"InsulinDelivered": "1.70",
"FoodDelivered": "0.00",
"CorrectionDelivered": "1.70",
"CompletionStatusID": "3",
"CompletionStatusDesc": "Completed",
"BolusIsComplete": "1",
"BolexCompletionID": "",
"BolexSize": "",
"BolexStartDateTime": "",
"BolexCompletionDateTime": "",
"BolexInsulinDelivered": "",
"BolexIOB": "",
"BolexCompletionStatusID": "",
"BolexCompletionStatusDesc": "",
"ExtendedBolusIsComplete": "",
"EventDateTime": "2021-04-02T00:59:13",
"RequestDateTime": "2021-04-02T00:59:13",
"BolusType": "Automatic Correction",
"BolusRequestOptions": "Automatic Bolus/Correction",
"StandardPercent": "100.00",
"Duration": "0",
"CarbSize": "0",
"UserOverride": "0",
"TargetBG": "160",
"CorrectionFactor": "30.00",
"FoodBolusSize": "0.00",
"CorrectionBolusSize": "1.70",
"ActualTotalBolusRequested": "1.70",
"IsQuickBolus": "0",
"EventHistoryReportEventDesc": "0",
"EventHistoryReportDetails": "Correction Bolus",
"NoteID": "CF 1:30 - Carb Ratio 1:0 - Target BG 160",
"IndexID": "0",
"Note": "1183132"
}
def test_parse_bolus_entry_std_automatic(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdAutomatic),
{
"description": "Automatic Bolus/Correction",
"complete": "1",
"completion": "Completed",
"request_time": "2021-04-02 00:59:13-04:00",
"completion_time": "2021-04-02 01:00:47-04:00",
"insulin": "1.70",
"carbs": "0",
"user_override": "0",
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
if __name__ == '__main__':
unittest.main()
View File
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.basal import process_ciq_basal_events
from tconnectsync.parser.tconnect import TConnectEntry
class TestBasalSync(unittest.TestCase):
base = {
"basal": {
"profileRates": [],
"tempDeliveryEvents": [],
"algorithmDeliveryEvents": [],
"profileDeliveryEvents": []
},
"events": [],
"suspensionDeliveryEvents": [],
"softwareUpdates": [],
"pumpFeatures": []
}
@staticmethod
def get_example_ciq_basal_events():
data = TestBasalSync.base.copy()
data["basal"]["tempDeliveryEvents"] = [
{
"y": 0.8,
"duration": 1221,
"x": 1615878000
}
]
data["basal"]["algorithmDeliveryEvents"] = [
{
"y": 0.797,
"duration": 300,
"x": 1615879521
},
{
"y": 0,
"duration": 2693,
"x": 1615879821
},
]
data["basal"]["profileDeliveryEvents"] = [
{
"y": 0.799,
"duration": 300,
"x": 1615879221
}
]
data["suspensionDeliveryEvents"] = [
{
"suspendReason": "control-iq",
"continuation": None,
"x": 1615879821
},
]
return data
def test_process_ciq_basal_events(self):
data = TestBasalSync.get_example_ciq_basal_events()
basalEvents = process_ciq_basal_events(data)
self.assertEqual(len(basalEvents), 4)
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["tempDeliveryEvents"][0], delivery_type="tempDelivery"))
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["profileDeliveryEvents"][0], delivery_type="profileDelivery"))
self.assertEqual(basalEvents[2], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[3], {
"suspendReason": "control-iq",
**TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][1],
delivery_type="algorithmDelivery")
})
if __name__ == '__main__':
unittest.main()
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.bolus import process_bolus_events
from tconnectsync.parser.tconnect import TConnectEntry
from ..parser.test_tconnect import TestTConnectEntryBolus
class TestBolusSync(unittest.TestCase):
@staticmethod
def get_example_csv_bolus_events():
return [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
def test_process_bolus_events(self):
bolusData = TestBolusSync.get_example_csv_bolus_events()
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), 3)
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(bolusData[0]),
TConnectEntry.parse_bolus_entry(bolusData[1]),
TConnectEntry.parse_bolus_entry(bolusData[2])
])
if __name__ == '__main__':
unittest.main()
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
import unittest
import datetime
import pprint
from tconnectsync.process import process_time_range
from tconnectsync.parser.nightscout import NightscoutEntry
from .api.fake import TConnectApi
from .nightscout_fake import NightscoutApi
from .sync.test_basal import TestBasalSync
from .sync.test_bolus import TestBolusSync
class TestProcessTimeRange(unittest.TestCase):
maxDiff = None
def stub_therapy_timeline(self, time_start, time_end):
pass
def stub_therapy_timeline_csv(self, time_start, time_end):
return {
"readingData": [],
"iobData": [],
"basalData": [],
"bolusData": []
}
def stub_last_uploaded_entry(self, event_type):
return None
def stub_last_uploaded_activity(self, activity_type):
return None
"""No data in Nightscout. Uploads all basal data from tconnect."""
def test_new_ciq_basal_data(self):
tconnect = TConnectApi()
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 4)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.basal(0.8, 20.35, "2021-03-16 00:00:00-04:00", reason="tempDelivery"),
NightscoutEntry.basal(0.799, 5.0, "2021-03-16 00:20:21-04:00", reason="profileDelivery"),
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertDictEqual(nightscout.deleted_entries, {})
"""Two basal entries in Nightscout. Two new basal entries in tconnect."""
def test_partial_ciq_basal_data(self):
tconnect = TConnectApi()
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
if event_type == "Temp Basal":
return {
"created_at": "2021-03-16 00:20:21-04:00",
"duration": 5
}
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertDictEqual(nightscout.deleted_entries, {})
"""
Two basal entries in Nightscout, the latter which needs to be updated
with a longer duration. Two entirely new entries in tconnect."""
def test_with_updated_duration_ciq_basal_data(self):
tconnect = TConnectApi()
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
if event_type == "Temp Basal":
return {
"created_at": "2021-03-16 00:20:21-04:00",
"duration": 3,
"_id": "nightscout_id"
}
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(nightscout.uploaded_entries, {
"treatments": [
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
]})
self.assertEqual(len(nightscout.put_entries["treatments"]), 1)
self.assertDictEqual(dict(nightscout.put_entries), {
"treatments": [
{
"_id": "nightscout_id",
**NightscoutEntry.basal(0.799, 5.0, "2021-03-16 00:20:21-04:00", reason="profileDelivery")
}
]
})
self.assertDictEqual(nightscout.deleted_entries, {})
"""No data in Nightscout. Uploads all bolus data from tconnect."""
def test_new_ciq_bolus_data(self):
tconnect = TConnectApi()
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"bolusData": TestBolusSync.get_example_csv_bolus_events(),
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False)
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 3)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.bolus(13.53, 75, "2021-04-01 12:58:26-04:00", notes="Standard/Correction"),
NightscoutEntry.bolus(1.25, 0, "2021-04-01 23:23:17-04:00", notes="Standard (Override)"),
NightscoutEntry.bolus(1.7, 0, "2021-04-02 01:00:47-04:00", notes="Automatic Bolus/Correction"),
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertDictEqual(nightscout.deleted_entries, {})
if __name__ == '__main__':
unittest.main()