mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-28 05:34:10 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2df266e93 | ||
|
|
b53fb9c099 | ||
|
|
bcd486561f | ||
|
|
d931242562 | ||
|
|
bec1cef1f1 | ||
|
|
82ec1b4c1b | ||
|
|
b6569edabc | ||
|
|
2e5d8fc793 | ||
|
|
c33a674bad | ||
|
|
3be0289177 | ||
|
|
87f531cbb3 | ||
|
|
795b5ce240 | ||
|
|
3a004183bb | ||
|
|
7d813ee8fb | ||
|
|
d0366cc064 | ||
|
|
f2f7b1c0e1 | ||
|
|
855e50358a | ||
|
|
4afb35e235 | ||
|
|
655e66a8aa | ||
|
|
380e4f00b6 | ||
|
|
f9ecaedf4d | ||
|
|
faf54b1f47 | ||
|
|
1733b0e139 | ||
|
|
acfe44881b | ||
|
|
3fc9cdeea3 | ||
|
|
33df18d4c9 | ||
|
|
05c19fffa7 | ||
|
|
878cd5b247 | ||
|
|
9c8e5c150c | ||
|
|
01d9517ff4 | ||
|
|
e7816c4aa0 | ||
|
|
18f1149b28 | ||
|
|
fe941c432f | ||
|
|
71ceb94270 | ||
|
|
a3c7aa69a9 | ||
|
|
093a7f0627 | ||
|
|
e1921ccac7 | ||
|
|
52f7972153 | ||
|
|
2130099f70 | ||
|
|
4c6e05b2c1 | ||
|
|
cda01894dc | ||
|
|
7a18a5d4be | ||
|
|
4b8ddd0cec | ||
|
|
1efe532a2f | ||
|
|
8dcbee4280 | ||
|
|
c7aac1dca3 | ||
|
|
739c01f876 | ||
|
|
8ce8b42bfc | ||
|
|
c14298384a | ||
|
|
84067015e3 | ||
|
|
0b7e806a40 | ||
|
|
89b40dcecb | ||
|
|
539516aeae | ||
|
|
332f54e85e | ||
|
|
19f4f9363a | ||
|
|
d804510633 | ||
|
|
713d2ee79e | ||
|
|
e0bfab8759 | ||
|
|
209292dd21 | ||
|
|
1a9fb36b88 | ||
|
|
2e0120fdfc | ||
|
|
3911da7d37 | ||
|
|
7cfd486ac5 | ||
|
|
8c6719c6a6 | ||
|
|
d00d8e59dd | ||
|
|
63ca6eda43 |
@@ -0,0 +1,11 @@
|
||||
status:
|
||||
patch: no
|
||||
changes: no
|
||||
project:
|
||||
default: false
|
||||
tconnectsync:
|
||||
paths: "tconnectsync/"
|
||||
target: 75%
|
||||
tests:
|
||||
paths: "tests/"
|
||||
target: 95%
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -1,6 +1,9 @@
|
||||
__pycache__
|
||||
dist
|
||||
build
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.swp
|
||||
*.swa
|
||||
*.egg-info
|
||||
.env
|
||||
|
||||
+8
-2
@@ -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
@@ -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,13 +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"
|
||||
tconnectsync = "python3 main.py"
|
||||
test = "python3 -m unittest discover -vv"
|
||||
|
||||
Generated
+156
-78
@@ -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,118 +39,196 @@
|
||||
},
|
||||
"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: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: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",
|
||||
"sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f",
|
||||
"sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee",
|
||||
"sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec",
|
||||
"sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969",
|
||||
"sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28",
|
||||
"sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a",
|
||||
"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",
|
||||
"sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23",
|
||||
"sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.6.2"
|
||||
"version": "==4.6.3"
|
||||
},
|
||||
"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:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e",
|
||||
"sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0"
|
||||
"sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8",
|
||||
"sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.15.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:407fa1e8eb3458d1b5614df51d9651a1180ea5fedf07feb46e45d7e25e6d6cdd",
|
||||
"sha256:d3a5ea5b350423f47d07639f74475afedad48cf41c0ad7a82ca13a3928af34f6"
|
||||
"sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc",
|
||||
"sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"
|
||||
],
|
||||
"markers": "python_version >= '3.0'",
|
||||
"version": "==2.2"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.2.1"
|
||||
},
|
||||
"tconnectsync": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80",
|
||||
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
|
||||
"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.3"
|
||||
"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,24 +1,40 @@
|
||||
# tconnectsync
|
||||
|
||||

|
||||
[](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!
|
||||
|
||||
At a high level, tconnectsync works by querying Tandem's undocumented APIs to receive basal and bolus data from t:connect, and then uploads that data as treatment objects to Nightscout. It contains features for checking for new Tandem pump data continuously, and updating that data along with the pump's reported IOB value to Nightscout whenever there is new data.
|
||||
|
||||
**To get started,** read the setup instructions below and choose whether to run the application via **Pipenv** or **Docker**.
|
||||
When you run the program with no arguments, it performs a single cycle of the following, and exits after completion:
|
||||
|
||||
## Tandem APIs
|
||||
* Queries for basal information via the t:connect ControlIQ API.
|
||||
* Queries for bolus, basal, and IOB data via the t:connect non-ControlIQ API.
|
||||
* Merges the basal information received from the two APIs. (If using ControlIQ, then basal information appears only on the ControlIQ API. If not using ControlIQ, it appears only on the legacy API.)
|
||||
* Queries Nightscout for the most recently created Temp Basal object by tconnectsync, and uploads all data newer than that.
|
||||
* Queries Nightscout for the most recently created Bolus object by tconnectsync, and uploads all data newer than that.
|
||||
* Uploads a single Nightscout Activity object representing the current IOB as reported by the pump.
|
||||
|
||||
This application utilizes three separate Tandem APIs for obtaining t:connect data, referenced here by the identifying part of their URLs:
|
||||
If run with the `--auto-update` flag, then the application performs the following steps:
|
||||
|
||||
* [**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.)
|
||||
* Queries an API endpoint used only by the t:connect mobile app which returns an internal event ID, corresponding to the most recent event published by the mobile app.
|
||||
* Whenever the internal event ID changes (denoting that the mobile app uploaded new data to synchronize), perform all of the above mentioned steps to synchronize data.
|
||||
|
||||
## Setup
|
||||
|
||||
Create a file named `.env` containing configuration values. You should specify the following parameters:
|
||||
**To get started,** you need to choose whether to install the application via
|
||||
**Pip**, **Pipenv**, or **Docker**.
|
||||
|
||||
After that, you can choose to run the program continuously via **Supervisord**
|
||||
or on a regular interval with **Cron**.
|
||||
|
||||
## Installation
|
||||
|
||||
First, create a file named `.env` containing configuration values.
|
||||
You should specify the following parameters:
|
||||
|
||||
```bash
|
||||
# Your credentials for t:connect
|
||||
@@ -36,55 +52,95 @@ 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).
|
||||
|
||||
When you run the program with no arguments, it performs a single cycle of the following, and exits after completion:
|
||||
### Installation via Pip
|
||||
|
||||
* Queries for basal information via the t:connect ControlIQ API.
|
||||
* Queries for bolus, basal, and IOB data via the t:connect non-ControlIQ API.
|
||||
* Merges the basal information received from the two APIs. (If using ControlIQ, then basal information appears only on the ControlIQ API. If not using ControlIQ, it appears only on the legacy API.)
|
||||
* Queries Nightscout for the most recently created Temp Basal object by tconnectsync, and uploads all data newer than that.
|
||||
* Queries Nightscout for the most recently created Bolus object by tconnectsync, and uploads all data newer than that.
|
||||
* Uploads a single Nightscout Activity object representing the current IOB as reported by the pump.
|
||||
This is the easiest method to install.
|
||||
|
||||
If run with the `--auto-update` flag, then the application performs the following steps:
|
||||
First, ensure that you have **Python 3** with **Pip** installed on your
|
||||
Linux machine. Then, install tconnectsync from pip:
|
||||
|
||||
* Queries an API endpoint used only by the t:connect mobile app which returns an internal event ID, corresponding to the most recent event published by the mobile app.
|
||||
* Whenever the internal event ID changes (denoting that the mobile app uploaded new data to synchronize), perform all of the above mentioned steps to synchronize data.
|
||||
```
|
||||
$ pip3 install tconnectsync
|
||||
```
|
||||
|
||||
### Running with Pipenv
|
||||
|
||||
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
|
||||
$ pip3 install pipenv
|
||||
$ pipenv install
|
||||
$ pipenv run tconnectsync --help
|
||||
usage: main.py [-h] [--pretend] [--start-date START_DATE] [--end-date END_DATE]
|
||||
[--days DAYS] [--auto-update]
|
||||
After this, you should be able to view tconnectsync's help with:
|
||||
```
|
||||
$ tconnectsync --help
|
||||
usage: tconnectsync [-h] [--version] [--pretend] [-v] [--start-date START_DATE] [--end-date END_DATE] [--days DAYS] [--auto-update] [--check-login]
|
||||
|
||||
Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--version show program's version number and exit
|
||||
--pretend Pretend mode: do not upload any data to Nightscout.
|
||||
-v, --verbose Verbose mode: show extra logging details
|
||||
--start-date START_DATE
|
||||
The oldest date to process data from. Must be specified with
|
||||
--end-date.
|
||||
--end-date END_DATE The newest date to process data until (inclusive). Must be
|
||||
specified with --start-date.
|
||||
--days DAYS The number of days of t:connect data to read in. Cannot be
|
||||
used with --from-date and --until-date.
|
||||
--auto-update If set, continuously checks for updates from t:connect and
|
||||
syncs with Nightscout.
|
||||
The oldest date to process data from. Must be specified with --end-date.
|
||||
--end-date END_DATE The newest date to process data until (inclusive). Must be specified with --start-date.
|
||||
--days DAYS The number of days of t:connect data to read in. Cannot be used with --from-date and --until-date.
|
||||
--auto-update If set, continuously checks for updates from t:connect and syncs with Nightscout.
|
||||
--check-login If set, checks that the provided t:connect credentials can be used to log in.
|
||||
```
|
||||
|
||||
You can now continue to either the **Running with Cron** or **Running with Supervisord** sections.
|
||||
Go to the folder where you created the `.env` file, and run:
|
||||
```
|
||||
$ tconnectsync --check-login
|
||||
```
|
||||
|
||||
### Running with Docker
|
||||
If you receive no errors, then you can move on to the **Running Tconnectsync Continuously** section.
|
||||
|
||||
### Installing with Pipenv
|
||||
|
||||
You can run the application using Pipenv.
|
||||
|
||||
First, ensure you have Python 3 and pip installed, then install pipenv with `pip3 install pipenv`.
|
||||
|
||||
Clone the Git repository for tconnectsync and cd into it with:
|
||||
```
|
||||
$ git clone https://github.com/jwoglom/tconnectsync
|
||||
$ cd tconnectsync
|
||||
```
|
||||
|
||||
Then install tconnectsync's dependencies with `pipenv install`.
|
||||
Afterwards, you can launch the program with `pipenv run tconnectsync` so long as
|
||||
you are inside the checked-out tconnectsync folder.
|
||||
|
||||
```bash
|
||||
$ git clone https://github.com/jwoglom/tconnectsync && cd tconnectsync
|
||||
$ pip3 install pipenv
|
||||
$ pipenv install
|
||||
$ pipenv run tconnectsync --help
|
||||
usage: main.py [-h] [--version] [--pretend] [-v] [--start-date START_DATE] [--end-date END_DATE] [--days DAYS] [--auto-update] [--check-login]
|
||||
|
||||
Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--version show program's version number and exit
|
||||
--pretend Pretend mode: do not upload any data to Nightscout.
|
||||
-v, --verbose Verbose mode: show extra logging details
|
||||
--start-date START_DATE
|
||||
The oldest date to process data from. Must be specified with --end-date.
|
||||
--end-date END_DATE The newest date to process data until (inclusive). Must be specified with --start-date.
|
||||
--days DAYS The number of days of t:connect data to read in. Cannot be used with --from-date and --until-date.
|
||||
--auto-update If set, continuously checks for updates from t:connect and syncs with Nightscout.
|
||||
--check-login If set, checks that the provided t:connect credentials can be used to log in.
|
||||
```
|
||||
|
||||
|
||||
Move the `.env` file you created earlier into this folder, and run:
|
||||
```
|
||||
$ pipenv run tconnectsync --check-login
|
||||
```
|
||||
|
||||
If you receive no errors, then you can move on to the **Running Tconnectsync Continuously** section.
|
||||
|
||||
### Installing with Docker
|
||||
|
||||
First, [ensure that you have Docker running and installed](https://docs.docker.com/get-started/#download-and-install-docker).
|
||||
|
||||
@@ -92,21 +148,41 @@ 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:
|
||||
|
||||
```bash
|
||||
$ git clone https://github.com/jwoglom/tconnectsync
|
||||
$ cd tconnectsync
|
||||
$ docker build -t tconnectsync .
|
||||
$ docker run tconnectsync --help
|
||||
```
|
||||
|
||||
You can now continue to either the **Running with Cron** or **Running with Supervisord** sections.
|
||||
Move the `.env` file you created earlier into this folder, and run:
|
||||
```
|
||||
$ docker run tconnectsync --check-login
|
||||
```
|
||||
|
||||
If you receive no errors, then you can move on to the **Running Tconnectsync Continuously** section.
|
||||
|
||||
## Running Tconnectsync Continuously
|
||||
|
||||
You most likely want tconnectsync to run either continuously (via the auto-update
|
||||
feature) or on a regular interval (via cron).
|
||||
|
||||
The supervisord approach is recommended for simplicity.
|
||||
|
||||
### Running with Supervisord (recommended)
|
||||
To instead configure tconnectsync to run continuously in the background using its `--auto-update` feature, you can use a tool such as Supervisord. Here is an example `tconnectsync.conf` which you can place inside `/etc/supervisor/conf.d`:
|
||||
To configure tconnectsync to run continuously in the background using its `--auto-update` feature, you can use a tool such as Supervisord.
|
||||
|
||||
First, install supervisord via your Linux system's package manager.
|
||||
(For example, for Ubuntu/Debian-based systems, run `sudo apt install supervisor`)
|
||||
|
||||
Supervisord is configured by creating a configuration file in `/etc/supervisor/conf.d`.
|
||||
|
||||
Here is an example `tconnectsync.conf` which you can place in that folder:
|
||||
|
||||
```
|
||||
[program:tconnectsync]
|
||||
@@ -114,12 +190,63 @@ command=/path/to/tconnectsync/run.sh
|
||||
directory=/path/to/tconnectsync/
|
||||
stderr_logfile=/path/to/tconnectsync/stderr.log
|
||||
stdout_logfile=/path/to/tconnectsync/stdout.log
|
||||
user=tconnectsync
|
||||
user=<your username>
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=true
|
||||
```
|
||||
|
||||
In order to create a `run.sh` file, see the section below which aligns with your
|
||||
choice of installation method.
|
||||
|
||||
After the configuration file has been created, ensure that Supervisor is running
|
||||
and configured to start on boot:
|
||||
|
||||
```bash
|
||||
$ sudo systemctl daemon-reload
|
||||
$ sudo systemctl start supervisord
|
||||
$ sudo systemctl enable supervisord
|
||||
```
|
||||
|
||||
Then use the `supervisorctl` command to manage the status of the tconnectsync program:
|
||||
|
||||
```bash
|
||||
$ sudo supervisorctl status
|
||||
tconnectsync STOPPED
|
||||
$ sudo supervisorctl start tconnectsync
|
||||
$ sudo supervisorctl status
|
||||
tconnectsync RUNNING pid 18810, uptime 00:00:05
|
||||
```
|
||||
|
||||
You can look at the `stderr.log` and `stdout.log` files to check that tconnectsync
|
||||
is running and has started up properly:
|
||||
```bash
|
||||
$ tail -f /path/to/tconnectsync/stdout.log
|
||||
Starting auto-update between 2021-09-30 00:06:39.942273 and 2021-10-01 00:06:39.942273
|
||||
2021-10-01 00:06:39 DEBUG Instantiating new AndroidApi
|
||||
2021-10-01 00:06:39 DEBUG Starting new HTTPS connection (1): tdcservices.tandemdiabetes.com:443
|
||||
2021-10-01 00:06:40 DEBUG https://tdcservices.tandemdiabetes.com:443 "POST /cloud/oauth2/token HTTP/1.1" 200 404
|
||||
2021-10-01 00:06:40 INFO Logged in to AndroidApi successfully (expiration: 2021-10-01T08:06:40.362Z, in 7 hours, 59 minutes)
|
||||
```
|
||||
|
||||
#### With Pip Installation
|
||||
|
||||
In the `tconnectsync.conf`, you should set `/path/to/tconnectsync` to the folder
|
||||
containing your `.env` file.
|
||||
|
||||
Create a `run.sh` file containing:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
tconnectsync --auto-update
|
||||
```
|
||||
|
||||
#### With Pipenv Installation
|
||||
|
||||
In the `tconnectsync.conf`, you should set `/path/to/tconnectsync` to the folder
|
||||
where you checked-out the GitHub repository.
|
||||
|
||||
An example `run.sh` which launches tconnectsync within its pipenv-configured virtual environment:
|
||||
|
||||
```bash
|
||||
@@ -134,7 +261,12 @@ cd /path/to/tconnectsync
|
||||
exec python3 -u main.py --auto-update
|
||||
```
|
||||
|
||||
An example `run.sh` which uses Docker:
|
||||
#### With Docker Installation
|
||||
|
||||
In the `tconnectsync.conf`, you should set `/path/to/tconnectsync` to the folder
|
||||
where you checked-out the GitHub repository.
|
||||
|
||||
An example `run.sh` if you installed tconnectsync via Docker:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
@@ -144,7 +276,10 @@ docker run tconnectsync --auto-update
|
||||
```
|
||||
|
||||
### Running with Cron
|
||||
To configure tconnectsync to run at a periodic interval (i.e. every 15 minutes), you can just invoke main.py with no arguments via cron.
|
||||
|
||||
If you choose not to run tconnectsync with `--auto-update` continuously,
|
||||
you can instead run it at a periodic interval (i.e. every 15 minutes) by just
|
||||
invoking tconnectsync with no arguments via cron.
|
||||
|
||||
If using Pipenv or a virtualenv, make sure that you either prefix the call to main.py with `pipenv run` or source the `bin/activate` file within the virtualenv, so that the proper dependencies are loaded. If not using any kind of virtualenv, you can instead just install the necessary dependencies as specified inside Pipfile globally.
|
||||
|
||||
@@ -157,6 +292,15 @@ An example configuration in `/etc/crontab` which runs every 15 minutes:
|
||||
|
||||
You can use one of the same `run.sh` files mentioned above in the Supervisord example, but remove the `--auto-update` flag since you are handling the functionality for running the script periodically yourself.
|
||||
|
||||
## Tandem APIs
|
||||
|
||||
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/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.)
|
||||
|
||||
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.
|
||||
## Backfilling t:connect Data
|
||||
|
||||
To backfill existing t:connect data in to Nightscout, you can use the `--start-date` and `--end-date` options. For example, the following will upload all t:connect data between January 1st and March 1st, 2020 to Nightscout:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import requests
|
||||
import datetime
|
||||
import csv
|
||||
|
||||
from .common import parse_date, base_headers, ApiException
|
||||
|
||||
class WS2Api:
|
||||
BASE_URL = 'https://tconnectws2.tandemdiabetes.com/'
|
||||
|
||||
userGuid = None
|
||||
|
||||
def __init__(self, userGuid):
|
||||
self.userGuid = userGuid
|
||||
|
||||
def get(self, endpoint, query):
|
||||
r = requests.get(self.BASE_URL + endpoint, query, 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))
|
||||
return r.text
|
||||
|
||||
|
||||
def _split_empty_sections(self, text):
|
||||
sections = [[]]
|
||||
sectionIndex = 0
|
||||
for line in text.splitlines():
|
||||
if len(line.strip()) > 0:
|
||||
sections[sectionIndex].append(line)
|
||||
else:
|
||||
sections.append([])
|
||||
sectionIndex += 1
|
||||
|
||||
return sections + [None] * (4 - len(sections))
|
||||
|
||||
def _csv_to_dict(self, rawdata):
|
||||
data = []
|
||||
if not rawdata or len(rawdata) == 0:
|
||||
return data
|
||||
headers = rawdata[0].split(",")
|
||||
for row in csv.reader(rawdata[1:]):
|
||||
data.append({headers[i]: row[i] for i in range(len(row)) if i < len(headers)})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def therapy_timeline_csv(self, start=None, end=None):
|
||||
startDate = parse_date(start)
|
||||
endDate = parse_date(end)
|
||||
|
||||
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), {})
|
||||
|
||||
sections = self._split_empty_sections(req_text)
|
||||
|
||||
readingData = None
|
||||
iobData = None
|
||||
basalData = None
|
||||
bolusData = None
|
||||
|
||||
for s in sections:
|
||||
if s and len(s) > 2:
|
||||
firstrow = s[1].replace('"', '').strip()
|
||||
if firstrow.startswith("t:slim X2 Insulin Pump"):
|
||||
readingData = s
|
||||
elif firstrow.startswith("IOB"):
|
||||
iobData = s
|
||||
elif firstrow.startswith("Basal"):
|
||||
basalData = s
|
||||
elif firstrow.startswith("Bolus"):
|
||||
bolusData = s
|
||||
|
||||
|
||||
return {
|
||||
"readingData": self._csv_to_dict(readingData),
|
||||
"iobData": self._csv_to_dict(iobData),
|
||||
"basalData": self._csv_to_dict(basalData),
|
||||
"bolusData": self._csv_to_dict(bolusData)
|
||||
}
|
||||
@@ -1,355 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import datetime
|
||||
import json
|
||||
import hashlib
|
||||
import requests
|
||||
import arrow
|
||||
import argparse
|
||||
import time
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
try:
|
||||
from secret import (
|
||||
TCONNECT_EMAIL,
|
||||
TCONNECT_PASSWORD,
|
||||
PUMP_SERIAL_NUMBER,
|
||||
TIMEZONE_NAME
|
||||
)
|
||||
except Exception:
|
||||
print('Unable to import 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('--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.')
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
if args.pretend:
|
||||
print("Pretend mode: will not write to Nightscout")
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
else:
|
||||
print("Processing data between", time_start, "and", time_end)
|
||||
added = process_time_range(tconnect, time_start, time_end, args.pretend)
|
||||
print("Added", added, "items")
|
||||
from tconnectsync import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools>=42",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -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)))
|
||||
@@ -0,0 +1,37 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.4.1
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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"]
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,135 @@
|
||||
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
|
||||
})
|
||||
|
||||
"""
|
||||
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, {})
|
||||
@@ -0,0 +1,142 @@
|
||||
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):
|
||||
self.userGuid = userGuid
|
||||
|
||||
def get(self, endpoint, query):
|
||||
r = requests.get(self.BASE_URL + endpoint, query, 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))
|
||||
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 = [[]]
|
||||
sectionIndex = 0
|
||||
for line in text.splitlines():
|
||||
if len(line.strip()) > 0:
|
||||
sections[sectionIndex].append(line)
|
||||
else:
|
||||
sections.append([])
|
||||
sectionIndex += 1
|
||||
|
||||
return sections + [None] * (4 - len(sections))
|
||||
|
||||
def _csv_to_dict(self, rawdata):
|
||||
data = []
|
||||
if not rawdata or len(rawdata) == 0:
|
||||
return data
|
||||
headers = rawdata[0].split(",")
|
||||
for row in csv.reader(rawdata[1:]):
|
||||
data.append({headers[i]: row[i] for i in range(len(row)) if i < len(headers)})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
"""
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
readingData = None
|
||||
iobData = None
|
||||
basalData = None
|
||||
bolusData = None
|
||||
|
||||
for s in sections:
|
||||
if s and len(s) > 2:
|
||||
firstrow = s[1].replace('"', '').strip()
|
||||
if firstrow.startswith("t:slim X2 Insulin Pump"):
|
||||
readingData = s
|
||||
elif firstrow.startswith("IOB"):
|
||||
iobData = s
|
||||
elif firstrow.startswith("Basal"):
|
||||
basalData = s
|
||||
elif firstrow.startswith("Bolus"):
|
||||
bolusData = s
|
||||
|
||||
|
||||
return {
|
||||
"readingData": self._csv_to_dict(readingData),
|
||||
"iobData": self._csv_to_dict(iobData),
|
||||
"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.
|
||||
"""
|
||||
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))
|
||||
@@ -0,0 +1,94 @@
|
||||
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_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 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:
|
||||
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
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
@@ -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": float(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" }
|
||||
@@ -81,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 "",
|
||||
@@ -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" % added)
|
||||
return added
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -0,0 +1,65 @@
|
||||
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: requested %s units)" % (parsed["completion"], parsed["requested_insulin"])
|
||||
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:
|
||||
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:
|
||||
logger.info("Skipping basal event before last upload time: %s" % event)
|
||||
continue
|
||||
|
||||
entry = NightscoutEntry.bolus(
|
||||
bolus=event["insulin"],
|
||||
carbs=event["carbs"],
|
||||
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
|
||||
|
||||
logger.info(" Processing bolus: %s entry: %s" % (event, entry))
|
||||
if not pretend:
|
||||
nightscout.upload_entry(entry)
|
||||
|
||||
return add_count
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,47 @@
|
||||
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://'
|
||||
self.SLEEP_SECONDS_INCREMENT = 0.01
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 = []
|
||||
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_path):
|
||||
self.deleted_entries.append(ns_path)
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,420 @@
|
||||
#!/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 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",
|
||||
"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",
|
||||
"requested_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",
|
||||
"requested_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",
|
||||
"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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import random
|
||||
|
||||
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,
|
||||
TestTConnectEntryBolus.entryStdIncompletePartial
|
||||
]
|
||||
|
||||
def test_process_bolus_events_standard(self):
|
||||
bolusData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
TestTConnectEntryBolus.entryStd,
|
||||
TestTConnectEntryBolus.entryStdAutomatic
|
||||
]
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData)
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
self.assertListEqual(bolusEvents, [
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import datetime
|
||||
import pprint
|
||||
|
||||
from tconnectsync.process import process_time_range
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
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.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)
|
||||
|
||||
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.assertListEqual(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()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
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.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": bolusData,
|
||||
}
|
||||
|
||||
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"]), 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.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"
|
||||
])
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user