Compare commits

...
31 Commits
Author SHA1 Message Date
James Woglom bcd486561f dockerfile: fix build 2021-10-20 01:01:47 -04:00
James Woglom d931242562 setup.cfg: set version to 0.4.0 2021-10-20 00:51:26 -04:00
James Woglom bec1cef1f1 dockerfile: attempt to fix build 2021-10-20 00:48:46 -04:00
James Woglom 82ec1b4c1b pypi: add github action 2021-10-20 00:43:28 -04:00
James Woglom b6569edabc configure console_script for tconnectsync, move main code to inside package 2021-10-20 00:34:58 -04:00
James Woglom 2e5d8fc793 add setup.cfg/setup.py/pyproject.toml 2021-10-20 00:15:23 -04:00
James Woglom c33a674bad iob: add e2e tests for IOB behavior 2021-10-14 02:02:57 -04:00
James Woglom 3be0289177 bolus: add tests for partial entries, propagate requested_insulin 2021-10-14 02:02:57 -04:00
James Woglom 87f531cbb3 tests: add iob test, fix sleep increment in ws2 tests 2021-10-14 02:02:57 -04:00
James WoglomandGitHub 795b5ce240 Merge pull request #15 from policetonyR/patch-1
Remove PatientObjectId
2021-09-10 20:36:02 -04:00
TonyandGitHub 3a004183bb Remove PatientObjectId
After trying to run auto update, I was met with an error saying that patientObjectId was not found, I commented the code out and the auto update ran. I propose that this line is removed as I do not see any use for the variable anywhere.
2021-09-09 15:06:29 -04:00
James Woglom 7d813ee8fb main: update logging config to show timestamps 2021-07-09 21:49:34 -04:00
James Woglom d0366cc064 sync/bolus: fix bug when synchronizing extended bolus 2021-07-09 21:30:23 -04:00
James Woglom f2f7b1c0e1 api: add basalsuspension, pumpfeatures api endpoints 2021-07-09 21:29:06 -04:00
James Woglom 855e50358a bugfix: fix TypeError if last_process_time_range is None, fix printf 2021-05-06 13:30:26 -04:00
James Woglom 4afb35e235 bugfix: autoupdate 2021-05-04 02:03:36 -04:00
James Woglom 655e66a8aa bug: fix string format 2021-05-04 02:00:27 -04:00
James Woglom 380e4f00b6 parser/nightscout: add rate field for temp basals for autotune compatibility (fixes #7) 2021-05-03 23:59:30 -04:00
James Woglom f9ecaedf4d tests/controliq: fix fake access token expiration in tests. remove warns for warnings 2021-05-03 23:59:30 -04:00
James Woglom faf54b1f47 autoupdate: add new config options if no data received, log pretty timestamps 2021-05-03 23:59:30 -04:00
James Woglom 1733b0e139 logging: use logger instead of prints, improve logging throughout 2021-05-03 23:59:30 -04:00
James Woglom acfe44881b add .codecov.yml 2021-04-26 23:14:58 -04:00
James Woglom 3fc9cdeea3 add .codecov.yml 2021-04-26 23:14:43 -04:00
James Woglom 33df18d4c9 api/android: add tests for last_event_uploaded and retry behavior 2021-04-26 23:07:32 -04:00
James Woglom 05c19fffa7 api/controliq: add login tests 2021-04-26 22:50:33 -04:00
James Woglom 878cd5b247 api/controliq: retry/relogin on http errors, add tests 2021-04-26 22:50:33 -04:00
James WoglomandGitHub 9c8e5c150c Add codecov badge 2021-04-24 23:36:38 -04:00
James WoglomandGitHub 01d9517ff4 Add codecov 2021-04-24 23:24:01 -04:00
James Woglom e7816c4aa0 tests/api: test ws2 therapy_timeline_csv parsing 2021-04-24 23:12:36 -04:00
James Woglom 18f1149b28 tests: add ws2 api tests for retries 2021-04-24 22:48:45 -04:00
James Woglom fe941c432f api/ws2: add retry logic for http 500s in therapy_timeline_csv (fix #9) 2021-04-24 22:27:07 -04:00
38 changed files with 1476 additions and 183 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%
+35
View File
@@ -0,0 +1,35 @@
name: Publish to PyPI
on:
push:
branches: [ master ]
jobs:
build-binary:
name: Build Binary and Publish to PyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.9
- name: Install pypa/build
run: >-
python -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}
+8
View File
@@ -40,3 +40,11 @@ jobs:
- 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
View File
@@ -1,6 +1,9 @@
__pycache__
dist
build
*.pyc
*.pyo
*.swp
*.swa
*.egg-info
.env
+8 -2
View File
@@ -15,16 +15,22 @@ FROM base AS python-deps
RUN pip install pipenv
RUN apt-get update && apt-get install -y --no-install-recommends gcc
RUN mkdir -p /base
WORKDIR /base
# Install python dependencies in /.venv
COPY Pipfile .
COPY Pipfile.lock .
COPY setup.cfg .
COPY setup.py .
COPY pyproject.toml .
RUN PIPENV_VENV_IN_PROJECT=1 pipenv install --deploy
FROM base AS runtime
# Copy virtualenv from python-deps stage
COPY --from=python-deps /.venv /.venv
ENV PATH="/.venv/bin:$PATH"
COPY --from=python-deps /base/.venv /base/.venv
ENV PATH="/base/.venv/bin:$PATH"
# Create and switch to a new user
RUN useradd --create-home appuser
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 James Woglom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4 -1
View File
@@ -4,14 +4,17 @@ url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
ptpython = "*"
[packages]
tconnectsync = {editable = true, path = "."}
requests = "*"
bs4 = "*"
arrow = "*"
lxml = "*"
python-dotenv = "*"
requests-mock = "*"
[scripts]
tconnectsync = "python3 main.py"
test = "python3 -m unittest discover -vv"
test = "python3 -m unittest discover -vv"
Generated
+116 -37
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "071afba23de7f532f99a0611caed63951c6f7ba968ba877ad5c76c244e78577e"
"sha256": "413f8bbd66e702656262deda1b6586d42b5a2e9fa52f6d23416b1ea1efbc2591"
},
"pipfile-spec": 6,
"requires": {},
@@ -16,19 +16,19 @@
"default": {
"arrow": {
"hashes": [
"sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543",
"sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"
"sha256:16fc29bbd9e425e3eb0fef3018297910a0f4568f21116fc31771e2760a50e074",
"sha256:8fb7d9d3d4bf90e49e734c22fa077bdd0964135c4b8120de2510575a8d1f620c"
],
"index": "pypi",
"version": "==1.0.3"
"version": "==1.2.0"
},
"beautifulsoup4": {
"hashes": [
"sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35",
"sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25",
"sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"
"sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf",
"sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"
],
"version": "==4.9.3"
"markers": "python_version >= '3.1'",
"version": "==4.10.0"
},
"bs4": {
"hashes": [
@@ -39,44 +39,51 @@
},
"certifi": {
"hashes": [
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
],
"version": "==2020.12.5"
"version": "==2021.10.8"
},
"chardet": {
"charset-normalizer": {
"hashes": [
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
"sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0",
"sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==4.0.0"
"markers": "python_version >= '3'",
"version": "==2.0.7"
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
"markers": "python_version >= '3'",
"version": "==3.3"
},
"lxml": {
"hashes": [
"sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d",
"sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3",
"sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2",
"sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae",
"sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f",
"sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927",
"sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3",
"sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7",
"sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59",
"sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f",
"sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade",
"sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96",
"sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468",
"sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b",
"sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4",
"sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354",
"sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83",
"sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04",
"sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16",
"sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4",
"sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791",
"sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a",
"sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51",
"sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1",
"sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a",
@@ -89,10 +96,15 @@
"sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa",
"sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106",
"sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d",
"sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d",
"sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617",
"sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4",
"sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92",
"sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0",
"sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4",
"sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24",
"sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2",
"sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e",
"sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0",
"sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654",
"sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2",
@@ -104,52 +116,119 @@
},
"python-dateutil": {
"hashes": [
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.1"
"version": "==2.8.2"
},
"python-dotenv": {
"hashes": [
"sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a",
"sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"
"sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8",
"sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a"
],
"index": "pypi",
"version": "==0.17.0"
"version": "==0.19.1"
},
"requests": {
"hashes": [
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
"sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
"sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
],
"index": "pypi",
"version": "==2.25.1"
"version": "==2.26.0"
},
"requests-mock": {
"hashes": [
"sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970",
"sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba"
],
"index": "pypi",
"version": "==1.9.3"
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
"version": "==1.16.0"
},
"soupsieve": {
"hashes": [
"sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc",
"sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"
],
"markers": "python_version >= '3.0'",
"markers": "python_version >= '3.6'",
"version": "==2.2.1"
},
"tconnectsync": {
"editable": true,
"path": "."
},
"urllib3": {
"hashes": [
"sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
"sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
"sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
"sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
],
"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.4"
"version": "==1.26.7"
}
},
"develop": {}
"develop": {
"appdirs": {
"hashes": [
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
],
"version": "==1.4.4"
},
"jedi": {
"hashes": [
"sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93",
"sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"
],
"markers": "python_version >= '3.6'",
"version": "==0.18.0"
},
"parso": {
"hashes": [
"sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398",
"sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"
],
"markers": "python_version >= '3.6'",
"version": "==0.8.2"
},
"prompt-toolkit": {
"hashes": [
"sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c",
"sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c"
],
"markers": "python_full_version >= '3.6.2'",
"version": "==3.0.20"
},
"ptpython": {
"hashes": [
"sha256:99636899ab0e4d026e2ecc9368269114f387b4bb5411e57f072b0bde724d9f99",
"sha256:eafd4ced27ca5dc370881d4358d1ab5041b32d88d31af8e3c24167fe4af64ed6"
],
"index": "pypi",
"version": "==3.0.20"
},
"pygments": {
"hashes": [
"sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380",
"sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"
],
"markers": "python_version >= '3.5'",
"version": "==2.10.0"
},
"wcwidth": {
"hashes": [
"sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
"sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
],
"version": "==0.2.5"
}
}
}
+1
View File
@@ -1,6 +1,7 @@
# 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.
+1 -65
View File
@@ -1,69 +1,5 @@
#!/usr/bin/env python3
import sys
import datetime
import arrow
import argparse
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 tconnectsync.secret import (
TCONNECT_EMAIL,
TCONNECT_PASSWORD,
NS_URL,
NS_SECRET
)
except Exception:
print('Unable to read secret.py')
sys.exit(1)
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('--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.auto_update and (args.start_date or args.end_date):
raise Exception('Auto-update cannot be used with start/end date')
if args.start_date and args.end_date:
time_start = arrow.get(args.start_date)
time_end = arrow.get(args.end_date)
else:
time_end = datetime.datetime.now()
time_start = time_end - datetime.timedelta(days=args.days)
if time_end < time_start:
raise Exception('time_start must be before time_end')
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:
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, "(PRETEND)" if args.pretend else "")
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend)
print("Added", added, "items")
from tconnectsync import main
if __name__ == '__main__':
main()
+6
View File
@@ -0,0 +1,6 @@
[build-system]
requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta"
+37
View File
@@ -0,0 +1,37 @@
[metadata]
name = tconnectsync
version = 0.4.0
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/jwoglom/tconnectsync
project_urls =
Bug Tracker = https://github.com/jwoglom/tconnectsync/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[options]
package_dir =
= .
packages = find:
python_requires = >=3.6
install_requires =
requests
bs4
arrow
lxml
python-dotenv
[options.packages.find]
where = .
exclude =
tests*
scripts*
[options.entry_points]
console_scripts =
tconnectsync = tconnectsync:main
+3
View File
@@ -0,0 +1,3 @@
from setuptools import setup
setup()
+86
View File
@@ -0,0 +1,86 @@
import sys
import datetime
import arrow
import argparse
import logging
import pkg_resources
from .api import TConnectApi
from .process import process_time_range
from .autoupdate import process_auto_update
from .check import check_login
from .nightscout import NightscoutApi
try:
from .secret import (
TCONNECT_EMAIL,
TCONNECT_PASSWORD,
NS_URL,
NS_SECRET
)
except Exception:
print('Unable to read secret.py')
sys.exit(1)
try:
__version__ = pkg_resources.require("tconnectsync")[0].version
except Exception:
__version__ = "UNKNOWN"
def parse_args(*args, **kwargs):
parser = argparse.ArgumentParser(description="Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.")
parser.add_argument('--version', action='version', version='tconnectsync %s' % __version__)
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(*args, **kwargs)
def main(*args, **kwargs):
args = parse_args(*args, **kwargs)
if args.verbose:
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s %(levelname)-8s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
logging.root.debug("Set logging level to DEBUG")
else:
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)-8s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
if args.auto_update and (args.start_date or args.end_date):
raise Exception('Auto-update cannot be used with start/end date')
if args.start_date and args.end_date:
time_start = arrow.get(args.start_date)
time_end = arrow.get(args.end_date)
else:
time_end = datetime.datetime.now()
time_start = time_end - datetime.timedelta(days=args.days)
if time_end < time_start:
raise Exception('time_start must be before time_end')
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:
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, "(PRETEND)" if args.pretend else "")
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend)
print("Added", added, "items")
+14
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
@@ -21,6 +25,8 @@ class TConnectApi:
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
+32 -3
View File
@@ -5,11 +5,16 @@ 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
@@ -34,6 +39,8 @@ class AndroidApi:
def __init__(self, email, password):
self.login(email, password)
self._email = email
self._password = password
def login(self, email, password):
r = requests.post(
@@ -62,7 +69,8 @@ class AndroidApi:
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())
@@ -73,12 +81,33 @@ class AndroidApi:
raise Exception('No access token')
return {'Authorization': 'Bearer %s' % self.accessToken}
def get(self, endpoint, query={}, **kwargs):
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))
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:
+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
+56 -13
View File
@@ -2,11 +2,16 @@ 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'
@@ -17,23 +22,16 @@ class ControlIQApi:
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 = {
"__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)
}
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.')
@@ -46,8 +44,23 @@ class ControlIQApi:
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)
@@ -57,12 +70,35 @@ class ControlIQApi:
raise Exception('No access token provided')
return {'Authorization': 'Bearer %s' % self.accessToken, **base_headers()}
def get(self, endpoint, query):
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.
"""
@@ -90,3 +126,10 @@ class ControlIQApi:
"startDate": startDate,
"endDate": endDate
})
"""
Returns active account features, including the date when ControlIQ was enabled.
[{"serialNumber": "11111111", "features": {"controlIQ": {"feature": 1, "dateTimeFirstDetected": "YYYY-MM-DD:THH:MM:SS", "unixTimestamp": 1111111111}}}]
"""
def pumpfeatures(self):
return self.get('pumpfeatures/users/%s' % self.userGuid, {})
+47 -2
View File
@@ -1,12 +1,19 @@
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
SLEEP_SECONDS_INCREMENT = 60
userGuid = None
def __init__(self, userGuid):
@@ -54,11 +61,31 @@ class WS2Api:
return data
def therapy_timeline_csv(self, start=None, end=None):
"""
Returns information on therapy, displayed in the therapy timeline on the
t:connect website.
Contains BG reading (CGM), IOB, basal, and bolus data.
Basal data does NOT appear for the specified time range if using Control-IQ.
The ControlIQ API endpoints must be used for basal data instead.
However, all other fields are still accessed via this endpoint.
"""
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) * self.SLEEP_SECONDS_INCREMENT
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)
@@ -86,6 +113,24 @@ class WS2Api:
"basalData": self._csv_to_dict(basalData),
"bolusData": self._csv_to_dict(bolusData)
}
"""
Returns information on basal suspension. The filterbasal option only returns site/cartridge changes.
SuspendReason values are:
- "site-cart"
- "basal-profile"
- "manual"
- "previous"
- "alarm"
{"BasalSuspension":[{"EventDateTime":"/Date(EPOCH_MS-0000)/", "SuspendReason": "reason"}]}
"""
def basalsuspension(self, start=None, end=None, filterbasal=False):
startDate = parse_date(start)
endDate = parse_date(end)
arg = "filterbasal/1" if filterbasal else ""
return self.get_jsonp('basalsuspension/%s/%s/%s/%s' % (self.userGuid, startDate, endDate, arg))
"""
Returns info on BasalIQ in JSONP format.
+42 -9
View File
@@ -1,13 +1,19 @@
import time
import logging
import sys
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_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).
@@ -18,30 +24,54 @@ def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
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()
print('New event index:', last_event['maxPumpEventIndex'], 'last:', last_event_index)
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], last_event_index))
if pretend:
print('Would update now')
logger.info('Would update now if not in pretend mode')
else:
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend)
print('Added', added, 'items')
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)
print('Time diffs:', time_diffs)
logger.debug('Updating tracking of time since last update: %s' % time_diffs)
last_event_index = last_event['maxPumpEventIndex']
last_event_time = now
else:
print('No event index change:', last_event['maxPumpEventIndex'])
logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex'])
now = time.time()
if last_event_time and (now - last_event_time) >= 60 * AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateFailureException("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:
sys.exit(1)
elif last_process_time_range and (now - last_process_time_range) >= 60 * AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateFailureException("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:
sys.exit(1)
if len(time_diffs) > 2:
print('Sleeping 60 seconds after unexpected no index change')
logger.info('Sleeping 60 seconds after unexpected no index change. (New data might be delayed.)')
time.sleep(60)
continue
@@ -57,5 +87,8 @@ def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
sleep_secs = AUTOUPDATE_MAX_SLEEP_SECONDS
# Sleep for a rolling average of time between updates
print('Sleeping for', sleep_secs, 'sec')
time.sleep(sleep_secs)
logger.info('Sleeping for %d sec' % sleep_secs)
time.sleep(sleep_secs)
class AutoupdateFailureException(RuntimeError):
pass
+2 -1
View File
@@ -14,6 +14,7 @@ class NightscoutEntry:
"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,
@@ -35,7 +36,7 @@ class NightscoutEntry:
def iob(iob, created_at):
return {
"activityType": IOB_ACTIVITYTYPE,
"iob": iob,
"iob": float(iob),
"created_at": created_at,
"enteredBy": ENTERED_BY
}
+7 -3
View File
@@ -85,16 +85,20 @@ class TConnectEntry:
@staticmethod
def parse_bolus_entry(data):
# All DateTime's are stored in the user's timezone.
complete = (data["ExtendedBolusIsComplete"] or data["BolusIsComplete"])
def is_complete(s):
return s and int(s) == 1
complete = is_complete(data["ExtendedBolusIsComplete"]) or is_complete(data["BolusIsComplete"])
extended_bolus = ("extended" in data["Description"].lower())
return {
"description": data["Description"],
"complete": "1" if complete else "",
"completion": data["CompletionStatusDesc"] if not extended_bolus else data["BolexCompletionStatusDesc"],
"request_time": TConnectEntry._datetime_parse(data["RequestDateTime"]).format() if complete and not extended_bolus else None,
"completion_time": TConnectEntry._datetime_parse(data["CompletionDateTime"]).format() if complete and not extended_bolus else None,
"request_time": TConnectEntry._datetime_parse(data["RequestDateTime"]).format() if not extended_bolus else None,
"completion_time": TConnectEntry._datetime_parse(data["CompletionDateTime"]).format() if not extended_bolus else None,
"insulin": data["InsulinDelivered"],
"requested_insulin": data["ActualTotalBolusRequested"],
"carbs": data["CarbSize"],
"user_override": data["UserOverride"],
"extended_bolus": "1" if extended_bolus else "",
+21 -5
View File
@@ -1,5 +1,9 @@
from datetime import datetime
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,
@@ -14,6 +18,9 @@ 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
@@ -21,7 +28,7 @@ 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):
print("Downloading t:connect ControlIQ data")
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
@@ -29,12 +36,12 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend):
# 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")
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqTherapyTimelineData = None
else:
raise e
print("Downloading t:connect CSV data")
logger.info("Downloading t:connect CSV data")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
readingData = csvdata["readingData"]
@@ -43,13 +50,21 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend):
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)
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)
@@ -59,4 +74,5 @@ def process_time_range(tconnect, nightscout, time_start, time_end, 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" % added)
return added
+10 -4
View File
@@ -15,6 +15,9 @@ def get_number(name, default):
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')
@@ -27,14 +30,17 @@ TIMEZONE_NAME = get('TIMEZONE_NAME', 'America/New_York')
# Optional configuration
AUTOUPDATE_DEFAULT_SLEEP_SECONDS = get_number('AUTOUPDATE_DEFAULT_SLEEP_SECONDS', '60')
AUTOUPDATE_MAX_SLEEP_SECONDS = get_number('AUTOUPDATE_MAX_SLEEP_SECONDS', '600')
AUTOUPDATE_USE_FIXED_SLEEP = get_number('AUTOUPDATE_USE_FIXED_SLEEP', '0')
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_USE_FIXED_SLEEP', 'AUTOUPDATE_FAILURE_MINUTES',
'AUTOUPDATE_RESTART_ON_FAILURE']
if __name__ == '__main__':
for k in locals():
+8 -4
View File
@@ -1,4 +1,5 @@
import arrow
import logging
from ..parser.nightscout import (
BASAL_EVENTTYPE,
@@ -6,6 +7,7 @@ from ..parser.nightscout import (
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Merges together input from the therapy timeline API
@@ -60,17 +62,18 @@ def add_csv_basal_events(basalEvents, data):
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"])
print("Last Nightscout basal upload:", last_upload_time)
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:
print("Skipping basal event before last upload time:", event)
logger.info("Skipping basal event before last upload time: %s" % event)
continue
recent_needs_update = False
@@ -97,13 +100,14 @@ def ns_write_basal_events(nightscout, basalEvents, pretend=False):
add_count += 1
print(" Processing basal:", event, "entry:", entry)
logger.info(" Processing basal: %s entry: %s" % (event, entry))
if recent_needs_update:
print("Replacing last uploaded entry:", last_upload)
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
+12 -7
View File
@@ -1,4 +1,5 @@
import arrow
import logging
from ..parser.nightscout import (
BOLUS_EVENTTYPE,
@@ -6,6 +7,8 @@ from ..parser.nightscout import (
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
"""
@@ -17,9 +20,9 @@ def process_bolus_events(bolusdata):
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"]
parsed["description"] += " (%s: requested %s units)" % (parsed["completion"], parsed["requested_insulin"])
else:
print("Skipping non-completed bolus data:", b, "parsed:", parsed)
logger.warning("Skipping non-completed bolus data (was a bolus in progress?): %s parsed: %s" % (b, parsed))
continue
bolusEvents.append(parsed)
@@ -31,29 +34,31 @@ def process_bolus_events(bolusdata):
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"])
print("Last Nightscout bolus upload:", last_upload_time)
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:
created_at = event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
print("Skipping basal event before last upload time:", event)
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"],
created_at=created_at,
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)
logger.info(" Processing bolus: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry)
+9 -5
View File
@@ -1,4 +1,5 @@
import arrow
import logging
from ..parser.nightscout import (
IOB_ACTIVITYTYPE,
@@ -6,6 +7,8 @@ from ..parser.nightscout import (
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given IOB data input from the therapy timeline CSV, converts it into a digestable format.
"""
@@ -22,19 +25,20 @@ def process_iob_events(iobdata):
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"])
print("Last Nightscout iob upload:", last_upload_time)
logger.info("Last Nightscout iob upload: %s" % last_upload_time)
if not iobEvents or len(iobEvents) == 0:
print("No IOB events: skipping")
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:
print(" Skipping already uploaded iob event:", event)
logger.info(" Skipping already uploaded iob event: %s" % event)
return 0
entry = NightscoutEntry.iob(
@@ -42,13 +46,13 @@ def ns_write_iob_events(nightscout, iobEvents, pretend=False):
created_at=event["time"]
)
print(" Processing iob:", event, "entry:", entry)
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:
print(" Deleting old iob entry:", last_upload)
logger.info(" Deleting old iob entry: %s" % last_upload)
if not pretend:
nightscout.delete_entry('activity/{}'.format(last_upload['_id']))
+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
+3 -2
View File
@@ -11,12 +11,13 @@ class ControlIQApi(tconnectsync.api.controliq.ControlIQApi):
def needs_relogin(self):
return False
def get(self, endpoint, query):
def _get(self, endpoint, query):
raise NotImplementedError
class WS2Api(tconnectsync.api.ws2.WS2Api):
def __init__(self):
self.BASE_URL = 'invalid://'
self.SLEEP_SECONDS_INCREMENT = 0.01
def get(self, endpoint, query):
raise NotImplementedError
@@ -34,7 +35,7 @@ class AndroidApi(tconnectsync.api.android.AndroidApi):
def needs_relogin(self):
return False
def get(self, endpoint, query={}, **kwargs):
def _get(self, endpoint, query={}, **kwargs):
raise NotImplementedError
class TConnectApi(tconnectsync.api.TConnectApi):
+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()
+3 -3
View File
@@ -8,14 +8,14 @@ class NightscoutApi(tconnectsync.nightscout.NightscoutApi):
self.secret = 'invalid'
self.uploaded_entries = collections.defaultdict(list)
self.deleted_entries = collections.defaultdict(list)
self.deleted_entries = []
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 delete_entry(self, ns_path):
self.deleted_entries.append(ns_path)
def put_entry(self, ns_format, entity):
self.put_entries[entity].append(ns_format)
+2
View File
@@ -15,6 +15,7 @@ class TestNightscoutEntry(unittest.TestCase):
"reason": "",
"duration": 30,
"absolute": 1.05,
"rate": 1.05,
"created_at": "2021-03-16 00:25:21-04:00",
"carbs": None,
"insulin": None,
@@ -33,6 +34,7 @@ class TestNightscoutEntry(unittest.TestCase):
"reason": "Correction",
"duration": 5,
"absolute": 0.95,
"rate": 0.95,
"created_at": "2021-03-16 12:25:21-04:00",
"carbs": None,
"insulin": None,
+202 -2
View File
@@ -33,6 +33,83 @@ class TestTConnectEntryBasal(unittest.TestCase):
}
)
class TestTConnectEntrySuspension(unittest.TestCase):
def test_parse_suspension_entry(self):
self.assertEqual(
TConnectEntry.parse_suspension_entry({
"suspendReason": "control-iq",
"continuation": None,
"x": 1615879821
}),
{
"time": "2021-03-16 00:30:21-04:00",
"continuation": None,
"suspendReason": "control-iq"
}
)
self.assertEqual(
TConnectEntry.parse_suspension_entry({
"suspendReason": "control-iq",
"continuation": "previous",
"x": 1634022000
}),
{
"time": "2021-10-12 00:00:00-04:00",
"continuation": "previous",
"suspendReason": "control-iq"
}
)
class TestTConnectEntryCGM(unittest.TestCase):
def test_parse_cgm_entry(self):
self.assertEqual(
TConnectEntry.parse_cgm_entry({
"DeviceType": "t:slim X2 Insulin Pump",
"SerialNumber": "11111111",
"Description": "EGV",
"EventDateTime": "2021-10-12T00:01:12",
"Readings (CGM / BGM)": "131"
}),
{
"time": "2021-10-12 00:01:12-04:00",
"reading": "131",
"reading_type": "EGV"
}
)
class TestTConnectEntryIOB(unittest.TestCase):
entry1 = {
"Type": "IOB",
"EventID": "81",
"EventDateTime": "2021-10-12T00:00:30",
"IOB": "6.91"
}
def test_parse_iob_entry1(self):
self.assertEqual(
TConnectEntry.parse_iob_entry(self.entry1),
{
"time": "2021-10-12 00:00:30-04:00",
"iob": "6.91",
"event_id": "81"
}
)
entry2 = {
"Type": "IOB",
"EventID": "9",
"EventDateTime": "2021-10-12T00:10:30",
"IOB": "6.80"
}
def test_parse_iob_entry2(self):
self.assertEqual(
TConnectEntry.parse_iob_entry(self.entry2),
{
"time": "2021-10-12 00:10:30-04:00",
"iob": "6.80",
"event_id": "9"
}
)
class TestTConnectEntryBolus(unittest.TestCase):
entryStdCorrection = {
"Type": "Bolus",
@@ -87,6 +164,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"request_time": "2021-04-01 12:53:36-04:00",
"completion_time": "2021-04-01 12:58:26-04:00",
"insulin": "13.53",
"requested_insulin": "13.53",
"carbs": "75",
"user_override": "0",
"extended_bolus": "",
@@ -147,6 +225,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"request_time": "2021-04-01 23:21:58-04:00",
"completion_time": "2021-04-01 23:23:17-04:00",
"insulin": "1.25",
"requested_insulin": "1.25",
"carbs": "0",
"user_override": "1",
"extended_bolus": "",
@@ -207,14 +286,135 @@ class TestTConnectEntryBolus(unittest.TestCase):
"request_time": "2021-04-02 00:59:13-04:00",
"completion_time": "2021-04-02 01:00:47-04:00",
"insulin": "1.70",
"requested_insulin": "1.70",
"carbs": "0",
"user_override": "0",
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
entryStdIncompleteZero = {
"Type": "Bolus",
"Description": "Standard",
"BG": "144",
"IOB": "1.20",
"BolusRequestID": "9694.000",
"BolusCompletionID": "9694.000",
"CompletionDateTime": "2021-10-08T15:47:02",
"InsulinDelivered": "0.00",
"FoodDelivered": "0.00",
"CorrectionDelivered": "0.00",
"CompletionStatusID": "0",
"CompletionStatusDesc": "User Aborted",
"BolusIsComplete": "0",
"BolexCompletionID": "",
"BolexSize": "",
"BolexStartDateTime": "",
"BolexCompletionDateTime": "",
"BolexInsulinDelivered": "",
"BolexIOB": "",
"BolexCompletionStatusID": "",
"BolexCompletionStatusDesc": "",
"ExtendedBolusIsComplete": "",
"EventDateTime": "2021-10-08T15:46:56",
"RequestDateTime": "2021-10-08T15:46:56",
"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": "0.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": "1669328"
}
def test_parse_bolus_entry_std_incomplete_zero(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdIncompleteZero),
{
"description": "Standard",
"complete": "",
"completion": "User Aborted",
"request_time": "2021-10-08 15:46:56-04:00",
"completion_time": "2021-10-08 15:47:02-04:00",
"insulin": "0.00",
"requested_insulin": "0.50",
"carbs": "0",
"user_override": "1",
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
entryStdIncompletePartial = {
"Type": "Bolus",
"Description": "Standard/Correction",
"BG": "189",
"IOB": "",
"BolusRequestID": "9261.000",
"BolusCompletionID": "9261.000",
"CompletionDateTime": "2021-09-06T12:24:47",
"InsulinDelivered": "1.82",
"FoodDelivered": "0.00",
"CorrectionDelivered": "1.82",
"CompletionStatusID": "1",
"CompletionStatusDesc": "Terminated by Alarm",
"BolusIsComplete": "0",
"BolexCompletionID": "",
"BolexSize": "",
"BolexStartDateTime": "",
"BolexCompletionDateTime": "",
"BolexInsulinDelivered": "",
"BolexIOB": "",
"BolexCompletionStatusID": "",
"BolexCompletionStatusDesc": "",
"ExtendedBolusIsComplete": "",
"EventDateTime": "2021-09-06T12:23:23",
"RequestDateTime": "2021-09-06T12:23:23",
"BolusType": "Carb",
"BolusRequestOptions": "Standard/Correction",
"StandardPercent": "100.00",
"Duration": "0",
"CarbSize": "0",
"UserOverride": "0",
"TargetBG": "110",
"CorrectionFactor": "30.00",
"FoodBolusSize": "0.00",
"CorrectionBolusSize": "2.63",
"ActualTotalBolusRequested": "2.63",
"IsQuickBolus": "0",
"EventHistoryReportEventDesc": "0",
"EventHistoryReportDetails": "Correction & Food Bolus",
"NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"IndexID": "0",
"Note": "1589227"
}
def test_parse_bolus_entry_std_incomplete_partial(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdIncompletePartial),
{
"description": "Standard/Correction",
"complete": "",
"completion": "Terminated by Alarm",
"request_time": "2021-09-06 12:23:23-04:00",
"completion_time": "2021-09-06 12:24:47-04:00",
"insulin": "1.82",
"requested_insulin": "2.63",
"carbs": "0",
"user_override": "0",
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
if __name__ == '__main__':
unittest.main()
+57 -7
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import unittest
import random
from tconnectsync.sync.bolus import process_bolus_events
from tconnectsync.parser.tconnect import TConnectEntry
@@ -12,23 +13,72 @@ class TestBolusSync(unittest.TestCase):
@staticmethod
def get_example_csv_bolus_events():
return [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic,
TestTConnectEntryBolus.entryStdIncompletePartial
]
def test_process_bolus_events_standard(self):
bolusData = [
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.assertEqual(len(bolusEvents), len(bolusData))
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(bolusData[0]),
TConnectEntry.parse_bolus_entry(bolusData[1]),
TConnectEntry.parse_bolus_entry(bolusData[2])
TConnectEntry.parse_bolus_entry(d) for d in bolusData
])
def test_process_bolus_events_update_partial_description(self):
stdData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
partialData = [
TestTConnectEntryBolus.entryStdIncompletePartial
]
bolusData = stdData + partialData
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), len(bolusData))
partialEntries = [
TConnectEntry.parse_bolus_entry(e) for e in partialData
]
for e in partialEntries:
e["description"] += " (%s: requested %s units)" % (e["completion"], e["requested_insulin"])
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in stdData
] + partialEntries)
def test_process_bolus_events_skip_zero(self):
stdData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
zeroData = [
TestTConnectEntryBolus.entryStdIncompleteZero
]
bolusData = stdData + zeroData
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), len(stdData))
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in stdData
])
for d in zeroData:
self.assertNotIn(TConnectEntry.parse_bolus_entry(d), bolusEvents)
if __name__ == '__main__':
unittest.main()
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.iob import process_iob_events
from tconnectsync.parser.tconnect import TConnectEntry
from ..parser.test_tconnect import TestTConnectEntryIOB
class TestIOBSync(unittest.TestCase):
@staticmethod
def get_example_csv_iob_events():
return [
TestTConnectEntryIOB.entry1,
TestTConnectEntryIOB.entry2,
]
def test_process_iob_events(self):
iobData = TestIOBSync.get_example_csv_iob_events()
iobEvents = process_iob_events(iobData)
self.assertEqual(len(iobEvents), len(iobData))
self.assertListEqual(iobEvents, [
TConnectEntry.parse_iob_entry(d) for d in iobData
])
if __name__ == '__main__':
unittest.main()
+98 -7
View File
@@ -5,12 +5,13 @@ import datetime
import pprint
from tconnectsync.process import process_time_range
from tconnectsync.parser.nightscout import NightscoutEntry
from tconnectsync.parser.nightscout import IOB_ACTIVITYTYPE, NightscoutEntry
from .api.fake import TConnectApi
from .nightscout_fake import NightscoutApi
from .sync.test_basal import TestBasalSync
from .sync.test_bolus import TestBolusSync
from .sync.test_iob import TestIOBSync
class TestProcessTimeRange(unittest.TestCase):
maxDiff = None
@@ -36,6 +37,7 @@ class TestProcessTimeRange(unittest.TestCase):
def test_new_ciq_basal_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
@@ -64,13 +66,14 @@ class TestProcessTimeRange(unittest.TestCase):
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, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""Two basal entries in Nightscout. Two new basal entries in tconnect."""
def test_partial_ciq_basal_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
@@ -104,7 +107,7 @@ class TestProcessTimeRange(unittest.TestCase):
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, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""
@@ -113,6 +116,7 @@ class TestProcessTimeRange(unittest.TestCase):
def test_with_updated_duration_ciq_basal_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
@@ -155,21 +159,23 @@ class TestProcessTimeRange(unittest.TestCase):
}
]
})
self.assertDictEqual(nightscout.deleted_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Uploads all bolus data from tconnect."""
def test_new_ciq_bolus_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
bolusData = TestBolusSync.get_example_csv_bolus_events()
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(),
"bolusData": bolusData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
@@ -182,15 +188,100 @@ class TestProcessTimeRange(unittest.TestCase):
process_time_range(tconnect, nightscout, start, end, pretend=False)
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 3)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), len(bolusData))
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"),
NightscoutEntry.bolus(1.82, 0, "2021-09-06 12:24:47-04:00", notes="Standard/Correction (Terminated by Alarm: requested 2.63 units)"),
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertDictEqual(nightscout.deleted_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Uploads new iob reading from tconnect."""
def test_new_ciq_iob_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
iobData = TestIOBSync.get_example_csv_iob_events()
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"iobData": iobData,
}
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["activity"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"activity": [
# the most recent IOB entry is added
NightscoutEntry.iob(6.80, "2021-10-12 00:10:30-04:00")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""Existing IOB in Nightscout. Uploads new iob reading and deletes old IOB."""
def test_updates_ciq_iob_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
iobData = TestIOBSync.get_example_csv_iob_events()
iobData[0]["created_at"] = start
iobData[0]["_id"] = "sentinel_existing_iob_id"
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"iobData": iobData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
def fake_last_uploaded_activity(activityType):
if activityType == IOB_ACTIVITYTYPE:
return iobData[0]
return self.stub_last_uploaded_activity(activityType)
nightscout.last_uploaded_activity = fake_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False)
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"activity": [
# the most recent IOB entry is added
NightscoutEntry.iob(6.80, "2021-10-12 00:10:30-04:00")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [
"activity/sentinel_existing_iob_id"
])