mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-27 18:24:14 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4837ac1e86 | ||
|
|
8b15ec5c90 | ||
|
|
37b008f6c3 | ||
|
|
8c40fdb533 | ||
|
|
d51080f6d0 | ||
|
|
b32415aaac | ||
|
|
ffd3c5b1ea | ||
|
|
0b4674bae9 | ||
|
|
a1202decbe | ||
|
|
4888426034 | ||
|
|
803b15b886 | ||
|
|
9e78fe770f | ||
|
|
dfb39e07ef | ||
|
|
9fad0d71ef | ||
|
|
f5cd7c0151 | ||
|
|
13bb106924 | ||
|
|
fd96b08aab | ||
|
|
4a8da39b44 | ||
|
|
7a0f9477f2 | ||
|
|
934d90e3b9 | ||
|
|
ea84b0b9cc | ||
|
|
07aaea9e1c | ||
|
|
67b9f42f9b | ||
|
|
997ab7bac8 | ||
|
|
b1f8263ceb | ||
|
|
29d08a7f1b | ||
|
|
c7ade49eea | ||
|
|
1f0fbd7788 | ||
|
|
4bda0a21f2 | ||
|
|
d1341e09c0 | ||
|
|
fb9588ff2c | ||
|
|
2339b4543e | ||
|
|
3f6010fafc | ||
|
|
a028cdf3fe | ||
|
|
093b42ff94 | ||
|
|
00023c6aba | ||
|
|
1ed3d35965 | ||
|
|
9493b6546f | ||
|
|
24a635ca21 | ||
|
|
80824906d9 | ||
|
|
e5259a7981 | ||
|
|
650fb60988 | ||
|
|
2b367d45f3 | ||
|
|
dd2dcd17bd | ||
|
|
98b7edc904 | ||
|
|
e82d311fd6 | ||
|
|
9a2a4ca771 | ||
|
|
54ba2ed7df | ||
|
|
2892eb8ace | ||
|
|
1d432982ad | ||
|
|
891c2e2c10 | ||
|
|
7dc479c84b | ||
|
|
0417fb9fd8 | ||
|
|
b80d52662b | ||
|
|
51ac469f00 | ||
|
|
81b13f236a | ||
|
|
d57b3872f1 | ||
|
|
0609a46ed2 | ||
|
|
e63d725c2b | ||
|
|
fec964e767 | ||
|
|
366182f65b | ||
|
|
d9d09dddb3 | ||
|
|
04643255fe | ||
|
|
c2f7070f3b | ||
|
|
9c35f850b9 | ||
|
|
a7383b0796 | ||
|
|
4f52927d2f | ||
|
|
5b938c010d | ||
|
|
3e50c7018d | ||
|
|
32f3639eb0 | ||
|
|
9a7cb1bdfd | ||
|
|
ead7ab883b | ||
|
|
ff395ffa3e | ||
|
|
8f2ad586fd | ||
|
|
828cccaf10 | ||
|
|
ae26102df2 | ||
|
|
152c263fe0 | ||
|
|
38c933a257 | ||
|
|
d507e47e7a | ||
|
|
8c2f41c2d5 | ||
|
|
018c6d2228 | ||
|
|
32ba49dc8a | ||
|
|
a5cbc5d612 | ||
|
|
ef515021cc | ||
|
|
4af54bf7cb | ||
|
|
d9d1b8a8fb | ||
|
|
29ed755356 | ||
|
|
b862ea9d0d | ||
|
|
2677fc86be | ||
|
|
1ae40e3cdb | ||
|
|
90c63c8211 | ||
|
|
60c2e08dcf | ||
|
|
d6f08c61b8 | ||
|
|
784387dcb8 | ||
|
|
84092e1de6 | ||
|
|
bb3094869d | ||
|
|
ec497d7d00 | ||
|
|
5aa5ea5215 | ||
|
|
34853dfb36 | ||
|
|
362888cb14 | ||
|
|
abe8ea32aa | ||
|
|
40cccc5de5 | ||
|
|
81d1e80b59 | ||
|
|
210bcddbd3 | ||
|
|
c0cddb80b9 | ||
|
|
254d3fbbfc | ||
|
|
712b8a19cf | ||
|
|
9752270b6c | ||
|
|
7066afef33 | ||
|
|
63fe56467e | ||
|
|
6b7afaba03 | ||
|
|
454b545e35 | ||
|
|
4e4de23e80 | ||
|
|
68384caa04 | ||
|
|
6fc49c60d3 | ||
|
|
75260c6957 | ||
|
|
a77ebb5429 | ||
|
|
822589e4b2 | ||
|
|
90f2406b4f | ||
|
|
9d8554bd03 | ||
|
|
c60b19dbed | ||
|
|
492963e240 | ||
|
|
c9f6362928 | ||
|
|
3d2c2dc280 | ||
|
|
e2b55d55af | ||
|
|
6150619d1a | ||
|
|
7cba6bb0e6 | ||
|
|
d2190db45d | ||
|
|
d928c0ca72 | ||
|
|
5c09748fdb | ||
|
|
2bfc5a7860 | ||
|
|
73a0e2924d | ||
|
|
f973f374e7 | ||
|
|
8fb2ced9c5 | ||
|
|
d902cd7aab | ||
|
|
a781e458c3 | ||
|
|
3fe131bcf3 | ||
|
|
3260442893 | ||
|
|
e3542bc002 | ||
|
|
fb5f89e051 | ||
|
|
017e8845b9 | ||
|
|
30f1a8cbcd | ||
|
|
22006f7c0f | ||
|
|
166a142de5 | ||
|
|
b68425c5d3 | ||
|
|
9ab6906e81 | ||
|
|
86b6b28803 | ||
|
|
4409b78890 | ||
|
|
e52cd73549 | ||
|
|
10fca69038 | ||
|
|
1f95dd99f6 | ||
|
|
1e16b7ad01 | ||
|
|
cffee9cdca | ||
|
|
ee85226c2f | ||
|
|
db7d34e62e |
+5
-3
@@ -5,10 +5,12 @@ coverage:
|
||||
project:
|
||||
default: false
|
||||
tconnectsync:
|
||||
paths: "tconnectsync/"
|
||||
target: '75%'
|
||||
paths:
|
||||
- "tconnectsync/"
|
||||
target: '60%'
|
||||
threshold: '5%'
|
||||
tests:
|
||||
paths: "tests/"
|
||||
paths:
|
||||
- "tests/"
|
||||
target: '95%'
|
||||
threshold: '5%'
|
||||
@@ -22,19 +22,29 @@ jobs:
|
||||
repository: jwoglom/tconnectsync/tconnectsync
|
||||
tag_with_ref: true
|
||||
|
||||
- name: Push latest tag to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: docker.pkg.github.com
|
||||
repository: jwoglom/tconnectsync/tconnectsync
|
||||
tags: latest
|
||||
push: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
push_to_dockerhub:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
@@ -45,7 +55,7 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||
with:
|
||||
|
||||
@@ -28,6 +28,6 @@ jobs:
|
||||
--outdir dist/
|
||||
.
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
@@ -15,37 +15,63 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.7', '3.8', '3.9', '3.10']
|
||||
python-version: ['3.8', '3.9', '3.10', '3.11']
|
||||
|
||||
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 \
|
||||
--ignore 51499 # DDoS attack in wheel package, which is unsupported in python 3.7
|
||||
- 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
|
||||
- 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: |
|
||||
# # DDoS attacks in wheel and setuptools packages, not relevant
|
||||
# # root certificate store, not relevant
|
||||
# pipenv check \
|
||||
# --ignore 51499 \
|
||||
# --ignore 52495 \
|
||||
# --ignore 52365 \
|
||||
# --ignore 59956 \
|
||||
# --ignore 58755 \
|
||||
# --ignore 67895 \
|
||||
# --ignore 61893 \
|
||||
# --ignore 61601 \
|
||||
# --ignore 62044 \
|
||||
# --ignore 67599 \
|
||||
# --ignore 72083 \
|
||||
# --ignore 71064 \
|
||||
# --ignore 71608 \
|
||||
# --ignore 72236
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --ignore=F824 --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: Run tconnectsync --help
|
||||
run: |
|
||||
tconnectsync --help
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pytest
|
||||
- name: Check codecov configuration
|
||||
run: |
|
||||
curl -X POST --data-binary @.codecov.yml https://codecov.io/validate
|
||||
|
||||
if [[ "$(curl -s -o /dev/null -w "%{http_code}" -X POST --data-binary @.codecov.yml https://codecov.io/validate)" != "200" ]]; then
|
||||
echo Error parsing codecov file
|
||||
exit 1
|
||||
fi
|
||||
- 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: false
|
||||
|
||||
+3
-1
@@ -8,4 +8,6 @@ build
|
||||
*.egg-info
|
||||
.env
|
||||
tconnectsync-check-output.log
|
||||
ignore_*
|
||||
ignore_*
|
||||
.venv/
|
||||
.vscode/
|
||||
@@ -8,14 +8,23 @@ ptpython = "*"
|
||||
|
||||
[packages]
|
||||
tconnectsync = {path = "."}
|
||||
requests = "*"
|
||||
bs4 = "*"
|
||||
arrow = "*"
|
||||
arrow = "==1.2.3"
|
||||
lxml = "*"
|
||||
python-dotenv = "*"
|
||||
python-dotenv = "==0.21.1"
|
||||
requests-mock = "*"
|
||||
pysocks = "*"
|
||||
urllib3 = "==1.26.6"
|
||||
requests = {extras = ["socks"], version = "==2.31.0"}
|
||||
requests-oidc = "*"
|
||||
PyJWT = "==2.8.0"
|
||||
cryptography = "*"
|
||||
dataclasses-json = "*"
|
||||
cffi = ">=1.15.1"
|
||||
typing-extensions = "*"
|
||||
|
||||
[scripts]
|
||||
tconnectsync = "python3 main.py"
|
||||
test = "python3 -m unittest discover -vv"
|
||||
build_events = "bash -c 'cd tconnectsync/eventparser && python3 build_events.py > events.py'"
|
||||
lint = "bash -c 'flake8 . --count --select=E9,F63,F7,F82 && flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 && echo PASS'"
|
||||
Generated
+538
-133
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "b5eacb40f06a4e1d7e3951b84c11699899f1f264f028d43281bdf217f6737956"
|
||||
"sha256": "1d28c5104c37e390b88704d1453eec15e5c6df241ca85f82a88973f8aee2d1d1"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
@@ -20,122 +20,470 @@
|
||||
"sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==1.2.3"
|
||||
},
|
||||
"beautifulsoup4": {
|
||||
"hashes": [
|
||||
"sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30",
|
||||
"sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"
|
||||
"sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051",
|
||||
"sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==4.11.1"
|
||||
"markers": "python_full_version >= '3.6.0'",
|
||||
"version": "==4.12.3"
|
||||
},
|
||||
"bs4": {
|
||||
"hashes": [
|
||||
"sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"
|
||||
"sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925",
|
||||
"sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.0.1"
|
||||
"version": "==0.0.2"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14",
|
||||
"sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"
|
||||
"sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8",
|
||||
"sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2022.9.24"
|
||||
"version": "==2024.8.30"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8",
|
||||
"sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2",
|
||||
"sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1",
|
||||
"sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15",
|
||||
"sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36",
|
||||
"sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824",
|
||||
"sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8",
|
||||
"sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36",
|
||||
"sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17",
|
||||
"sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf",
|
||||
"sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc",
|
||||
"sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3",
|
||||
"sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed",
|
||||
"sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702",
|
||||
"sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1",
|
||||
"sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8",
|
||||
"sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903",
|
||||
"sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6",
|
||||
"sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d",
|
||||
"sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b",
|
||||
"sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e",
|
||||
"sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be",
|
||||
"sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c",
|
||||
"sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683",
|
||||
"sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9",
|
||||
"sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c",
|
||||
"sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8",
|
||||
"sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1",
|
||||
"sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4",
|
||||
"sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655",
|
||||
"sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67",
|
||||
"sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595",
|
||||
"sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0",
|
||||
"sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65",
|
||||
"sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41",
|
||||
"sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6",
|
||||
"sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401",
|
||||
"sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6",
|
||||
"sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3",
|
||||
"sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16",
|
||||
"sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93",
|
||||
"sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e",
|
||||
"sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4",
|
||||
"sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964",
|
||||
"sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c",
|
||||
"sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576",
|
||||
"sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0",
|
||||
"sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3",
|
||||
"sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662",
|
||||
"sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3",
|
||||
"sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff",
|
||||
"sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5",
|
||||
"sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd",
|
||||
"sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f",
|
||||
"sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5",
|
||||
"sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14",
|
||||
"sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d",
|
||||
"sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9",
|
||||
"sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7",
|
||||
"sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382",
|
||||
"sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a",
|
||||
"sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e",
|
||||
"sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a",
|
||||
"sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4",
|
||||
"sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99",
|
||||
"sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87",
|
||||
"sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.17.1"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845",
|
||||
"sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"
|
||||
"sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027",
|
||||
"sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087",
|
||||
"sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786",
|
||||
"sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8",
|
||||
"sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09",
|
||||
"sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185",
|
||||
"sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574",
|
||||
"sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e",
|
||||
"sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519",
|
||||
"sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898",
|
||||
"sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269",
|
||||
"sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3",
|
||||
"sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f",
|
||||
"sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6",
|
||||
"sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8",
|
||||
"sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a",
|
||||
"sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73",
|
||||
"sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc",
|
||||
"sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714",
|
||||
"sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2",
|
||||
"sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc",
|
||||
"sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce",
|
||||
"sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d",
|
||||
"sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e",
|
||||
"sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6",
|
||||
"sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269",
|
||||
"sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96",
|
||||
"sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d",
|
||||
"sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a",
|
||||
"sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4",
|
||||
"sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77",
|
||||
"sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d",
|
||||
"sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0",
|
||||
"sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed",
|
||||
"sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068",
|
||||
"sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac",
|
||||
"sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25",
|
||||
"sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8",
|
||||
"sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab",
|
||||
"sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26",
|
||||
"sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2",
|
||||
"sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db",
|
||||
"sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f",
|
||||
"sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5",
|
||||
"sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99",
|
||||
"sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c",
|
||||
"sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d",
|
||||
"sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811",
|
||||
"sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa",
|
||||
"sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a",
|
||||
"sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03",
|
||||
"sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b",
|
||||
"sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04",
|
||||
"sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c",
|
||||
"sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001",
|
||||
"sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458",
|
||||
"sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389",
|
||||
"sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99",
|
||||
"sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985",
|
||||
"sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537",
|
||||
"sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238",
|
||||
"sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f",
|
||||
"sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d",
|
||||
"sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796",
|
||||
"sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a",
|
||||
"sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143",
|
||||
"sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8",
|
||||
"sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c",
|
||||
"sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5",
|
||||
"sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5",
|
||||
"sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711",
|
||||
"sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4",
|
||||
"sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6",
|
||||
"sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c",
|
||||
"sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7",
|
||||
"sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4",
|
||||
"sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b",
|
||||
"sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae",
|
||||
"sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12",
|
||||
"sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c",
|
||||
"sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae",
|
||||
"sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8",
|
||||
"sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887",
|
||||
"sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b",
|
||||
"sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4",
|
||||
"sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f",
|
||||
"sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5",
|
||||
"sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33",
|
||||
"sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519",
|
||||
"sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.1.1"
|
||||
"markers": "python_full_version >= '3.7.0'",
|
||||
"version": "==3.3.2"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28",
|
||||
"sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==8.1.7"
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
"sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494",
|
||||
"sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806",
|
||||
"sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d",
|
||||
"sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062",
|
||||
"sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2",
|
||||
"sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4",
|
||||
"sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1",
|
||||
"sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85",
|
||||
"sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84",
|
||||
"sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042",
|
||||
"sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d",
|
||||
"sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962",
|
||||
"sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2",
|
||||
"sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa",
|
||||
"sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d",
|
||||
"sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365",
|
||||
"sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96",
|
||||
"sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47",
|
||||
"sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d",
|
||||
"sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d",
|
||||
"sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c",
|
||||
"sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb",
|
||||
"sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277",
|
||||
"sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172",
|
||||
"sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034",
|
||||
"sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a",
|
||||
"sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==43.0.1"
|
||||
},
|
||||
"dataclasses-json": {
|
||||
"hashes": [
|
||||
"sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a",
|
||||
"sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7' and python_version < '4.0'",
|
||||
"version": "==0.6.7"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
|
||||
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
|
||||
"sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
|
||||
"sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.4"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.10"
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
"sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318",
|
||||
"sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c",
|
||||
"sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b",
|
||||
"sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000",
|
||||
"sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73",
|
||||
"sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d",
|
||||
"sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb",
|
||||
"sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8",
|
||||
"sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2",
|
||||
"sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345",
|
||||
"sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94",
|
||||
"sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e",
|
||||
"sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b",
|
||||
"sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc",
|
||||
"sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a",
|
||||
"sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9",
|
||||
"sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc",
|
||||
"sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387",
|
||||
"sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb",
|
||||
"sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7",
|
||||
"sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4",
|
||||
"sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97",
|
||||
"sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67",
|
||||
"sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627",
|
||||
"sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7",
|
||||
"sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd",
|
||||
"sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3",
|
||||
"sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7",
|
||||
"sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130",
|
||||
"sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b",
|
||||
"sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036",
|
||||
"sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785",
|
||||
"sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca",
|
||||
"sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91",
|
||||
"sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc",
|
||||
"sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536",
|
||||
"sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391",
|
||||
"sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3",
|
||||
"sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d",
|
||||
"sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21",
|
||||
"sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3",
|
||||
"sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d",
|
||||
"sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29",
|
||||
"sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715",
|
||||
"sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed",
|
||||
"sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25",
|
||||
"sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c",
|
||||
"sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785",
|
||||
"sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837",
|
||||
"sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4",
|
||||
"sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b",
|
||||
"sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2",
|
||||
"sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067",
|
||||
"sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448",
|
||||
"sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d",
|
||||
"sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2",
|
||||
"sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc",
|
||||
"sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c",
|
||||
"sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5",
|
||||
"sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84",
|
||||
"sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8",
|
||||
"sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf",
|
||||
"sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7",
|
||||
"sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e",
|
||||
"sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb",
|
||||
"sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b",
|
||||
"sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3",
|
||||
"sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad",
|
||||
"sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8",
|
||||
"sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"
|
||||
"sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e",
|
||||
"sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229",
|
||||
"sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3",
|
||||
"sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5",
|
||||
"sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70",
|
||||
"sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15",
|
||||
"sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002",
|
||||
"sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd",
|
||||
"sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22",
|
||||
"sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf",
|
||||
"sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22",
|
||||
"sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832",
|
||||
"sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727",
|
||||
"sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e",
|
||||
"sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30",
|
||||
"sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f",
|
||||
"sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f",
|
||||
"sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51",
|
||||
"sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4",
|
||||
"sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de",
|
||||
"sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875",
|
||||
"sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42",
|
||||
"sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e",
|
||||
"sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6",
|
||||
"sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391",
|
||||
"sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc",
|
||||
"sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b",
|
||||
"sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237",
|
||||
"sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4",
|
||||
"sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86",
|
||||
"sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f",
|
||||
"sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a",
|
||||
"sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8",
|
||||
"sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f",
|
||||
"sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903",
|
||||
"sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03",
|
||||
"sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e",
|
||||
"sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99",
|
||||
"sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7",
|
||||
"sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab",
|
||||
"sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d",
|
||||
"sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22",
|
||||
"sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492",
|
||||
"sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b",
|
||||
"sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3",
|
||||
"sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be",
|
||||
"sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469",
|
||||
"sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f",
|
||||
"sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a",
|
||||
"sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c",
|
||||
"sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a",
|
||||
"sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4",
|
||||
"sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94",
|
||||
"sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442",
|
||||
"sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b",
|
||||
"sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84",
|
||||
"sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c",
|
||||
"sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9",
|
||||
"sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1",
|
||||
"sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be",
|
||||
"sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367",
|
||||
"sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e",
|
||||
"sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21",
|
||||
"sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa",
|
||||
"sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16",
|
||||
"sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d",
|
||||
"sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe",
|
||||
"sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83",
|
||||
"sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba",
|
||||
"sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040",
|
||||
"sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763",
|
||||
"sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8",
|
||||
"sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff",
|
||||
"sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2",
|
||||
"sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a",
|
||||
"sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b",
|
||||
"sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce",
|
||||
"sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c",
|
||||
"sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577",
|
||||
"sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8",
|
||||
"sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71",
|
||||
"sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512",
|
||||
"sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540",
|
||||
"sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f",
|
||||
"sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2",
|
||||
"sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a",
|
||||
"sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce",
|
||||
"sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e",
|
||||
"sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2",
|
||||
"sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27",
|
||||
"sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1",
|
||||
"sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d",
|
||||
"sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1",
|
||||
"sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330",
|
||||
"sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920",
|
||||
"sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99",
|
||||
"sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff",
|
||||
"sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18",
|
||||
"sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff",
|
||||
"sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c",
|
||||
"sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179",
|
||||
"sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080",
|
||||
"sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19",
|
||||
"sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d",
|
||||
"sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70",
|
||||
"sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32",
|
||||
"sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a",
|
||||
"sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2",
|
||||
"sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79",
|
||||
"sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3",
|
||||
"sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5",
|
||||
"sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f",
|
||||
"sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d",
|
||||
"sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3",
|
||||
"sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b",
|
||||
"sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753",
|
||||
"sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9",
|
||||
"sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957",
|
||||
"sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033",
|
||||
"sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb",
|
||||
"sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656",
|
||||
"sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab",
|
||||
"sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b",
|
||||
"sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d",
|
||||
"sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd",
|
||||
"sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859",
|
||||
"sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11",
|
||||
"sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c",
|
||||
"sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a",
|
||||
"sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005",
|
||||
"sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654",
|
||||
"sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80",
|
||||
"sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e",
|
||||
"sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec",
|
||||
"sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7",
|
||||
"sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965",
|
||||
"sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945",
|
||||
"sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.9.1"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.3.0"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e",
|
||||
"sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==3.22.0"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
"sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
|
||||
"sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"oauthlib": {
|
||||
"hashes": [
|
||||
"sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca",
|
||||
"sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.2.2"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
|
||||
"sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==24.1"
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3",
|
||||
"sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.11.0"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6",
|
||||
"sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.22"
|
||||
},
|
||||
"pyjwt": {
|
||||
"hashes": [
|
||||
"sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de",
|
||||
"sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2.8.0"
|
||||
},
|
||||
"pypng": {
|
||||
"hashes": [
|
||||
"sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c",
|
||||
"sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1"
|
||||
],
|
||||
"version": "==0.20220715.0"
|
||||
},
|
||||
"pysocks": {
|
||||
"hashes": [
|
||||
@@ -144,39 +492,70 @@
|
||||
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.7.1"
|
||||
},
|
||||
"python-dateutil": {
|
||||
"hashes": [
|
||||
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
|
||||
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
|
||||
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
|
||||
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.8.2"
|
||||
"version": "==2.9.0.post0"
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5",
|
||||
"sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"
|
||||
"sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49",
|
||||
"sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.21.0"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.21.1"
|
||||
},
|
||||
"qrcode": {
|
||||
"hashes": [
|
||||
"sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a",
|
||||
"sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==7.4.2"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983",
|
||||
"sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"
|
||||
"extras": [
|
||||
"socks"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.28.1"
|
||||
"hashes": [
|
||||
"sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
|
||||
"sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2.31.0"
|
||||
},
|
||||
"requests-mock": {
|
||||
"hashes": [
|
||||
"sha256:2fdbb637ad17ee15c06f33d31169e71bf9fe2bdb7bc9da26185be0dd8d842699",
|
||||
"sha256:59c9c32419a9fb1ae83ec242d98e889c45bd7d7a65d48375cc243ec08441658b"
|
||||
"sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563",
|
||||
"sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.10.0"
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.12.1"
|
||||
},
|
||||
"requests-oauthlib": {
|
||||
"hashes": [
|
||||
"sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5",
|
||||
"sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.3.1"
|
||||
},
|
||||
"requests-oidc": {
|
||||
"hashes": [
|
||||
"sha256:227fbcc561d0acb54469d62a7d4bde055a8eeee7968cdb98fa732e0d2313d814",
|
||||
"sha256:84ea777457067f8f67fe25534b9c153f3f9a45336c83e073a93ce735a86406ca"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7' and python_version < '4'",
|
||||
"version": "==0.6.2"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
@@ -188,23 +567,48 @@
|
||||
},
|
||||
"soupsieve": {
|
||||
"hashes": [
|
||||
"sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759",
|
||||
"sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"
|
||||
"sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb",
|
||||
"sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.3.2.post1"
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.6"
|
||||
},
|
||||
"tconnectsync": {
|
||||
"path": ".",
|
||||
"version": "==0.8.6"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"path": "."
|
||||
},
|
||||
"typer": {
|
||||
"hashes": [
|
||||
"sha256:aa6c4a4e2329d868b80ecbaf16f807f2b54e192209d7ac9dd42691d63f7a54eb",
|
||||
"sha256:f714c2d90afae3a7929fcd72a3abb08df305e1ff61719381384211c4070af57f"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.9.4"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
|
||||
"sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.12.2"
|
||||
},
|
||||
"typing-inspect": {
|
||||
"hashes": [
|
||||
"sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f",
|
||||
"sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"
|
||||
],
|
||||
"version": "==0.9.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc",
|
||||
"sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"
|
||||
"sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4",
|
||||
"sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==1.26.13"
|
||||
"index": "pypi",
|
||||
"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.6"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@@ -217,50 +621,51 @@
|
||||
},
|
||||
"jedi": {
|
||||
"hashes": [
|
||||
"sha256:203c1fd9d969ab8f2119ec0a3342e0b49910045abe6af0a3ae83a5764d54639e",
|
||||
"sha256:bae794c30d07f6d910d32a7048af09b5a39ed740918da923c6b780790ebac612"
|
||||
"sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd",
|
||||
"sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.18.2"
|
||||
"version": "==0.19.1"
|
||||
},
|
||||
"parso": {
|
||||
"hashes": [
|
||||
"sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0",
|
||||
"sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"
|
||||
"sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18",
|
||||
"sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.8.3"
|
||||
"version": "==0.8.4"
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:535c29c31216c77302877d5120aef6c94ff573748a5b5ca5b1b1f76f5e700c73",
|
||||
"sha256:ced598b222f6f4029c0800cefaa6a17373fb580cd093223003475ce32805c35b"
|
||||
"sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90",
|
||||
"sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.2'",
|
||||
"version": "==3.0.33"
|
||||
"markers": "python_full_version >= '3.7.0'",
|
||||
"version": "==3.0.48"
|
||||
},
|
||||
"ptpython": {
|
||||
"hashes": [
|
||||
"sha256:1ec28ba37959e8e6c4880fcf4748ad5c61341d4c1bfd24a38a4d5b97af1f475a",
|
||||
"sha256:a57b9952eac4a12540a4dfb4ccd4a243403eceb27b0d13245b5e4144c8731d32"
|
||||
"sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402",
|
||||
"sha256:b9d625183aef93a673fc32cbe1c1fcaf51412e7a4f19590521cdaccadf25186e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.21"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.0.29"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1",
|
||||
"sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"
|
||||
"sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199",
|
||||
"sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.13.0"
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.18.0"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
"sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
|
||||
"sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
|
||||
"sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859",
|
||||
"sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"
|
||||
],
|
||||
"version": "==0.2.5"
|
||||
"version": "==0.2.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,29 @@
|
||||

|
||||
[](https://codecov.io/gh/jwoglom/tconnectsync)
|
||||
|
||||
Tconnectsync synchronizes data one-way from the Tandem Diabetes t:connect web/mobile application to Nightscout.
|
||||
Tconnectsync synchronizes data one-way from Tandem Source to Nightscout.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Tandem has announced that t:connect will be shut down in favor of Tandem Source in the US beginning September 30, 2024.
|
||||
> tconnectsync has undergone major changes to support Tandem Source. **For Tandem Source support, you MUST upgrade to tconnectsync version 2.0 or above.**
|
||||
|
||||
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.
|
||||
Together with a CGM uploader, such as [xDrip+](https://github.com/NightscoutFoundation/xDrip) or the official Dexcom mobile app plus Dexcom Share, this allows your CGM _and_ pump data to be automatically uploaded to Nightscout!
|
||||
|
||||
If you have an Android phone, you can use [tconnectpatcher](https://github.com/jwoglom/tconnectpatcher) to modify the t:connect Android app to upload more frequently. By default, pump data is uploaded to Tandem's servers every hour, but with tconnectpatcher the frequency can be brought down to **as low as every five minutes**! This allows for nearly real-time (albeit not fully instantaneous) pump data updates, almost like your pump uploads data directly to Nightscout!
|
||||
|
||||
## How It Works
|
||||
|
||||
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.
|
||||
At a high level, tconnectsync works by querying Tandem's undocumented APIs to receive basal and bolus data from Tandem Source, 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 to Nightscout whenever there is new data.
|
||||
|
||||
When you run the program with no arguments, it performs a single cycle of the following, and exits after completion:
|
||||
|
||||
* 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.
|
||||
* Logs in to Tandem Source
|
||||
* Fetches your list of pumps, and unless overridden by an environment variable, fetches the event data for the pump which was most recently used
|
||||
* Processes the internal pump event metadata to extract basal, bolus, CGM, and other pump event data
|
||||
* Queries Nightscout to find the most recent data which was uploaded to it for each event category
|
||||
* Uploads any missing data to Nightscout
|
||||
|
||||
If run with the `--auto-update` flag, then the application performs the following steps:
|
||||
|
||||
* 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.
|
||||
If run with the `--auto-update` flag, then the application periodically looks for new data and synchronizes it to Nightscout in a loop every few minutes.
|
||||
|
||||
## What Gets Synced
|
||||
|
||||
@@ -36,28 +36,23 @@ When setting up tconnectsync, you can choose to configure which synchronization
|
||||
Here are a few examples of reasons why you might want to adjust the enabled synchronization features:
|
||||
|
||||
* If you currently input boluses into Nightscout manually with comments, then you may wish to _disable the `BOLUS` synchronization feature_ so that there are no duplicated boluses in Nightscout.
|
||||
* If you want to see Sleep and Exercise Mode data appear in Nightscout, then you may with to _enable the `PUMP_EVENTS` synchronization feature_.
|
||||
* If you want to automatically update your Nightscout insulin profile settings from your pump, then you may want to _enable the `PROFILES` synchronization feature_.
|
||||
* If you want to see Sleep and Exercise Mode data appear in Nightscout, then you may wish to _enable the `PUMP_EVENTS` synchronization feature_.
|
||||
* If you want to automatically update your Nightscout insulin profile settings from your pump, then you may wish to _enable the `PROFILES` synchronization feature_.
|
||||
|
||||
These synchronization features are enabled by default:
|
||||
|
||||
* `BASAL`: Basal data
|
||||
* `BOLUS`: Bolus data
|
||||
* `PUMP_EVENTS`: Events reported by the pump. Includes support for the following:
|
||||
* Alarms, like cartridge out-of-insulin or pump malfunction
|
||||
* Basal suspension (user or pump-initiated) and resume
|
||||
* Cartridge, cannula, and tubing filled
|
||||
* Sleep and exercise modes
|
||||
* `PROFILES`: Insulin profile information, including segments, basal rates, correction factors, carb ratios, and the profile which is active.
|
||||
|
||||
The following synchronization features can be optionally enabled:
|
||||
* `CGM`: Adds Dexcom CGM readings from the pump to Nightscout as SGV (sensor glucose value) entries. This should only be used in a situation where xDrip/Dexcom Share/etc. is not used and the pump connection to the CGM will be the only source of CGM data to Nightscout. **THIS WILL DELIVER CGM DATA WITH A SIGNIFICANT (>30 MINUTE) LAG AND SHOULD NOT BE USED AS A REPLACEMENT FOR DEXCOM SHARE OR OTHER REAL TIME MONITORING.**
|
||||
|
||||
* `PROFILES`: Insulin profile information, including segments, basal rates, correction factors, carb ratios, and the profile which is active.
|
||||
* `PUMP_EVENTS`: Events reported by the pump. Includes support for the following:
|
||||
* Site/Cartridge Change (occurs for both a site change and a cartridge change)
|
||||
* Empty Cartridge/Pump Shutdown (from my investigation, occurs either when the cartridge runs out of insulin OR you hard-shut off the pump)
|
||||
* User Suspended (occurs when you manually disable insulin delivery)
|
||||
* Exercise Mode (in Nightscout, appears with a start and end time)
|
||||
* Sleep Mode (in Nightscout, appears with a start and end time)
|
||||
* `IOB`: Insulin-on-board data. Only the most recent IOB entry is saved to Nightscout, as an "activity". The Nightscout UI does not currently display this information. In order to read this value, you need to query the Nightscout activity API endpoint. If you don't know what that means, then there is no reason to enable this option.
|
||||
|
||||
The following synchronization features are under development, [**but are not yet ready for use**](https://github.com/jwoglom/tconnectsync/issues/16):
|
||||
* `BOLUS_BG`: Adds BG readings which are associated with boluses on the pump into the Nightscout treatment object. It will determine whether the BG reading was automatically filled via the Dexcom connection on the pump or was manually entered by seeing if the BG reading matches the current CGM reading as known to the pump at that time. Support for this is nearly complete.
|
||||
* `CGM`: Adds Dexcom CGM readings from the pump to Nightscout as SGV (sensor glucose value) entries. This should only be used in a situation where xDrip/Dexcom Share/etc. is not used and the pump connection to the CGM will be the only source of CGM data to Nightscout. This requires additional testing before it should be considered ready.
|
||||
|
||||
To specify custom synchronization features, pass the names of the desired features to the `--features` flag, e.g.:
|
||||
|
||||
@@ -94,8 +89,8 @@ You should specify the following parameters:
|
||||
TCONNECT_EMAIL='email@email.com'
|
||||
TCONNECT_PASSWORD='password'
|
||||
|
||||
# Your pump's serial number (numeric)
|
||||
PUMP_SERIAL_NUMBER=11111111
|
||||
# OPTIONAL: Your region (US or EU)
|
||||
TCONNECT_REGION=US
|
||||
|
||||
# URL of your Nightscout site
|
||||
NS_URL='https://yournightscouturl/'
|
||||
@@ -104,6 +99,10 @@ NS_SECRET='apisecret'
|
||||
|
||||
# Current timezone of the pump
|
||||
TIMEZONE_NAME='America/New_York'
|
||||
|
||||
# OPTIONAL: Your pump's serial number (numeric)
|
||||
PUMP_SERIAL_NUMBER=11111111
|
||||
|
||||
```
|
||||
|
||||
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). When specifying the timezone, enter a [TZ database name value](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
|
||||
@@ -119,12 +118,14 @@ First, ensure that you have **Python 3** with **Pip** installed:
|
||||
* **On MacOS:** Open Terminal. Install [Homebrew](https://brew.sh/), and then run `brew install python3`
|
||||
* **On Linux:** Follow your distribution's specific instructions.
|
||||
- For Debian/Ubuntu based distros, `sudo apt install python3 python3-pip`
|
||||
- For CentOS/Rocky Linux 8:
|
||||
- For CentOS/Rocky Linux 8:
|
||||
- `sudo dnf install python39-pip`
|
||||
- `sudo alternatives --set python /usr/bin/python3.9`
|
||||
* **On Windows:** Install Ubuntu under the [Windows Subsystem for Linux](https://ubuntu.com/wsl).
|
||||
Open the Ubuntu Terminal, then run `sudo apt install python3 python3-pip`.
|
||||
Perform the remainder of the steps under the Ubuntu environment.
|
||||
- `sudo alternatives --set python /usr/bin/python3.9`
|
||||
* **On Windows:**
|
||||
- **With WSL:** Install Ubuntu under the [Windows Subsystem for Linux](https://ubuntu.com/wsl).
|
||||
Open the Ubuntu Terminal, then run `sudo apt install python3 python3-pip`.
|
||||
Perform the remainder of the steps under the Ubuntu environment.
|
||||
- **Native:** Alternatively, you can run tconnectsync in native Windows with no modifications. However, this is less well-tested (open a GitHub issue if you experience any problems).
|
||||
|
||||
Now install the `tconnectsync` package with pip:
|
||||
|
||||
@@ -136,7 +137,7 @@ To install into a user environment instead of system-wide for a more contained i
|
||||
$ pip3 install --user tconnectsync
|
||||
````
|
||||
- This will place the tconnectsync binary file at ``/home/<username>/.local/bin/tconnectsync``
|
||||
|
||||
- For non-WSL Windows, it will be in ``<PYTHON DIRECTORY>\Lib\site-packages\tconnectsync``
|
||||
|
||||
If the pip3 command is not found, run `python3 -m pip install tconnectsync` instead.
|
||||
|
||||
@@ -167,7 +168,7 @@ Move the `.env` file you created to the following folder:
|
||||
|
||||
* **MacOS:** `/Users/<username>/.config/tconnectsync/.env`
|
||||
* **Linux:** `$HOME/.config/tconnectsync/.env`
|
||||
* **Windows:** `$HOME/.config/tconnectsync/.env` (inside WSL)
|
||||
* **Windows:** `$HOME/.config/tconnectsync/.env` (inside WSL) OR `C:\Users\<username>\.config\tconnectsync` (native Windows)
|
||||
|
||||
```
|
||||
$ tconnectsync --check-login
|
||||
@@ -402,6 +403,20 @@ An example of a user crontab `crontab -e` if not running system-wide, which runs
|
||||
|
||||
You can use one of the same `run.sh` files referenced above, but remove the `--auto-update` flag since you are handling the functionality for running the script periodically yourself.
|
||||
|
||||
### For Native Windows
|
||||
|
||||
Create a batch file 'tconnectsync.bat' file containing:
|
||||
|
||||
```
|
||||
python "C:\Users\<USERNAME>\AppData\Local\Programs\Python\<PYTHONVERSIONDIRECTORY>\Lib\site-packages\tconnectsync\main.py" --auto-update
|
||||
```
|
||||
|
||||
If `python` does not exist in your path, specify the full path to `python.exe`.
|
||||
|
||||
If main.py doesn't exist in `C:\Users\<USERNAME>\AppData\Local\Programs\Python\<PYTHONVERSIONDIRECTORY>\Lib\site-packages\tconnectsync\`, create it to match the copy in this repository.
|
||||
|
||||
[Use Windows Task Scheduler](https://www.windowscentral.com/how-create-automated-task-using-task-scheduler-windows-10) to run this batch file on a scheduled basis.
|
||||
|
||||
## Tandem APIs
|
||||
|
||||
This application utilizes three separate Tandem APIs for obtaining t:connect data, referenced here by the identifying part of their URLs:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 50%
|
||||
threshold: null
|
||||
@@ -1,9 +1,9 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.9.1
|
||||
author = James Woglom
|
||||
version = 2.3.0
|
||||
author = James Woglom
|
||||
author_email = j@wogloms.net
|
||||
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
|
||||
description = Syncs Tandem Source (formerly t:connect) insulin 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
|
||||
@@ -25,6 +25,16 @@ install_requires =
|
||||
arrow
|
||||
lxml
|
||||
python-dotenv
|
||||
requests-mock
|
||||
pysocks
|
||||
urllib3
|
||||
requests
|
||||
requests-oidc
|
||||
PyJWT
|
||||
cryptography
|
||||
dataclasses-json
|
||||
cffi
|
||||
typing-extensions
|
||||
|
||||
[options.packages.find]
|
||||
where = .
|
||||
|
||||
+48
-12
@@ -4,10 +4,17 @@ import arrow
|
||||
import argparse
|
||||
import logging
|
||||
import pkg_resources
|
||||
import typing
|
||||
|
||||
# Required for cryptography lib in python 3.7
|
||||
if sys.version_info < (3, 8):
|
||||
import typing_extensions
|
||||
typing.Protocol = typing_extensions.Protocol
|
||||
|
||||
from .api import TConnectApi
|
||||
from .process import process_time_range
|
||||
from .autoupdate import Autoupdate
|
||||
from .sync.tandemsource.autoupdate import TandemSourceAutoupdate
|
||||
from .sync.tandemsource.choose_device import ChooseDevice as TandemSourceChooseDevice
|
||||
from .sync.tandemsource.process import ProcessTimeRange as TandemSourceProcessTimeRange
|
||||
from .check import check_login
|
||||
from .nightscout import NightscoutApi
|
||||
from .features import DEFAULT_FEATURES, ALL_FEATURES
|
||||
@@ -16,13 +23,16 @@ try:
|
||||
from .secret import (
|
||||
TCONNECT_EMAIL,
|
||||
TCONNECT_PASSWORD,
|
||||
TCONNECT_REGION,
|
||||
NS_URL,
|
||||
NS_SECRET,
|
||||
NS_SKIP_TLS_VERIFY
|
||||
NS_SKIP_TLS_VERIFY,
|
||||
PUMP_SERIAL_NUMBER,
|
||||
NS_IGNORE_CONN_ERRORS
|
||||
)
|
||||
from . import secret
|
||||
except Exception:
|
||||
print('Unable to read secret.py')
|
||||
except Exception as e:
|
||||
print('Unable to read secrets from secret.py', e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -42,6 +52,8 @@ def parse_args(*args, **kwargs):
|
||||
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.')
|
||||
parser.add_argument('--features', dest='features', nargs='+', default=DEFAULT_FEATURES, choices=ALL_FEATURES, help='Specifies what data should be synchronized between tconnect and Nightscout.')
|
||||
parser.add_argument('--tandem-source', dest='tandem_source', action='store_const', const=True, default=True, help=argparse.SUPPRESS) # no longer used
|
||||
parser.add_argument('--region', dest='region', type=str, choices=['US', 'EU'], default=None, help='Tandem t:connect server region (US or EU). If not specified, uses TCONNECT_REGION from configuration or defaults to US.')
|
||||
|
||||
return parser.parse_args(*args, **kwargs)
|
||||
|
||||
@@ -73,21 +85,45 @@ def main(*args, **kwargs):
|
||||
if time_end < time_start:
|
||||
raise Exception('time_start must be before time_end')
|
||||
|
||||
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
|
||||
# Determine region: command line arg takes precedence, then config, then default to US
|
||||
region = args.region if args.region else TCONNECT_REGION
|
||||
|
||||
nightscout = NightscoutApi(NS_URL, NS_SECRET, NS_SKIP_TLS_VERIFY)
|
||||
if TCONNECT_EMAIL == 'email@email.com':
|
||||
logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.')
|
||||
if TCONNECT_PASSWORD == 'password':
|
||||
logging.warn('NO PASSWORD WAS PROVIDED. Ensure you have set TCONNECT_PASSWORD appropriately.')
|
||||
if NS_URL == 'https://yournightscouturl/':
|
||||
logging.warn('NO NIGHTSCOUT URL WAS PROVIDED. Ensure your have set NS_URL appropriately.')
|
||||
if PUMP_SERIAL_NUMBER == '11111111':
|
||||
if args.tandem_source:
|
||||
secret.PUMP_SERIAL_NUMBER = None
|
||||
else:
|
||||
logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.')
|
||||
|
||||
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, region)
|
||||
|
||||
nightscout = NightscoutApi(NS_URL, NS_SECRET, skip_verify=NS_SKIP_TLS_VERIFY, ignore_conn_errors=NS_IGNORE_CONN_ERRORS)
|
||||
|
||||
if args.check_login:
|
||||
return check_login(tconnect, time_start, time_end)
|
||||
|
||||
logging.warning("THIS VERSION OF TCONNECTSYNC READS DATA FROM TANDEM SOURCE, AND MAY CONTAIN BUGS!")
|
||||
logging.info("You may notice different behavior compared to older versions which utilized t:connect data sources.")
|
||||
logging.info("To report a bug or to get help, see https://github.com/jwoglom/tconnectsync/issues")
|
||||
|
||||
logging.info(f"Using Tandem t:connect region: {region}")
|
||||
logging.info("Enabled features: " + ", ".join(args.features))
|
||||
|
||||
if args.check_login:
|
||||
args.pretend = True
|
||||
|
||||
if args.auto_update:
|
||||
print("Starting auto-update between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
|
||||
u = Autoupdate(secret)
|
||||
u = TandemSourceAutoupdate(secret)
|
||||
sys.exit(u.process(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features))
|
||||
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, features=args.features)
|
||||
print("Added", added, "items")
|
||||
tconnectDevice = TandemSourceChooseDevice(secret, tconnect).choose()
|
||||
added, last_event_id = TandemSourceProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend=args.pretend, secret=secret, features=args.features).process(time_start, time_end)
|
||||
|
||||
# return exit code 0 if processed events
|
||||
sys.exit(0 if added>0 else 1)
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,6 +4,7 @@ from .android import AndroidApi
|
||||
from .controliq import ControlIQApi
|
||||
from .ws2 import WS2Api
|
||||
from .webui import WebUIScraper
|
||||
from .tandemsource import TandemSourceApi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,13 +13,25 @@ class TConnectApi:
|
||||
email = None
|
||||
password = None
|
||||
|
||||
def __init__(self, email, password):
|
||||
def __init__(self, email, password, region='US'):
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.region = region
|
||||
self._ciq = None
|
||||
self._ws2 = None
|
||||
self._android = None
|
||||
self._webui = None
|
||||
self._tandemsource = None
|
||||
|
||||
@property
|
||||
def tandemsource(self):
|
||||
if self._tandemsource and not self._tandemsource.needs_relogin():
|
||||
return self._tandemsource
|
||||
|
||||
logger.debug(f"Instantiating new TandemSourceApi for region {self.region}")
|
||||
|
||||
self._tandemsource = TandemSourceApi(self.email, self.password, self.region)
|
||||
return self._tandemsource
|
||||
|
||||
|
||||
@property
|
||||
|
||||
@@ -29,6 +29,10 @@ class AndroidApi:
|
||||
ANDROID_API_USERNAME = base64.b64decode('QzIzMzFDRDYtRDQ1MC00OTVFLTlDMTktNjcyMTUyMzBDODVD').decode()
|
||||
ANDROID_API_PASSWORD = base64.b64decode('dHo0MzNLVzVRREM5VjdmIXo2QF4ybyZZNlNHR1lo').decode()
|
||||
|
||||
# These credentials are used by tconnect web
|
||||
TCONNECT_WEB_USERNAME = base64.b64decode('M0U2MzU3QkEtRjYyNS00REQyLUI2NUYtNEI1RTgxNDRBQTZG').decode()
|
||||
TCONNECT_WEB_PASSWORD = base64.b64decode('cUMyaXFIc2w3OFFoR0RYdCpMenFwb1pxZTl3eHN6').decode()
|
||||
|
||||
ANDROID_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 12; Pixel 4a Build/SP2A.220305.012)'
|
||||
|
||||
# These tokens are separate from the "standard" tdcservices API
|
||||
@@ -65,15 +69,17 @@ class AndroidApi:
|
||||
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)
|
||||
# tconnect web returns a null user
|
||||
# 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"]
|
||||
if "refreshToken" in j and "refreshTokenExpiresAt" in j:
|
||||
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)))
|
||||
|
||||
@@ -11,6 +11,11 @@ def parse_date(date):
|
||||
return date
|
||||
return (date or datetime.datetime.now()).strftime('%m-%d-%Y')
|
||||
|
||||
def parse_ymd_date(date):
|
||||
if type(date) == str:
|
||||
date = arrow.get(date)
|
||||
return (date or datetime.datetime.now()).strftime('%Y-%m-%d')
|
||||
|
||||
def parsed_date_to_arrow(date):
|
||||
return arrow.get(datetime.datetime.strptime(date, '%m-%d-%Y'))
|
||||
|
||||
@@ -117,17 +122,17 @@ def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[str, str]]:
|
||||
if (cur - cur_s).days >= days-1:
|
||||
ranges.append((cur_s, cur))
|
||||
cur_s = cur + datetime.timedelta(days=1)
|
||||
|
||||
|
||||
cur += datetime.timedelta(days=1)
|
||||
if len(ranges) > 0 and (end - ranges[-1][-1]).days > 0:
|
||||
ranges.append((cur_s, end))
|
||||
|
||||
|
||||
return ranges
|
||||
|
||||
class ApiException(Exception):
|
||||
def __init__(self, status_code, text, *args, **kwargs):
|
||||
self.status_code = status_code
|
||||
super().__init__('%s (HTTP %s)' % (text, status_code), *args, **kwargs)
|
||||
super().__init__('%s%s' % (text, ' (HTTP %s)' % status_code if status_code else ''), *args, **kwargs)
|
||||
|
||||
class ApiLoginException(ApiException):
|
||||
pass
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from ..util import timeago
|
||||
from ..util import timeago, cap_length
|
||||
from .common import parse_date, base_headers, base_session, ApiException, ApiLoginException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,7 +14,7 @@ class ControlIQApi:
|
||||
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
|
||||
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
|
||||
|
||||
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.14.0.1'
|
||||
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.17.2.3'
|
||||
|
||||
userGuid = None
|
||||
accessToken = None
|
||||
@@ -34,18 +34,38 @@ class ControlIQApi:
|
||||
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)
|
||||
# HTTP 200 is reported when credentials are incorrect
|
||||
if req.status_code == 200:
|
||||
login_error = self._find_login_error(req.text)
|
||||
if not login_error:
|
||||
login_error = 'Check your login credentials.'
|
||||
raise ApiLoginException(None, 'Error logging in to t:connect: %s' % login_error)
|
||||
|
||||
if req.status_code != 302:
|
||||
raise ApiLoginException(req.status_code, 'Error logging in to t:connect. Check your login credentials.')
|
||||
raise ApiLoginException(req.status_code, 'Error logging in to t:connect')
|
||||
|
||||
|
||||
fwd = s.post(urllib.parse.urljoin(self.LOGIN_URL, req.headers['Location']), cookies=req.cookies, headers=base_headers())
|
||||
if fwd.status_code != 200:
|
||||
logger.warn("Received non-HTTP 200: %s" % req.text)
|
||||
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)))
|
||||
if 'accessToken' in req.cookies and 'accessTokenExpiresAt' in req.cookies:
|
||||
self.accessToken = req.cookies['accessToken']
|
||||
self.accessTokenExpiresAt = req.cookies['accessTokenExpiresAt']
|
||||
logger.info("Logged in to ControlIQApi successfully via accessToken cookie (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
|
||||
else:
|
||||
logger.info("No accessToken cookie found when logging in to ControlIQApi. Triggering AndroidApi auth")
|
||||
|
||||
from .android import AndroidApi
|
||||
android = AndroidApi(email, password)
|
||||
self.accessToken = android.accessToken
|
||||
self.accessTokenExpiresAt = android.accessTokenExpiresAt
|
||||
|
||||
logger.info("Logged in to AndroidApi successfully via accessToken param (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
|
||||
|
||||
|
||||
self.loginSession = s
|
||||
return True
|
||||
|
||||
@@ -66,6 +86,13 @@ class ControlIQApi:
|
||||
contents = "%s[SNIP]%s" % (contents[:500], contents[-500:])
|
||||
logger.info("BeautifulSoup parsed contents: %s" % contents)
|
||||
pass
|
||||
if not soup.select_one("#__VIEWSTATE"):
|
||||
enc_contents = str(soup.encode_contents())
|
||||
if "Web Page Blocked!" in enc_contents or "Attack ID:" in enc_contents:
|
||||
logger.warn("Being ratelimited/blocked by web application firewall. Sleeping for 30 minutes before retrying.")
|
||||
logger.info("BeautifulSoup parsed contents: %s" % enc_contents)
|
||||
time.sleep(1800)
|
||||
exit(1)
|
||||
return {
|
||||
"__LASTFOCUS": "",
|
||||
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
|
||||
@@ -79,7 +106,18 @@ class ControlIQApi:
|
||||
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password)
|
||||
}
|
||||
|
||||
def _find_login_error(self, text):
|
||||
try:
|
||||
soup = BeautifulSoup(text, features='lxml')
|
||||
notice_error = soup.select_one(".notice_error").text.strip()
|
||||
return notice_error
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def needs_relogin(self):
|
||||
if not self.accessTokenExpiresAt:
|
||||
return False
|
||||
|
||||
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
|
||||
return (diff.seconds <= 5 * 60)
|
||||
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import urllib
|
||||
import arrow
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import jwt
|
||||
import pickle
|
||||
|
||||
from requests_oidc import make_auth_code_session
|
||||
from requests_oidc.plugins import OSCachedPlugin
|
||||
from requests_oidc.utils import ServerDetails
|
||||
from requests_oauthlib import OAuth2Session
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
|
||||
from ..util import timeago, cap_length
|
||||
from .common import parse_ymd_date, base_headers, base_session, ApiException, ApiLoginException
|
||||
from ..secret import CACHE_CREDENTIALS, CACHE_CREDENTIALS_PATH
|
||||
from ..eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TandemSourceApi:
|
||||
# Common URLs that are shared between regions
|
||||
LOGIN_PAGE_URL = 'https://sso.tandemdiabetes.com/'
|
||||
TDC_AUTH_CALLBACK_URL = 'https://sso.tandemdiabetes.com/auth/callback'
|
||||
|
||||
# US Region URLs (default)
|
||||
_US_URLS = {
|
||||
'LOGIN_API_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/login',
|
||||
'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/oauth2/v1/authorize',
|
||||
'TDC_OIDC_JWKS_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks',
|
||||
'TDC_OIDC_ISSUER': 'https://tdcservices.tandemdiabetes.com/accounts/api',
|
||||
'TDC_OIDC_CLIENT_ID': '0oa27ho9tpZE9Arjy4h7',
|
||||
'SOURCE_URL': 'https://source.tandemdiabetes.com/',
|
||||
'REDIRECT_URI': 'https://sso.tandemdiabetes.com/auth/callback',
|
||||
'TOKEN_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/token',
|
||||
'AUTHORIZATION_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/authorize'
|
||||
}
|
||||
|
||||
# EU Region URLs
|
||||
_EU_URLS = {
|
||||
'LOGIN_API_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/login',
|
||||
'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/oauth2/v1/authorize',
|
||||
'TDC_OIDC_JWKS_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks',
|
||||
'TDC_OIDC_ISSUER': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api',
|
||||
'TDC_OIDC_CLIENT_ID': '1519e414-eeec-492e-8c5e-97bea4815a10',
|
||||
'SOURCE_URL': 'https://source.eu.tandemdiabetes.com/',
|
||||
'REDIRECT_URI': 'https://source.eu.tandemdiabetes.com/authorize/callback',
|
||||
'TOKEN_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/token',
|
||||
'AUTHORIZATION_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/authorize'
|
||||
}
|
||||
|
||||
def __init__(self, email, password, region='US'):
|
||||
self.region = region.upper()
|
||||
if self.region not in ['US', 'EU']:
|
||||
raise ValueError(f"Invalid region '{region}'. Must be 'US' or 'EU'.")
|
||||
|
||||
self._region_urls = self._US_URLS if self.region == 'US' else self._EU_URLS
|
||||
|
||||
self.login(email, password)
|
||||
self._email = email
|
||||
self._password = password
|
||||
|
||||
@property
|
||||
def LOGIN_API_URL(self):
|
||||
return self._region_urls['LOGIN_API_URL']
|
||||
|
||||
@property
|
||||
def TDC_OAUTH_AUTHORIZE_URL(self):
|
||||
return self._region_urls['TDC_OAUTH_AUTHORIZE_URL']
|
||||
|
||||
@property
|
||||
def TDC_OIDC_JWKS_URL(self):
|
||||
return self._region_urls['TDC_OIDC_JWKS_URL']
|
||||
|
||||
@property
|
||||
def TDC_OIDC_ISSUER(self):
|
||||
return self._region_urls['TDC_OIDC_ISSUER']
|
||||
|
||||
@property
|
||||
def TDC_OIDC_CLIENT_ID(self):
|
||||
return self._region_urls['TDC_OIDC_CLIENT_ID']
|
||||
|
||||
@property
|
||||
def SOURCE_URL(self):
|
||||
return self._region_urls['SOURCE_URL']
|
||||
|
||||
def login(self, email, password):
|
||||
logger.info(f"Logging in to TandemSourceApi ({self.region} region)...")
|
||||
if self.try_load_cached_creds(email):
|
||||
logger.info("Successfully used cached credentials")
|
||||
return True
|
||||
|
||||
with base_session() as s:
|
||||
initial = s.get(self.LOGIN_PAGE_URL, headers=base_headers())
|
||||
|
||||
data = {
|
||||
"username": email,
|
||||
"password": password
|
||||
}
|
||||
|
||||
req = s.post(self.LOGIN_API_URL, json=data, headers={'Referer': self.LOGIN_PAGE_URL, **base_headers()}, allow_redirects=False)
|
||||
|
||||
logger.debug("1. made POST to LOGIN_API")
|
||||
# {"redirectUrl":"/","status":"SUCCESS"}
|
||||
if req.status_code != 200:
|
||||
raise ApiException(req.status_code, 'Error sending POST to login_api_url: %s' % req.text)
|
||||
|
||||
req_json = req.json()
|
||||
login_ok = req_json.get('status', '') == 'SUCCESS'
|
||||
|
||||
if not login_ok:
|
||||
raise ApiException(req.status_code, 'Error parsing login_api_url: %s' % json.dumps(req_json))
|
||||
|
||||
logger.debug("2. starting OIDC")
|
||||
|
||||
# oidc
|
||||
client_id = self.TDC_OIDC_CLIENT_ID
|
||||
redirect_uri = self._region_urls['REDIRECT_URI']
|
||||
scope = 'openid profile email'
|
||||
|
||||
token_endpoint = self._region_urls['TOKEN_ENDPOINT']
|
||||
|
||||
def generate_code_verifier():
|
||||
"""Generates a high-entropy code verifier."""
|
||||
code_verifier = base64.urlsafe_b64encode(os.urandom(64)).decode('utf-8').rstrip('=')
|
||||
return code_verifier
|
||||
|
||||
def generate_code_challenge(verifier):
|
||||
"""Generates a code challenge from the code verifier."""
|
||||
sha256_digest = hashlib.sha256(verifier.encode('utf-8')).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(sha256_digest).decode('utf-8').rstrip('=')
|
||||
return code_challenge
|
||||
|
||||
|
||||
code_verifier = generate_code_verifier()
|
||||
code_challenge = generate_code_challenge(code_verifier)
|
||||
|
||||
authorization_endpoint = self._region_urls['AUTHORIZATION_ENDPOINT']
|
||||
|
||||
oidc_step1_params = {
|
||||
'client_id': client_id,
|
||||
'response_type': 'code',
|
||||
'scope': scope,
|
||||
'redirect_uri': redirect_uri,
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
|
||||
logger.debug("3. calling oidc_step1 with %s" % json.dumps(oidc_step1_params))
|
||||
oidc_step1 = s.get(
|
||||
authorization_endpoint + '?' + urllib.parse.urlencode(oidc_step1_params),
|
||||
headers={'Referer': self.LOGIN_PAGE_URL, **base_headers()},
|
||||
allow_redirects=True
|
||||
)
|
||||
|
||||
|
||||
if oidc_step1.status_code // 100 != 2:
|
||||
raise ApiException(oidc_step1.status_code, 'Got unexpected status code for oidc step1: %s' % oidc_step1.text)
|
||||
|
||||
oidc_step1_loc = oidc_step1.url
|
||||
oidc_step1_query = urllib.parse.parse_qs(urllib.parse.urlparse(oidc_step1_loc).query)
|
||||
if 'code' not in oidc_step1_query:
|
||||
raise ApiException(oidc_step1.status_code, 'No code for oidc step1 ReturnUrl (%s): %s' % (oidc_step1_loc, json.dumps(oidc_step1_query)))
|
||||
|
||||
oidc_step1_callback_code = oidc_step1_query['code'][0]
|
||||
|
||||
oidc_step2_token_data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': client_id,
|
||||
'code': oidc_step1_callback_code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'code_verifier': code_verifier,
|
||||
}
|
||||
|
||||
logger.debug("4. calling oidc_step2 with %s" % json.dumps(oidc_step2_token_data))
|
||||
|
||||
oidc_step2 = s.post(token_endpoint, data=oidc_step2_token_data, headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
**base_headers()
|
||||
})
|
||||
|
||||
if oidc_step2.status_code//100 != 2:
|
||||
raise ApiException(oidc_step1.status_code, 'Got unexpected status code for oidc step2: %s' % oidc_step1.text)
|
||||
|
||||
oidc_json = oidc_step2.json()
|
||||
logger.debug("5. parsing oidc_step2 json response: %s" % json.dumps(oidc_json))
|
||||
|
||||
if not 'access_token' in oidc_json:
|
||||
raise ApiException(oidc_step1.status_code, 'Missing access_token in oidc_step2 json: %s' % json.dumps(oidc_json))
|
||||
|
||||
if not 'id_token' in oidc_json:
|
||||
raise ApiException(oidc_step1.status_code, 'Missing id_token in oidc_step2 json: %s' % json.dumps(oidc_json))
|
||||
|
||||
self.loginSession = s
|
||||
self.idToken = oidc_json['id_token']
|
||||
self.extract_jwt()
|
||||
|
||||
|
||||
self.accessToken = oidc_json['access_token']
|
||||
self.accessTokenExpiresAt = arrow.get(arrow.get().int_timestamp + oidc_json['expires_in'])
|
||||
|
||||
self.cache_creds(email)
|
||||
|
||||
return True
|
||||
|
||||
def extract_jwt(self):
|
||||
logger.debug("6. extracting JWT from %s" % self.idToken)
|
||||
id_token = self.idToken
|
||||
|
||||
jwks_response = self.loginSession.get(self.TDC_OIDC_JWKS_URL)
|
||||
jwks = jwks_response.json()
|
||||
public_keys = {}
|
||||
for jwk in jwks['keys']:
|
||||
kid = jwk['kid']
|
||||
public_keys[kid] = RSAAlgorithm.from_jwk(json.dumps(jwk))
|
||||
|
||||
# Get the key ID (kid) from the headers of the ID Token
|
||||
unverified_header = jwt.get_unverified_header(id_token)
|
||||
kid = unverified_header['kid']
|
||||
|
||||
key = public_keys.get(kid)
|
||||
if not key:
|
||||
raise ApiException(0, 'Public key not found for JWT: %s' % kid)
|
||||
|
||||
audience = self.TDC_OIDC_CLIENT_ID
|
||||
issuer = self.TDC_OIDC_ISSUER
|
||||
|
||||
# Decode and verify the ID Token
|
||||
id_token_claims = jwt.decode(
|
||||
id_token,
|
||||
key=key,
|
||||
algorithms=['RS256'],
|
||||
audience=audience,
|
||||
issuer=issuer,
|
||||
)
|
||||
|
||||
logger.info("Decoded JWT: %s" % json.dumps(id_token_claims))
|
||||
|
||||
self.jwtData = id_token_claims
|
||||
self.pumperId = id_token_claims['pumperId']
|
||||
self.accountId = id_token_claims['accountId']
|
||||
|
||||
def try_load_cached_creds(self, email):
|
||||
if not CACHE_CREDENTIALS:
|
||||
return False
|
||||
|
||||
if not os.path.exists(CACHE_CREDENTIALS_PATH):
|
||||
logger.info("No cached credentials exist")
|
||||
return False
|
||||
|
||||
_saved_blob = {}
|
||||
try:
|
||||
with open(CACHE_CREDENTIALS_PATH, 'rb') as f:
|
||||
_saved_blob = pickle.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load cached credentials at {CACHE_CREDENTIALS_PATH}: {e}")
|
||||
return False
|
||||
|
||||
if not _saved_blob:
|
||||
logger.warning(f"Could not load cached credentials at {CACHE_CREDENTIALS_PATH}: empty dict")
|
||||
return False
|
||||
|
||||
if _saved_blob.get('cache_creds_version') != 1.0:
|
||||
logger.warning(f"Unexpected cache_creds_version at {CACHE_CREDENTIALS_PATH}: {_saved_blob['cache_creds_version']}, expected 1.0")
|
||||
return False
|
||||
|
||||
if _saved_blob.get('cache_creds_email') != email:
|
||||
logger.warning(f"Cached credentials are for a different email ({_saved_blob['cache_creds_email']} in cache, but using {email}), skipping")
|
||||
return False
|
||||
|
||||
# Check if cached region matches current region
|
||||
cached_region = _saved_blob.get('cache_creds_region', 'US') # Default to US for backward compatibility
|
||||
if cached_region != self.region:
|
||||
logger.warning(f"Cached credentials are for a different region ({cached_region} in cache, but using {self.region}), skipping")
|
||||
return False
|
||||
|
||||
at_expiry = _saved_blob['accessTokenExpiresAt']
|
||||
if arrow.get().int_timestamp >= arrow.get(at_expiry).int_timestamp:
|
||||
logger.info(f"Cached credentials have expired ({_saved_blob['accessTokenExpiresAt']}), skipping")
|
||||
return False
|
||||
|
||||
self.jwtData = _saved_blob['jwtData']
|
||||
self.pumperId = _saved_blob['pumperId']
|
||||
self.accountId = _saved_blob['accountId']
|
||||
self.idToken = _saved_blob['idToken']
|
||||
self.accessToken = _saved_blob['accessToken']
|
||||
self.accessTokenExpiresAt = _saved_blob['accessTokenExpiresAt']
|
||||
self.loginSession = _saved_blob['loginSession']
|
||||
|
||||
def est_time(t):
|
||||
now = arrow.get()
|
||||
if now < t:
|
||||
sec = (t - now).seconds
|
||||
else:
|
||||
sec = (now - t).seconds
|
||||
min = sec//60
|
||||
hr = min//60
|
||||
min = min % 60
|
||||
sec = sec % 60
|
||||
r = ''
|
||||
if hr:
|
||||
r += f'{hr} hr '
|
||||
if min:
|
||||
r += f'{min} min '
|
||||
if sec:
|
||||
r += f'{sec} sec '
|
||||
if not r:
|
||||
return 'now'
|
||||
elif now < t:
|
||||
return 'in '+r.strip()
|
||||
else:
|
||||
return r.strip()+' ago'
|
||||
|
||||
|
||||
sa = _saved_blob['cache_creds_saved_at']
|
||||
ex = _saved_blob['accessTokenExpiresAt']
|
||||
logger.info(f"Loaded cached credentials from {CACHE_CREDENTIALS_PATH}: saved at {sa} ({est_time(sa)}), access token expiry {ex} ({est_time(ex)})")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def cache_creds(self, email):
|
||||
if not CACHE_CREDENTIALS:
|
||||
logger.info("Credentials caching is disabled, skipping save")
|
||||
return
|
||||
|
||||
_saved_blob = {
|
||||
'cache_creds_version': 1.0,
|
||||
'cache_creds_saved_at': arrow.get(),
|
||||
'cache_creds_email': email,
|
||||
'cache_creds_region': self.region, # Store the region in cache
|
||||
'jwtData': self.jwtData,
|
||||
'pumperId': self.pumperId,
|
||||
'accountId': self.accountId,
|
||||
'idToken': self.idToken,
|
||||
'accessToken': self.accessToken,
|
||||
'accessTokenExpiresAt': self.accessTokenExpiresAt,
|
||||
'loginSession': self.loginSession
|
||||
}
|
||||
|
||||
if not os.path.exists(CACHE_CREDENTIALS_PATH):
|
||||
mkdir = os.path.dirname(CACHE_CREDENTIALS_PATH)
|
||||
logger.debug(f"Running mkdir on {mkdir}")
|
||||
os.makedirs(mkdir, exist_ok=True)
|
||||
|
||||
with open(CACHE_CREDENTIALS_PATH, 'wb') as f:
|
||||
pickle.dump(_saved_blob, f)
|
||||
logger.info(f"Saved cached credentials to {CACHE_CREDENTIALS_PATH}")
|
||||
|
||||
|
||||
def needs_relogin(self):
|
||||
if not self.accessTokenExpiresAt:
|
||||
return False
|
||||
|
||||
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,
|
||||
'Origin': 'https://tconnect.tandemdiabetes.com',
|
||||
'Referer': 'https://tconnect.tandemdiabetes.com/',
|
||||
**base_headers()
|
||||
}
|
||||
|
||||
def _get(self, endpoint, query):
|
||||
r = base_session().get(self.SOURCE_URL + endpoint, data=query, headers=self.api_headers())
|
||||
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "TandemSourceApi 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 TandemSourceApi with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
|
||||
if tries > 0:
|
||||
raise ApiException(e.status_code, "TandemSourceApi 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 TandemSourceApi")
|
||||
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 information about the user and available pumps.
|
||||
"""
|
||||
def pumper_info(self):
|
||||
return self.get('api/pumpers/pumpers/%s' % (self.pumperId), {})
|
||||
|
||||
"""
|
||||
Returns metadata for pump events. Returns a list of dict's per-pump on the account.
|
||||
[
|
||||
{'tconnectDeviceId', 'serialNumber', 'modelNumber', 'minDateWithEvents', 'maxDateWithEvents', 'lastUpload', 'patientName', 'patientDateOfBirth', 'patientCareGiver', 'softwareVersion', 'partNumber'},
|
||||
]
|
||||
"""
|
||||
def pump_event_metadata(self):
|
||||
return self.get('api/reports/reportsfacade/%s/pumpeventmetadata' % (self.pumperId), {})
|
||||
|
||||
DEFAULT_EVENT_IDS = [229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,399,256,213,406,394,212,404,214,405,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191]
|
||||
|
||||
"""
|
||||
Returns raw unparsed string for pump events
|
||||
tconnect_device_id is "tconnectDeviceId" from pump_event_metadata()
|
||||
"""
|
||||
def pump_events_raw(self, tconnect_device_id, min_date=None, max_date=None, event_ids_filter=DEFAULT_EVENT_IDS):
|
||||
minDate = parse_ymd_date(min_date)
|
||||
maxDate = parse_ymd_date(max_date)
|
||||
logger.debug(f'pump_events_raw({tconnect_device_id}, {minDate}, {maxDate})')
|
||||
|
||||
# default: 229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,399,256,213,406,394,212,404,214,405,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191
|
||||
eventIdsFilter = '%2C'.join(map(str, event_ids_filter)) if event_ids_filter else None
|
||||
return self.get('api/reports/reportsfacade/pumpevents/%s/%s?minDate=%s&maxDate=%s%s' % (
|
||||
self.pumperId,
|
||||
tconnect_device_id,
|
||||
minDate,
|
||||
maxDate,
|
||||
'&eventIds=%s' % eventIdsFilter if eventIdsFilter else ''
|
||||
), {})
|
||||
|
||||
"""
|
||||
Fetch and decode pump events using eventparser.
|
||||
Default of fetch_all_events=False will filter to the same eventids used in the Tandem Source backend.
|
||||
If fetch_all_events=True, then all event types from the history log will be returned.
|
||||
"""
|
||||
def pump_events(self, tconnect_device_id, min_date=None, max_date=None, fetch_all_event_types=False):
|
||||
pump_events_raw = self.pump_events_raw(
|
||||
tconnect_device_id,
|
||||
min_date,
|
||||
max_date,
|
||||
event_ids_filter=None if fetch_all_event_types else self.DEFAULT_EVENT_IDS
|
||||
)
|
||||
|
||||
pump_events_decoded = decode_raw_events(pump_events_raw)
|
||||
logger.info(f"Read {len(pump_events_decoded)} bytes (est. {len(pump_events_decoded)/EVENT_LEN} events)")
|
||||
return Events(pump_events_decoded)
|
||||
|
||||
|
||||
@@ -30,10 +30,14 @@ class WebUIScraper:
|
||||
return self.controliq.needs_relogin()
|
||||
|
||||
def _get(self, endpoint):
|
||||
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers())
|
||||
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers(), allow_redirects=True)
|
||||
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "WebUIScraper HTTP %s response: %s" % (str(r.status_code), r.text))
|
||||
|
||||
if 'login.aspx' in r.url:
|
||||
raise ApiException(401, "WebUIScraper HTTP %s response for login page, returning 401: %s" % (str(r.status_code), r.url))
|
||||
|
||||
return r
|
||||
|
||||
|
||||
|
||||
+67
-98
@@ -4,13 +4,15 @@ import arrow
|
||||
import logging
|
||||
import traceback
|
||||
import pkg_resources
|
||||
import collections
|
||||
from datetime import datetime
|
||||
from pprint import pformat as pformat_base
|
||||
|
||||
from .nightscout import NightscoutApi
|
||||
from .parser.nightscout import BASAL_EVENTTYPE, BOLUS_EVENTTYPE
|
||||
from .parser.tconnect import TConnectEntry
|
||||
from .sync.basal import process_ciq_basal_events
|
||||
from .domain.tandemsource.event_class import EventClass
|
||||
from .sync.tandemsource.choose_device import ChooseDevice
|
||||
|
||||
try:
|
||||
__version__ = pkg_resources.require("tconnectsync")[0].version
|
||||
@@ -51,27 +53,29 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
|
||||
log("Loading secrets...")
|
||||
try:
|
||||
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
|
||||
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
|
||||
from . import secret
|
||||
except ImportError as e:
|
||||
log("Error: Unable to load config file. Please check your .env file or environment variables")
|
||||
log_err(e)
|
||||
|
||||
|
||||
log(f"Using {TCONNECT_REGION=}")
|
||||
|
||||
if not TCONNECT_EMAIL or TCONNECT_EMAIL == 'email@email.com':
|
||||
log("Error: You have not specified a TCONNECT_EMAIL")
|
||||
errors += 1
|
||||
|
||||
|
||||
if not TCONNECT_PASSWORD or TCONNECT_PASSWORD == 'password':
|
||||
log("Error: You have not specified a TCONNECT_PASSWORD")
|
||||
errors += 1
|
||||
|
||||
|
||||
if not PUMP_SERIAL_NUMBER or PUMP_SERIAL_NUMBER == '11111111':
|
||||
log("Error: You have not specified a PUMP_SERIAL_NUMBER")
|
||||
errors += 1
|
||||
|
||||
log("Warning: You have not specified a PUMP_SERIAL_NUMBER, so the pump with most recent activity will be automatically used.")
|
||||
|
||||
if not NS_URL or NS_URL == 'https://yournightscouturl/':
|
||||
log("Error: You have not specified a NS_URL")
|
||||
errors += 1
|
||||
|
||||
|
||||
if not NS_SECRET or NS_SECRET == 'apisecret':
|
||||
log("Error: You have not specified a NS_SECRET")
|
||||
errors += 1
|
||||
@@ -80,89 +84,62 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
|
||||
log("-----")
|
||||
|
||||
log("Logging in to t:connect ControlIQ API...")
|
||||
serialNumberToPump = None
|
||||
try:
|
||||
summary = tconnect.controliq.dashboard_summary(time_start, time_end)
|
||||
debug("ControlIQ dashboard summary: \n%s" % pformat(summary))
|
||||
log("tconnect_software_ver: %s" % tconnect.controliq.tconnect_software_ver)
|
||||
except Exception as e:
|
||||
log("Error occurred querying ControlIQ API for dashboard_summary:")
|
||||
log_err(e)
|
||||
if e and "HTTP 404" in str(e):
|
||||
log("<!> The API returns a 404 error if there is no data for the provided dates (currently %s - %s). Try re-running with an earlier start date using --start-date and --end-date arguments." % (time_start, time_end))
|
||||
errors += 1
|
||||
|
||||
log("Querying ControlIQ therapy_timeline...")
|
||||
lastBasalTime = None
|
||||
lastBasalDuration = None
|
||||
try:
|
||||
tt = tconnect.controliq.therapy_timeline(time_start, time_end)
|
||||
debug("ControlIQ therapy_timeline: \n%s" % pformat(tt))
|
||||
if tt:
|
||||
processed_tt = process_ciq_basal_events(tt)
|
||||
debug("ControlIQ processed therapy_timeline: \n%s" % pformat(processed_tt))
|
||||
if processed_tt:
|
||||
log("Last ControlIQ processed therapy_timeline event: \n%s" % pformat(processed_tt[-1]))
|
||||
lastBasalTime = processed_tt[-1]['time']
|
||||
lastBasalDuration = processed_tt[-1]['duration_mins']
|
||||
except Exception as e:
|
||||
log("Error occurred querying ControlIQ therapy_timeline:")
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
log("Querying ControlIQ therapy_events...")
|
||||
try:
|
||||
androidevents = tconnect.controliq.therapy_events(time_start, time_end)
|
||||
debug("controliq therapy_events: \n%s" % pformat(androidevents))
|
||||
except Exception as e:
|
||||
log("Error occurred querying ControlIQ therapy_events:")
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
log("Fetching pump metadata...")
|
||||
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
|
||||
|
||||
log("Initializing t:connect WS2 API...")
|
||||
ws2_loggedin = False
|
||||
try:
|
||||
summary = tconnect.ws2.basaliqtech(time_start, time_end)
|
||||
debug("WS2 basaliq status: \n%s" % pformat(summary))
|
||||
ws2_loggedin = True
|
||||
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
|
||||
log(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
|
||||
for pumpSerial, pumpDetails in serialNumberToPump.items():
|
||||
log(f'Pump {pumpSerial=}: {pumpDetails=}')
|
||||
|
||||
log("Running ChooseDevice...")
|
||||
tconnectDevice = ChooseDevice(secret, tconnect).choose()
|
||||
|
||||
log(f'ChooseDevice selected: {tconnectDevice}')
|
||||
|
||||
tconnectDeviceId = tconnectDevice['tconnectDeviceId']
|
||||
|
||||
log(f'Fetching pump events for {tconnectDeviceId=} {time_start=} {time_end=} fetch_all_event_types=False')
|
||||
|
||||
events = tconnect.tandemsource.pump_events(tconnectDeviceId, time_start, time_end, fetch_all_event_types=False)
|
||||
events = list(events)
|
||||
|
||||
log(f"Found raw events count: {len(events)}")
|
||||
|
||||
|
||||
events_first_time = None
|
||||
events_last_time = None
|
||||
last_event_seqnum = None
|
||||
for_eventclass = collections.defaultdict(list)
|
||||
for event in events:
|
||||
if not events_first_time:
|
||||
events_first_time = event.eventTimestamp
|
||||
if not events_last_time:
|
||||
events_last_time = event.eventTimestamp
|
||||
if not last_event_seqnum:
|
||||
last_event_seqnum = event.seqNum
|
||||
events_first_time = min(events_first_time, event.eventTimestamp)
|
||||
events_last_time = max(events_last_time, event.eventTimestamp)
|
||||
last_event_seqnum = max(event.seqNum, last_event_seqnum)
|
||||
|
||||
clazz = EventClass.for_event(event)
|
||||
if clazz:
|
||||
for_eventclass[clazz.name].append(event)
|
||||
|
||||
count_by_eventclass = {k: len(v) for k,v in for_eventclass.items()}
|
||||
log(f"Found events count: {count_by_eventclass}")
|
||||
|
||||
log(f"Found first event time: {events_first_time}")
|
||||
log(f"Found last event time: {events_last_time}")
|
||||
log(f"Found last event sequence number: {last_event_seqnum}")
|
||||
except Exception as e:
|
||||
log("Error occurred querying WS2 API. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
|
||||
log("Error occurred querying Tandem Source:")
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
lastReadingTime = None
|
||||
if ws2_loggedin:
|
||||
log("Querying WS2 therapy_timeline_csv...")
|
||||
try:
|
||||
ttcsv = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
|
||||
debug("therapy_timeline_csv: \n%s" % pformat(ttcsv))
|
||||
if ttcsv and "readingData" in ttcsv and len(ttcsv["readingData"]) > 0:
|
||||
log("Last therapy_timeline_csv reading: \n%s" % pformat(ttcsv["readingData"][-1]))
|
||||
lastReadingTime = TConnectEntry._datetime_parse(ttcsv["readingData"][-1]['EventDateTime'])
|
||||
except Exception as e:
|
||||
log("Error occurred querying WS2 therapy_timeline_csv. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
|
||||
log_err(e)
|
||||
errors += 1
|
||||
else:
|
||||
log("Not able to log in to WS2 API, so skipping therapy_timeline_csv")
|
||||
|
||||
log("-----")
|
||||
|
||||
log("Logging in to t:connect Android API...")
|
||||
summary = None
|
||||
try:
|
||||
summary = tconnect.android.user_profile()
|
||||
debug("Android user profile: \n%s" % pformat(summary))
|
||||
|
||||
event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
|
||||
debug("Android last uploaded event: \n%s" % pformat(event))
|
||||
except Exception as e:
|
||||
log("Error occurred querying Android API:")
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
|
||||
log("Logging in to Nightscout...")
|
||||
@@ -186,17 +163,13 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
def time_ago(t):
|
||||
return '%s ago' % (arrow.now() - arrow.get(t)) if t else 'n/a'
|
||||
|
||||
log("Last basal start time: %s (%s)" % (lastBasalTime, time_ago(lastBasalTime)))
|
||||
log("Last basal duration: %s" % lastBasalDuration)
|
||||
log("Last reading time: %s (%s)" % (lastReadingTime, time_ago(lastReadingTime)))
|
||||
|
||||
log("-----")
|
||||
|
||||
if errors == 0:
|
||||
log("No API errors returned!")
|
||||
else:
|
||||
log("API errors occurred. Please check the errors above.")
|
||||
|
||||
|
||||
|
||||
with open('tconnectsync-check-output.log', 'w') as f:
|
||||
|
||||
@@ -208,14 +181,10 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
'NS_URL': NS_URL,
|
||||
'NS_SECRET': NS_SECRET
|
||||
}
|
||||
|
||||
if summary:
|
||||
sanitizedData.update({
|
||||
'ANDROID_PROFILE_USERID': summary.get('userID'),
|
||||
'ANDROID_PROFILE_PATIENT_FULLNAME': summary.get('patientFullName'),
|
||||
'ANDROID_PROFILE_CAREGIVER_FULLNAME': summary.get('caregiverFullName')
|
||||
})
|
||||
|
||||
if serialNumberToPump:
|
||||
for i, (pumpSerial, pumpDetails) in enumerate(serialNumberToPump.items()):
|
||||
sanitizedData[f'PUMP_SERIAL_{i}'] = pumpSerial
|
||||
sanitizedData[f'TCONNECT_DEVICE_ID_{i}'] = pumpDetails['tconnectDeviceId']
|
||||
loglines = [run_sanitize(i, sanitizedData) for i in loglines]
|
||||
|
||||
f.writelines(loglines)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from enum import Enum
|
||||
|
||||
from ...eventparser import events
|
||||
|
||||
class EventClass(set, Enum):
|
||||
# LidBasalDelivery = every 5min entry
|
||||
# LidBasalRateChange = only when basal rate changes
|
||||
BASAL = {events.LidBasalDelivery} # , LidBasalRateChange
|
||||
BASAL_SUSPENSION = {events.LidPumpingSuspended}
|
||||
BASAL_RESUME = {events.LidPumpingResumed}
|
||||
ALARM = {events.LidAlarmActivated, events.LidMalfunctionActivated}
|
||||
BOLUS = {
|
||||
events.LidBolusRequestedMsg1, # carb amount, bg, iob
|
||||
events.LidBolusRequestedMsg2, # more robust bolus type
|
||||
events.LidBolusRequestedMsg3, # total bolus requested amount
|
||||
events.LidBolusCompleted, # final event showing amount delivered
|
||||
events.LidBolexCompleted # extended bolus
|
||||
}
|
||||
CARTRIDGE = {events.LidCartridgeFilled, events.LidCannulaFilled, events.LidTubingFilled}
|
||||
CGM_ALERT = {events.LidCgmAlertActivated, events.LidCgmAlertActivatedDex, events.LidCgmAlertActivatedFsl2}
|
||||
_CGM_START = {events.LidCgmStartSessionGx, events.LidCgmStartSessionFsl2}
|
||||
_CGM_JOIN = {events.LidCgmJoinSessionGx, events.LidCgmJoinSessionG7, events.LidCgmJoinSessionFsl2}
|
||||
_CGM_STOP = {events.LidCgmStopSessionGx, events.LidCgmStopSessionG7, events.LidCgmStopSessionFsl2}
|
||||
CGM_START_JOIN_STOP = {*_CGM_START, *_CGM_JOIN, *_CGM_STOP}
|
||||
CGM_READING = {events.LidCgmDataGxb, events.LidCgmDataG7, events.LidCgmDataFsl2}
|
||||
USER_MODE = {events.LidAaUserModeChange}
|
||||
DEVICE_STATUS = {events.LidDailyBasal}
|
||||
|
||||
@staticmethod
|
||||
def for_event(evt):
|
||||
for typ, vals in EventClass.__members__.items():
|
||||
if typ.startswith('_'):
|
||||
continue
|
||||
if type(evt) == type and evt in vals:
|
||||
return EventClass.__members__[typ]
|
||||
elif type(evt) in vals:
|
||||
return EventClass.__members__[typ]
|
||||
return None
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses_json import dataclass_json
|
||||
from typing import List
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class PumpProfileSegment:
|
||||
startTime: int # minutes
|
||||
basalRate: int # milliunits
|
||||
isf: int
|
||||
carbRatio: int
|
||||
targetBg: int
|
||||
|
||||
@property
|
||||
def skip(self):
|
||||
return self.startTime == 0 and self.basalRate == 0 and self.isf == 0 and self.carbRatio == 0 and self.targetBg == 0
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class PumpProfile:
|
||||
name: str
|
||||
idp: int
|
||||
tDependentSegs: List[PumpProfileSegment]
|
||||
insulinDuration: int # minutes
|
||||
carbEntry: int # 1 / 0
|
||||
maxBolus: int # milliunits
|
||||
|
||||
def __post_init__(self):
|
||||
self.tDependentSegs = [i for i in self.tDependentSegs if not i.skip]
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class PumpProfiles:
|
||||
activeIdp: int
|
||||
profile: List[PumpProfile]
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class PumpGlucoseAlertSettings:
|
||||
mgPerDl: int
|
||||
enabled: int # 1 / 0
|
||||
duration: int # minutes
|
||||
status: int # unknown
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class PumpCgmSettings:
|
||||
highGlucoseAlert: PumpGlucoseAlertSettings
|
||||
lowGlucoseAlert: PumpGlucoseAlertSettings
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class PumpSettings:
|
||||
profiles: PumpProfiles
|
||||
cgmSettings: PumpCgmSettings
|
||||
@@ -19,6 +19,9 @@ class TherapyEvent:
|
||||
self.eventDateTime = json['eventDateTime']
|
||||
self.sourceRecId = json['sourceRecId']
|
||||
self.rawJson = json
|
||||
|
||||
def __str__(self):
|
||||
return "%s(%s)" % (self.type, self.rawJson)
|
||||
|
||||
class CGMTherapyEvent(TherapyEvent):
|
||||
eventID = None
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
header = '''# THIS FILE IS AUTOGENERATED. DO NOT EDIT.
|
||||
import struct
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, IntFlag
|
||||
from .raw_event import RawEvent, BaseEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EVENT_LEN = 26
|
||||
|
||||
'''
|
||||
|
||||
TYPE_TO_STRUCT = {
|
||||
'uint8': '>B',
|
||||
'int8': '>b',
|
||||
'uint16': '>H',
|
||||
'int16': '>h',
|
||||
'uint32': '>I',
|
||||
'float32': '>f',
|
||||
}
|
||||
|
||||
for k, v in TYPE_TO_STRUCT.items():
|
||||
header += f"{k.upper()} = '{v}'\n"
|
||||
|
||||
TYPE_TO_PYOBJ = {
|
||||
'uint8': 'int',
|
||||
'int8': 'int',
|
||||
'uint16': 'int',
|
||||
'int16': 'int',
|
||||
'uint32': 'int',
|
||||
'float32': 'float',
|
||||
}
|
||||
|
||||
HEADER_SIZE = 10
|
||||
def unpack_command_for(field_def):
|
||||
return f'struct.unpack_from({field_def["type"].upper()}, raw[:EVENT_LEN], {HEADER_SIZE + field_def["offset"]})'
|
||||
|
||||
TEMPLATE = '''
|
||||
@dataclass
|
||||
class {name}(BaseEvent):
|
||||
"""{id}: {raw_name}"""
|
||||
ID = {id}
|
||||
NAME = "{raw_name}"
|
||||
|
||||
raw: RawEvent
|
||||
{fields}
|
||||
|
||||
{transform_funcs}
|
||||
@staticmethod
|
||||
def build(raw):
|
||||
{build_p1}
|
||||
|
||||
return {name}(
|
||||
raw = RawEvent.build(raw),
|
||||
{build_p2}
|
||||
)
|
||||
|
||||
@property
|
||||
def eventTimestamp(self):
|
||||
return self.raw.timestamp
|
||||
|
||||
@property
|
||||
def seqNum(self):
|
||||
return self.raw.seqNum
|
||||
|
||||
@property
|
||||
def eventId(self):
|
||||
return self.ID
|
||||
|
||||
def todict(self):
|
||||
return dict(
|
||||
id=self.ID,
|
||||
name=self.NAME,
|
||||
seqNum=self.seqNum,
|
||||
eventTimestamp=str(self.eventTimestamp),
|
||||
{fields_dict}
|
||||
)
|
||||
|
||||
'''
|
||||
|
||||
def firstLower(text):
|
||||
if not text:
|
||||
return text
|
||||
return f'{text[0].lower()}{text[1:]}'
|
||||
|
||||
def eventNameFormat(text):
|
||||
if not text:
|
||||
return text
|
||||
return text.replace('_', ' ').title().replace(' ', '')
|
||||
|
||||
def fieldNameFormat(text):
|
||||
if not text or all([i.isupper() for i in text]):
|
||||
return text
|
||||
return firstLower(text.replace('_', ' ').title().replace(' ', '')).replace('raw', 'Raw')
|
||||
|
||||
|
||||
def build_fields(event_def):
|
||||
ret = []
|
||||
for name, field in event_def["data"].items():
|
||||
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
|
||||
f = f'{fieldNameFormat(name)}{suffix}: {TYPE_TO_PYOBJ[field["type"]]}'
|
||||
if "uom" in field:
|
||||
f += ' # ' + field['uom']
|
||||
ret.append(f)
|
||||
return '\n'.join([f'{" "*4}{f}' for f in ret])
|
||||
|
||||
|
||||
def build_fields_dict(event_def):
|
||||
ret = []
|
||||
for name, field in event_def["data"].items():
|
||||
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
|
||||
f = f'{fieldNameFormat(name)}{suffix}=self.{fieldNameFormat(name)}{suffix},'
|
||||
ret.append(f)
|
||||
return '\n'.join([f'{" "*12}{f}' for f in ret])
|
||||
|
||||
|
||||
def build_decode(event_def):
|
||||
p1s = []
|
||||
p2s = []
|
||||
for name, field in event_def["data"].items():
|
||||
p1 = f'{fieldNameFormat(name)}, = {unpack_command_for(field)}'
|
||||
p1s.append(p1)
|
||||
|
||||
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
|
||||
|
||||
p2 = f'{fieldNameFormat(name)}{suffix} = {fieldNameFormat(name)},'
|
||||
p2s.append(p2)
|
||||
|
||||
|
||||
return '\n'.join([f'{" "*8}{f}' for f in p1s]), '\n'.join([f'{" "*12}{f}' for f in p2s])
|
||||
|
||||
def build_transform_funcs(event_def):
|
||||
try:
|
||||
from transforms import TRANSFORMS
|
||||
except ImportError:
|
||||
from .transforms import TRANSFORMS
|
||||
|
||||
ret = []
|
||||
|
||||
for name, field in event_def["data"].items():
|
||||
if not "transform" in field:
|
||||
continue
|
||||
|
||||
for tx in field["transform"]:
|
||||
ret += TRANSFORMS[tx[0]](event_def, name, fieldNameFormat(name), field, tx[1])
|
||||
|
||||
return '\n'.join([f'{" "*4}{f}' if f else '' for f in ret])
|
||||
|
||||
def build_event(event_id, event_def):
|
||||
return TEMPLATE.format(
|
||||
name = eventNameFormat(event_def["name"]),
|
||||
fields = build_fields(event_def),
|
||||
fields_dict = build_fields_dict(event_def),
|
||||
build_p1 = build_decode(event_def)[0],
|
||||
build_p2 = build_decode(event_def)[1],
|
||||
transform_funcs = build_transform_funcs(event_def),
|
||||
id = event_id,
|
||||
raw_name = event_def["name"]
|
||||
)
|
||||
|
||||
def build_events_map(events):
|
||||
ret = ['EVENT_IDS = {']
|
||||
for event_id, event_def in events.items():
|
||||
ret += [f'{" "*4}{event_id}: {eventNameFormat(event_def["name"])},']
|
||||
|
||||
ret += ['}', '']
|
||||
|
||||
ret += ['EVENT_NAMES = {']
|
||||
for event_id, event_def in events.items():
|
||||
ret += [f'{" "*4}"{event_def["name"]}": {eventNameFormat(event_def["name"])},']
|
||||
|
||||
ret += ['}', '']
|
||||
|
||||
return '\n'.join(ret)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
merged_events = {}
|
||||
|
||||
output = f'{header}'
|
||||
with open("events.json", "r") as f:
|
||||
j = json.loads(f.read())
|
||||
merged_events.update(j["events"])
|
||||
|
||||
with open("custom_events.json", "r") as f:
|
||||
j = json.loads(f.read())
|
||||
merged_events.update(j["events"])
|
||||
|
||||
for event_id, event_def in merged_events.items():
|
||||
output += build_event(event_id, event_def)
|
||||
|
||||
output += build_events_map(merged_events)
|
||||
|
||||
|
||||
print(output)
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"events": {
|
||||
"81": {
|
||||
"name": "LID_DAILY_BASAL",
|
||||
"data": {
|
||||
"dailyTotalBasal": {
|
||||
"type": "float32",
|
||||
"offset": 0,
|
||||
"uom": "units"
|
||||
},
|
||||
"lastBasalRate": {
|
||||
"type": "float32",
|
||||
"offset": 4,
|
||||
"uom": "units/hour"
|
||||
},
|
||||
"iob": {
|
||||
"type": "float32",
|
||||
"offset": 8,
|
||||
"uom": "units"
|
||||
},
|
||||
"batteryChargePercentMSBRaw": {
|
||||
"type": "uint8",
|
||||
"offset": 12
|
||||
},
|
||||
"batteryChargePercentLSBRaw": {
|
||||
"type": "uint8",
|
||||
"offset": 13,
|
||||
"transform": [["battery_charge_percent", ""]]
|
||||
},
|
||||
"batteryLipoMilliVolts": {
|
||||
"type": "uint16",
|
||||
"offset": 14
|
||||
}
|
||||
}
|
||||
},
|
||||
"48": {
|
||||
"name": "LID_CARBS_ENTERED",
|
||||
"data": {
|
||||
"carbs": {
|
||||
"type": "float32",
|
||||
"offset": 0,
|
||||
"uom": "carbs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"36": {
|
||||
"name": "LID_USB_CONNECTED",
|
||||
"data": {
|
||||
"negotiatedCurrent": {
|
||||
"type": "float32",
|
||||
"offset": 0,
|
||||
"uom": "mA"
|
||||
}
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"name": "LID_USB_DISCONNECTED",
|
||||
"data": {
|
||||
"negotiatedCurrent": {
|
||||
"type": "float32",
|
||||
"offset": 0,
|
||||
"uom": "mA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
import struct
|
||||
import base64
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .raw_event import RawEvent, EVENT_LEN
|
||||
from .events import EVENT_IDS
|
||||
from .utils import batched
|
||||
|
||||
|
||||
def Event(x):
|
||||
raw_event = RawEvent.build(x)
|
||||
if not raw_event.id in EVENT_IDS:
|
||||
return raw_event
|
||||
|
||||
return EVENT_IDS[raw_event.id].build(x)
|
||||
|
||||
Events = lambda x: (Event(bytearray(e)) for e in batched(x, EVENT_LEN))
|
||||
|
||||
def decode_raw_events(raw):
|
||||
return base64.b64decode(raw)
|
||||
@@ -0,0 +1,73 @@
|
||||
import struct
|
||||
import arrow
|
||||
|
||||
from ..secret import TIMEZONE_NAME
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
EVENT_LEN = 26
|
||||
UINT16 = '>H'
|
||||
UINT32 = '>I'
|
||||
TANDEM_EPOCH = 1199145600
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseEvent:
|
||||
@staticmethod
|
||||
def build(raw):
|
||||
raise NotImplemented
|
||||
|
||||
@property
|
||||
def eventTimestamp(self):
|
||||
raise NotImplemented
|
||||
|
||||
@property
|
||||
def eventId(self):
|
||||
raise NotImplemented
|
||||
|
||||
@dataclass
|
||||
class RawEvent:
|
||||
source: int
|
||||
id: int
|
||||
timestampRaw: int
|
||||
seqNum: int
|
||||
raw: bytearray
|
||||
|
||||
@staticmethod
|
||||
def build(raw):
|
||||
source_and_id, = struct.unpack_from(UINT16, raw[:EVENT_LEN], 0)
|
||||
timestampRaw, = struct.unpack_from(UINT32, raw[:EVENT_LEN], 2)
|
||||
seqNum, = struct.unpack_from(UINT32, raw[:EVENT_LEN], 6)
|
||||
|
||||
return RawEvent(
|
||||
source = (source_and_id & 0xF000) >> 12,
|
||||
id = source_and_id & 0x0FFF,
|
||||
timestampRaw = timestampRaw,
|
||||
seqNum = seqNum,
|
||||
raw = raw
|
||||
)
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
# Event timestamps do not have TZ data attached to them when parsed,
|
||||
# but represent the user's time zone setting. So we keep the time
|
||||
# referenced on them, but force the timezone to what the user
|
||||
# requests via the TZ secret.
|
||||
return arrow.get(TANDEM_EPOCH + self.timestampRaw, tzinfo='UTC').replace(tzinfo=TIMEZONE_NAME)
|
||||
|
||||
@property
|
||||
def eventId(self):
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def eventTimestamp(self):
|
||||
return self.timestamp
|
||||
|
||||
def todict(self):
|
||||
return dict(
|
||||
id=self.id,
|
||||
name="RawEvent",
|
||||
seqNum=self.seqNum,
|
||||
eventTimestamp=str(self.eventTimestamp),
|
||||
raw=''.join('{:02x}'.format(x) for x in self.raw),
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
|
||||
ALERTS_DICT = {
|
||||
"0": "LOW_INSULIN_ALERT",
|
||||
"1": "USB_CONNECTION_ALERT",
|
||||
"2": "LOW_POWER_ALERT",
|
||||
"3": "LOW_POWER_ALERT2",
|
||||
"4": "DATA_ERROR_ALERT",
|
||||
"5": "AUTO_OFF_ALERT",
|
||||
"6": "MAX_BASAL_RATE_ALERT",
|
||||
"7": "POWER_SOURCE_ALERT",
|
||||
"8": "MIN_BASAL_ALERT",
|
||||
"9": "CONNECTION_ERROR_ALERT",
|
||||
"10": "CONNECTION_ERROR_ALERT2",
|
||||
"11": "INCOMPLETE_BOLUS_ALERT",
|
||||
"12": "INCOMPLETE_TEMP_RATE_ALERT",
|
||||
"13": "INCOMPLETE_CARTRIDGE_CHANGE_ALERT",
|
||||
"14": "INCOMPLETE_FILL_TUBING_ALERT",
|
||||
"15": "INCOMPLETE_FILL_CANNULA_ALERT",
|
||||
"16": "INCOMPLETE_SETTING_ALERT",
|
||||
"17": "LOW_INSULIN_ALERT2",
|
||||
"18": "MAX_BASAL_ALERT",
|
||||
"19": "LOW_TRANSMITTER_ALERT",
|
||||
"20": "TRANSMITTER_ALERT",
|
||||
"21": "DEFAULT_ALERT_21",
|
||||
"22": "SENSOR_EXPIRING_ALERT",
|
||||
"23": "PUMP_REBOOTING_ALERT",
|
||||
"24": "DEVICE_CONNECTION_ERROR",
|
||||
"25": "CGM_GRAPH_REMOVED",
|
||||
"26": "MIN_BASAL_ALERT2",
|
||||
"27": "INCOMPLETE_CALIBRATION",
|
||||
"28": "CALIBRATION_TIMEOUT",
|
||||
"29": "INVALID_TRANSMITTER_ID",
|
||||
"30": "DEFAULT_ALERT_30",
|
||||
"32": "DEFAULT_ALERT_32",
|
||||
"33": "BUTTON_ALERT",
|
||||
"34": "QUICK_BOLUS_ALERT",
|
||||
"35": "BASAL_IQ_ALERT",
|
||||
"36": "DEFAULT_ALERT_36",
|
||||
"37": "DEFAULT_ALERT_37",
|
||||
"38": "DEFAULT_ALERT_38",
|
||||
"39": "TRANSMITTER_END_OF_LIFE",
|
||||
"40": "CGM_ERROR",
|
||||
"41": "CGM_ERROR2",
|
||||
"42": "CGM_ERROR3",
|
||||
"43": "DEFAULT_ALERT_43",
|
||||
"44": "TRANSMITTER_EXPIRING_ALERT",
|
||||
"45": "TRANSMITTER_EXPIRING_ALERT2",
|
||||
"46": "TRANSMITTER_EXPIRING_ALERT3",
|
||||
"47": "DEFAULT_ALERT_47",
|
||||
"48": "CGM_UNAVAILABLE",
|
||||
"49": "DEFAULT_ALERT_49",
|
||||
"50": "DEFAULT_ALERT_50",
|
||||
"51": "DEFAULT_ALERT_51",
|
||||
"52": "DEFAULT_ALERT_52",
|
||||
"53": "DEFAULT_ALERT_53",
|
||||
"54": "DEVICE_PAIRED",
|
||||
"55": "DEFAULT_ALERT_55",
|
||||
"56": "DEFAULT_ALERT_56",
|
||||
"57": "DEFAULT_ALERT_57",
|
||||
"58": "DEFAULT_ALERT_58",
|
||||
"59": "DEFAULT_ALERT_59",
|
||||
"60": "DEFAULT_ALERT_60",
|
||||
"61": "DEFAULT_ALERT_61",
|
||||
"62": "DEFAULT_ALERT_62",
|
||||
"63": "DEFAULT_ALERT_63",
|
||||
}
|
||||
|
||||
ALARMS_DICT = {
|
||||
"0": "CARTRIDGE_ALARM",
|
||||
"1": "CARTRIDGE_ALARM2",
|
||||
"2": "OCCLUSION_ALARM",
|
||||
"3": "PUMP_RESET_ALARM",
|
||||
"4": "DEFAULT_ALARM_4",
|
||||
"5": "CARTRIDGE_ALARM3",
|
||||
"6": "CARTRIDGE_ALARM4",
|
||||
"7": "AUTO_OFF_ALARM",
|
||||
"8": "EMPTY_CARTRIDGE_ALARM",
|
||||
"9": "CARTRIDGE_ALARM5",
|
||||
"10": "TEMPERATURE_ALARM",
|
||||
"11": "TEMPERATURE_ALARM2",
|
||||
"12": "BATTERY_SHUTDOWN_ALARM",
|
||||
"13": "DEFAULT_ALARM_13",
|
||||
"14": "INVALID_DATE_ALARM",
|
||||
"15": "TEMPERATURE_ALARM3",
|
||||
"16": "CARTRIDGE_ALARM6",
|
||||
"17": "DEFAULT_ALARM_17",
|
||||
"18": "RESUME_PUMP_ALARM",
|
||||
"19": "DEFAULT_ALARM_19",
|
||||
"20": "CARTRIDGE_ALARM7",
|
||||
"21": "ALTITUDE_ALARM",
|
||||
"22": "STUCK_BUTTON_ALARM",
|
||||
"23": "RESUME_PUMP_ALARM2",
|
||||
"24": "ATMOSPHERIC_PRESSURE_OUT_OF_RANGE_ALARM",
|
||||
"25": "CARTRIDGE_REMOVED_ALARM",
|
||||
"26": "OCCLUSION_ALARM2",
|
||||
"27": "DEFAULT_ALARM_27",
|
||||
"28": "DEFAULT_ALARM_28",
|
||||
"29": "CARTRIDGE_ALARM10",
|
||||
"30": "CARTRIDGE_ALARM11",
|
||||
"31": "CARTRIDGE_ALARM12",
|
||||
"32": "DEFAULT_ALARM_32",
|
||||
"33": "DEFAULT_ALARM_33",
|
||||
"34": "DEFAULT_ALARM_34",
|
||||
"35": "DEFAULT_ALARM_35",
|
||||
"36": "DEFAULT_ALARM_36",
|
||||
"37": "DEFAULT_ALARM_37",
|
||||
"38": "DEFAULT_ALARM_38",
|
||||
"39": "DEFAULT_ALARM_39",
|
||||
"40": "DEFAULT_ALARM_40",
|
||||
"41": "DEFAULT_ALARM_41",
|
||||
"42": "DEFAULT_ALARM_42",
|
||||
"43": "DEFAULT_ALARM_43",
|
||||
"44": "DEFAULT_ALARM_44",
|
||||
"45": "DEFAULT_ALARM_45",
|
||||
"46": "DEFAULT_ALARM_46",
|
||||
"47": "DEFAULT_ALARM_47",
|
||||
"48": "DEFAULT_ALARM_48",
|
||||
"49": "DEFAULT_ALARM_49",
|
||||
"50": "DEFAULT_ALARM_50",
|
||||
"51": "DEFAULT_ALARM_51",
|
||||
"52": "DEFAULT_ALARM_52",
|
||||
"53": "DEFAULT_ALARM_53",
|
||||
"54": "DEFAULT_ALARM_54",
|
||||
"55": "DEFAULT_ALARM_55",
|
||||
"56": "DEFAULT_ALARM_56",
|
||||
"57": "DEFAULT_ALARM_57",
|
||||
"58": "DEFAULT_ALARM_58",
|
||||
"59": "DEFAULT_ALARM_59",
|
||||
"60": "DEFAULT_ALARM_60",
|
||||
"61": "DEFAULT_ALARM_61",
|
||||
"62": "DEFAULT_ALARM_62",
|
||||
"63": "DEFAULT_ALARM_63",
|
||||
}
|
||||
|
||||
CGM_ALERTS_DICT = {
|
||||
"11": "CGM Sensor Fail",
|
||||
"13": "CGM Sensor Expired",
|
||||
"14": "CGM Out Of Range",
|
||||
"20": "CGM Transmitter Error",
|
||||
"26": "CGM Temperature",
|
||||
"27": "CGM Failed Connection",
|
||||
"39": "CGM Transmitter Expired",
|
||||
"40": "Pump Bluetooth Error"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
try:
|
||||
from static_dicts import ALERTS_DICT, ALARMS_DICT, CGM_ALERTS_DICT
|
||||
except ImportError:
|
||||
from .static_dicts import ALERTS_DICT, ALARMS_DICT, CGM_ALERTS_DICT
|
||||
|
||||
def enumNameFormat(text):
|
||||
if not text:
|
||||
return text
|
||||
t = text.replace('_', ' ').title().replace(' ', '')
|
||||
if t.startswith('no,'):
|
||||
return 'No'
|
||||
if t.startswith('yes,'):
|
||||
return 'Yes'
|
||||
|
||||
rem = None
|
||||
for i in '-,.':
|
||||
spl = t.split(i)
|
||||
t = spl[0]
|
||||
if len(spl) > 1:
|
||||
rem = rem or spl[1]
|
||||
|
||||
for i in '()/"\u201c\u201d':
|
||||
t = t.replace(i, '')
|
||||
|
||||
if t.lower() == 'false':
|
||||
return 'FalseVal'
|
||||
if t.lower() == 'true':
|
||||
return 'TrueVal'
|
||||
if t.lower() == 'none':
|
||||
return 'NoneVal'
|
||||
if t.lower() == 'reserved':
|
||||
return None
|
||||
if t.lower() == 'unused':
|
||||
return None
|
||||
if t.lower() == 'unavailable' and rem:
|
||||
suffix = enumNameFormat(rem)
|
||||
t += f'{suffix[0].lower()}{suffix[1:]}'
|
||||
|
||||
return f'{t[0].upper()}{t[1:]}'
|
||||
|
||||
def transform_enum(event_def, name, name_fmt, field, tx):
|
||||
out = []
|
||||
lines_for_out = json.dumps(tx, indent=4).splitlines()
|
||||
out += [f'{enumNameFormat(name_fmt)}Map = {lines_for_out[0]}']
|
||||
out += lines_for_out[1:]
|
||||
out += ['']
|
||||
out += [f'class {enumNameFormat(name_fmt)}Enum(Enum):']
|
||||
out += [
|
||||
f' {enumNameFormat(v)} = {k}' for k, v in tx.items() if enumNameFormat(v)
|
||||
]
|
||||
out += ['']
|
||||
out += [
|
||||
'@property',
|
||||
f'def {name_fmt}(self):',
|
||||
f' try:',
|
||||
f' return self.{enumNameFormat(name_fmt)}Enum(self.{name_fmt}Raw)',
|
||||
f' except ValueError as e:',
|
||||
f' logger.error("Invalid {name_fmt}Raw in {enumNameFormat(name_fmt)} for "+str(self))',
|
||||
f' logger.error(e)',
|
||||
f' return None',
|
||||
''
|
||||
]
|
||||
|
||||
return out
|
||||
|
||||
def transform_dictionary(event_def, name, name_fmt, field, tx):
|
||||
if tx == 'alerts':
|
||||
return transform_enum(event_def, name, name_fmt, field, ALERTS_DICT)
|
||||
|
||||
if tx == 'alarms':
|
||||
return transform_enum(event_def, name, name_fmt, field, ALARMS_DICT)
|
||||
|
||||
if tx == 'dalerts':
|
||||
return transform_enum(event_def, name, name_fmt, field, CGM_ALERTS_DICT)
|
||||
return [f'# Dictionary unknown: {tx}']
|
||||
|
||||
def transform_bitmask(event_def, name, name_fmt, field, tx):
|
||||
out = []
|
||||
lines_for_out = json.dumps(tx, indent=4).splitlines()
|
||||
out += [f'{enumNameFormat(name_fmt)}Map = {lines_for_out[0]}']
|
||||
out += lines_for_out[1:]
|
||||
out += ['']
|
||||
out += [f'class {enumNameFormat(name_fmt)}Bitmask(IntFlag):',]
|
||||
out += [
|
||||
f' {enumNameFormat(v)} = 2**{k}' for k, v in tx.items() if enumNameFormat(v)
|
||||
]
|
||||
out += ['']
|
||||
out += [
|
||||
'@property',
|
||||
f'def {name_fmt}(self):',
|
||||
f' try:',
|
||||
f' return self.{enumNameFormat(name_fmt)}Bitmask(self.{name_fmt}Raw)',
|
||||
f' except ValueError as e:',
|
||||
f' logger.error("Invalid {name_fmt}Raw in {enumNameFormat(name_fmt)}Bitmask for "+str(self))',
|
||||
f' logger.error(e)',
|
||||
f' return None',
|
||||
f''
|
||||
]
|
||||
|
||||
return out
|
||||
|
||||
def transform_ratio(event_def, name, name_fmt, field, tx):
|
||||
out = []
|
||||
out += [
|
||||
'@property',
|
||||
f'def {name_fmt}(self):',
|
||||
f' return self.{name_fmt}Raw * {tx}',
|
||||
''
|
||||
]
|
||||
|
||||
return out
|
||||
|
||||
def transform_battery_charge_percent(event_def, name, name_fmt, field, tx):
|
||||
out = []
|
||||
out += [
|
||||
'@property',
|
||||
f'def batteryChargePercent(self):',
|
||||
f' return (256*(self.batterychargepercentmsbRaw-14)+self.batterychargepercentlsbRaw)/(3*256)',
|
||||
''
|
||||
]
|
||||
|
||||
return out
|
||||
|
||||
|
||||
TRANSFORMS = {
|
||||
'enum': transform_enum,
|
||||
'dictionary': transform_dictionary,
|
||||
'bitmask': transform_bitmask,
|
||||
'ratio': transform_ratio,
|
||||
'battery_charge_percent': transform_battery_charge_percent
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import itertools
|
||||
|
||||
def batched(iterable, n):
|
||||
"""
|
||||
Batch data into iterators of length n. The last batch may be shorter.
|
||||
This is a polyfill for itertools.batched() in Python 3.12+
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError('n must be at least one')
|
||||
it = iter(iterable)
|
||||
while True:
|
||||
chunk_it = itertools.islice(it, n)
|
||||
try:
|
||||
first_el = next(chunk_it)
|
||||
except StopIteration:
|
||||
return
|
||||
yield itertools.chain((first_el,), chunk_it)
|
||||
|
||||
def bitmask_to_list(intflag):
|
||||
n = type(intflag).__name__
|
||||
if not str(intflag).startswith(n):
|
||||
return []
|
||||
return str(intflag)[len(n)+1:].split('|')
|
||||
@@ -9,11 +9,14 @@ CGM = "CGM"
|
||||
PUMP_EVENTS = "PUMP_EVENTS"
|
||||
PUMP_EVENTS_BASAL_SUSPENSION = "PUMP_EVENTS_BASAL_SUSPENSION"
|
||||
PROFILES = "PROFILES"
|
||||
CGM_ALERTS = "CGM_ALERTS"
|
||||
DEVICE_STATUS = "DEVICE_STATUS"
|
||||
|
||||
DEFAULT_FEATURES = [
|
||||
BASAL,
|
||||
BOLUS,
|
||||
PUMP_EVENTS
|
||||
PUMP_EVENTS,
|
||||
PROFILES
|
||||
]
|
||||
|
||||
ALL_FEATURES = [
|
||||
@@ -22,13 +25,15 @@ ALL_FEATURES = [
|
||||
IOB,
|
||||
PUMP_EVENTS,
|
||||
PUMP_EVENTS_BASAL_SUSPENSION,
|
||||
PROFILES
|
||||
PROFILES,
|
||||
CGM,
|
||||
CGM_ALERTS,
|
||||
DEVICE_STATUS,
|
||||
]
|
||||
|
||||
|
||||
# These modes are not yet ready for wide use.
|
||||
if ENABLE_TESTING_MODES:
|
||||
ALL_FEATURES += [
|
||||
BOLUS_BG,
|
||||
CGM
|
||||
BOLUS_BG
|
||||
]
|
||||
+86
-28
@@ -30,10 +30,11 @@ def time_range(field_name, start_time, end_time, t_to_space=False):
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class NightscoutApi:
|
||||
def __init__(self, url, secret, skip_verify=False):
|
||||
def __init__(self, url, secret, skip_verify=False, ignore_conn_errors=False):
|
||||
self.url = url
|
||||
self.secret = secret
|
||||
self.verify = False if skip_verify else None
|
||||
self.ignore_conn_errors = ignore_conn_errors
|
||||
|
||||
|
||||
def upload_entry(self, ns_format, entity='treatments'):
|
||||
@@ -70,29 +71,37 @@ class NightscoutApi:
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
}, verify=self.verify)
|
||||
if latest.status_code != 200:
|
||||
if 'as a valid ISO-8601 date' in latest.text:
|
||||
logger.warning("Nightscout last_uploaded_entry %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (eventType, time_start, time_end, dateFilter))
|
||||
return None
|
||||
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry %s response: %s" % (latest.status_code, latest.text))
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
ret = None
|
||||
try:
|
||||
ret = internal(False)
|
||||
except ApiException as e:
|
||||
logger.warning("last_uploaded_entry with no t_to_space: %s", e)
|
||||
ret = None
|
||||
if ret is None and (time_start or time_end):
|
||||
try:
|
||||
ret = internal(True)
|
||||
ret = internal(False)
|
||||
except ApiException as e:
|
||||
logger.warning("last_uploaded_entry with t_to_space: %s", e)
|
||||
#logger.warning("last_uploaded_entry with no t_to_space: %s", e)
|
||||
ret = None
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
|
||||
return ret
|
||||
|
||||
if ret is None and (time_start or time_end):
|
||||
try:
|
||||
ret = internal(True)
|
||||
except ApiException as e:
|
||||
#logger.warning("last_uploaded_entry with t_to_space: %s", e)
|
||||
ret = None
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
|
||||
return ret
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
if self.ignore_conn_errors:
|
||||
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
def last_uploaded_bg_entry(self, time_start=None, time_end=None):
|
||||
def internal(t_to_space):
|
||||
dateFilter = time_range('dateString', time_start, time_end, t_to_space=t_to_space)
|
||||
@@ -100,19 +109,29 @@ class NightscoutApi:
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
}, verify=self.verify)
|
||||
if latest.status_code != 200:
|
||||
if 'as a valid ISO-8601 date' in latest.text:
|
||||
logger.warning("Nightscout last_uploaded_bg_entry could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (time_start, time_end, dateFilter))
|
||||
return None
|
||||
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry %s response: %s" % (latest.status_code, latest.text))
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_bg_entry with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
|
||||
return ret
|
||||
|
||||
try:
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_bg_entry with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
|
||||
|
||||
return ret
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
if self.ignore_conn_errors:
|
||||
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
def last_uploaded_activity(self, activityType, time_start=None, time_end=None):
|
||||
def internal(t_to_space):
|
||||
@@ -121,19 +140,58 @@ class NightscoutApi:
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
}, verify=self.verify)
|
||||
if latest.status_code != 200:
|
||||
if 'as a valid ISO-8601 date' in latest.text:
|
||||
logger.warning("Nightscout activity %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (activityType, time_start, time_end, dateFilter))
|
||||
return None
|
||||
raise ApiException(latest.status_code, "Nightscout activity %s response: %s" % (latest.status_code, latest.text))
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_activity with activityType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (activityType, time_start, time_end))
|
||||
return ret
|
||||
|
||||
try:
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_activity with activityType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (activityType, time_start, time_end))
|
||||
return ret
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
if self.ignore_conn_errors:
|
||||
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
def last_uploaded_devicestatus(self, time_start=None, time_end=None):
|
||||
def internal(t_to_space):
|
||||
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space)
|
||||
latest = requests.get(urljoin(self.url, 'api/v1/devicestatus?find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
}, verify=self.verify)
|
||||
if latest.status_code != 200:
|
||||
if 'as a valid ISO-8601 date' in latest.text:
|
||||
logger.warning("Nightscout devicestatus could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (time_start, time_end, dateFilter))
|
||||
return None
|
||||
raise ApiException(latest.status_code, "Nightscout devicestatus %s response: %s" % (latest.status_code, latest.text))
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
try:
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("devicestatus time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
|
||||
return ret
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
if self.ignore_conn_errors:
|
||||
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
"""
|
||||
Returns general status information about the Nightscout server.
|
||||
@@ -145,7 +203,7 @@ class NightscoutApi:
|
||||
if status.status_code != 200:
|
||||
raise Exception('HTTP error status code (%d) from Nightscout: %s' % (status.status_code, status.text))
|
||||
return status.json()
|
||||
|
||||
|
||||
"""
|
||||
Returns information on the currently configured Nightscout profile data store
|
||||
(contains all profiles in Nightscout under one mongo object).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import arrow
|
||||
|
||||
from ..domain.device_settings import Profile, DeviceSettings
|
||||
from ..domain.tandemsource.pump_settings import PumpProfile, PumpSettings
|
||||
from ..secret import TIMEZONE_NAME, NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE
|
||||
|
||||
ENTERED_BY = "Pump (tconnectsync)"
|
||||
@@ -9,9 +10,15 @@ BASAL_EVENTTYPE = "Temp Basal"
|
||||
BOLUS_EVENTTYPE = "Combo Bolus"
|
||||
SITECHANGE_EVENTTYPE = "Site Change"
|
||||
BASALSUSPENSION_EVENTTYPE = "Basal Suspension"
|
||||
BASALRESUME_EVENTTYPE = "Basal Resume"
|
||||
ACTIVITY_EVENTTYPE = "Activity"
|
||||
EXERCISE_EVENTTYPE = "Exercise"
|
||||
SLEEP_EVENTTYPE = "Sleep"
|
||||
ALARM_EVENTTYPE = "Alarm"
|
||||
CGM_ALERT_EVENTTYPE = "CGM Alert"
|
||||
CGM_START_EVENTTYPE = "Sensor Start"
|
||||
CGM_JOIN_EVENTTYPE = "Sensor Start"
|
||||
CGM_STOP_EVENTTYPE = "Sensor Stop"
|
||||
|
||||
IOB_ACTIVITYTYPE = "tconnect_iob"
|
||||
|
||||
@@ -21,7 +28,7 @@ Conversion methods for parsing data into Nightscout objects.
|
||||
"""
|
||||
class NightscoutEntry:
|
||||
@staticmethod
|
||||
def basal(value, duration_mins, created_at, reason=""):
|
||||
def basal(value, duration_mins, created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": BASAL_EVENTTYPE,
|
||||
"reason": reason,
|
||||
@@ -31,7 +38,8 @@ class NightscoutEntry:
|
||||
"created_at": created_at,
|
||||
"carbs": None,
|
||||
"insulin": None,
|
||||
"enteredBy": ENTERED_BY
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
# Note that Nightscout is not consistent and uses "Sensor"/"Finger"
|
||||
@@ -40,23 +48,29 @@ class NightscoutEntry:
|
||||
FINGER = "Finger"
|
||||
|
||||
@staticmethod
|
||||
def bolus(bolus, carbs, created_at, notes="", bg="", bg_type=""):
|
||||
def bolus(bolus, carbs, created_at, notes="", bg="", bg_type="", pump_event_id=""):
|
||||
data = {
|
||||
"eventType": BOLUS_EVENTTYPE,
|
||||
"created_at": created_at,
|
||||
"carbs": int(carbs),
|
||||
"carbs": int(carbs) if carbs else 0,
|
||||
"insulin": float(bolus),
|
||||
"notes": notes,
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
if bg:
|
||||
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
|
||||
raise InvalidBolusTypeException("bg_type: %s (%s)" % (bg_type, data))
|
||||
if bg_type:
|
||||
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
|
||||
raise InvalidBolusTypeException("bg_type: %s (%s)" % (bg_type, data))
|
||||
|
||||
data.update({
|
||||
"glucose": str(bg),
|
||||
"glucoseType": bg_type
|
||||
})
|
||||
data.update({
|
||||
"glucose": str(bg),
|
||||
"glucoseType": bg_type
|
||||
})
|
||||
else:
|
||||
data.update({
|
||||
"glucose": str(bg)
|
||||
})
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
@@ -67,49 +81,135 @@ class NightscoutEntry:
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def entry(sgv, created_at):
|
||||
def entry(sgv, created_at, pump_event_id=""):
|
||||
return {
|
||||
"type": "sgv",
|
||||
"sgv": int(sgv),
|
||||
"date": int(1000 * arrow.get(created_at).timestamp()),
|
||||
"dateString": arrow.get(created_at).strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
"device": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id,
|
||||
# delta, direction are undefined
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def sitechange(created_at, reason=""):
|
||||
def sitechange(created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": SITECHANGE_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def basalsuspension(created_at, reason=""):
|
||||
def basalsuspension(created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": BASALSUSPENSION_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def activity(created_at, duration, reason="", event_type=ACTIVITY_EVENTTYPE):
|
||||
def basalresume(created_at, pump_event_id=""):
|
||||
return {
|
||||
"eventType": BASALRESUME_EVENTTYPE,
|
||||
"reason": "Basal resumed",
|
||||
"notes": "Basal resumed",
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def alarm(created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": ALARM_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def cgm_alert(created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": CGM_ALERT_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def cgm_start(created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": CGM_START_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def cgm_join(created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": CGM_JOIN_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def cgm_stop(created_at, reason="", pump_event_id=""):
|
||||
return {
|
||||
"eventType": CGM_STOP_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def activity(created_at, duration, reason="", event_type=ACTIVITY_EVENTTYPE, pump_event_id=""):
|
||||
return {
|
||||
"eventType": event_type,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"duration": float(duration),
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
"enteredBy": ENTERED_BY,
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def devicestatus(created_at, batteryVoltage, batteryPercent, pump_event_id=""):
|
||||
return {
|
||||
"device": ENTERED_BY,
|
||||
"created_at": created_at,
|
||||
"pump": {
|
||||
"clock": created_at,
|
||||
"battery": {
|
||||
"voltage": float(batteryVoltage),
|
||||
"percent": int(batteryPercent) if batteryPercent else None,
|
||||
"status": "%.0f%s" % (batteryPercent, '%')
|
||||
},
|
||||
},
|
||||
"pump_event_id": pump_event_id
|
||||
}
|
||||
|
||||
# Tandem-scraped profile to Nightscout profile store entry
|
||||
@staticmethod
|
||||
def profile_store(profile: Profile, device_settings: DeviceSettings) -> dict:
|
||||
@@ -158,7 +258,61 @@ class NightscoutEntry:
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# TandemSource profile to Nightscout profile store entry
|
||||
@staticmethod
|
||||
def tandemsource_profile_store(profile: PumpProfile, pump_settings: PumpSettings) -> dict:
|
||||
return {
|
||||
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
|
||||
"dia": "%s" % (profile.insulinDuration / 60),
|
||||
"carbratio": list(sorted([
|
||||
{
|
||||
"time": minutes_to_ns_time(segment.startTime),
|
||||
"timeAsSeconds": segment.startTime * 60,
|
||||
"value": segment.carbRatio / 1000 # milliunits->units
|
||||
} for segment in profile.tDependentSegs if not segment.skip
|
||||
], key=lambda x: x["timeAsSeconds"])),
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
|
||||
"sens": list(sorted([ # Correction factor / isf
|
||||
{
|
||||
"time": minutes_to_ns_time(segment.startTime),
|
||||
"timeAsSeconds": segment.startTime * 60,
|
||||
"value": segment.isf
|
||||
} for segment in profile.tDependentSegs if not segment.skip
|
||||
], key=lambda x: x["timeAsSeconds"])),
|
||||
|
||||
"basal": list(sorted([
|
||||
{
|
||||
"time": minutes_to_ns_time(segment.startTime),
|
||||
"timeAsSeconds": segment.startTime * 60,
|
||||
"value": segment.basalRate / 1000 # milliunits->units
|
||||
} for segment in profile.tDependentSegs
|
||||
], key=lambda x: x["timeAsSeconds"])),
|
||||
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": pump_settings.cgmSettings.lowGlucoseAlert.mgPerDl
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": pump_settings.cgmSettings.highGlucoseAlert.mgPerDl
|
||||
}
|
||||
],
|
||||
"timezone": TIMEZONE_NAME, # tconnectsync settings timezone
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
def tandem_to_ns_time(tandem_time: str) -> str:
|
||||
numbers, ampm = tandem_time.split(' ')
|
||||
hr, min = numbers.split(':')
|
||||
@@ -177,6 +331,12 @@ def tandem_to_ns_time_seconds(tandem_time: str) -> int:
|
||||
return 60 * (60 * (12 + (int(hr) % 12)) + int(min))
|
||||
raise InvalidTimeException(tandem_time)
|
||||
|
||||
def minutes_to_ns_time(minutes_time: int) -> str:
|
||||
hr = minutes_time // 60
|
||||
mn = minutes_time % 60
|
||||
|
||||
return "%02d:%02d" % (hr, mn)
|
||||
|
||||
class InvalidBolusTypeException(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
+14
-2
@@ -4,6 +4,9 @@ from dotenv import dotenv_values
|
||||
cwd_path = os.path.join(os.getcwd(), '.env')
|
||||
global_path = os.path.join(pathlib.Path.home(), '.config/tconnectsync/.env')
|
||||
|
||||
cwd_creds_path = os.path.join(os.getcwd(), '.creds_cache')
|
||||
global_creds_path = os.path.join(pathlib.Path.home(), '.config/tconnectsync/.creds_cache')
|
||||
|
||||
values = {}
|
||||
|
||||
if os.path.exists(cwd_path):
|
||||
@@ -38,6 +41,7 @@ def get_bool(name, default):
|
||||
|
||||
TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com')
|
||||
TCONNECT_PASSWORD = get('TCONNECT_PASSWORD', 'password')
|
||||
TCONNECT_REGION = get_one_of('TCONNECT_REGION', 'US', ['US', 'EU'])
|
||||
|
||||
PUMP_SERIAL_NUMBER = int(get_number('PUMP_SERIAL_NUMBER', '11111111'))
|
||||
|
||||
@@ -49,7 +53,9 @@ if not get('NS_SECRET') and get('API_SECRET'):
|
||||
NS_SECRET = get('API_SECRET')
|
||||
|
||||
NS_SKIP_TLS_VERIFY = get_bool('NS_SKIP_TLS_VERIFY', 'false')
|
||||
NS_IGNORE_CONN_ERRORS = get_bool('NS_IGNORE_CONN_ERRORS', 'false')
|
||||
|
||||
# This should be the timezone your pump is set to.
|
||||
TIMEZONE_NAME = get('TIMEZONE_NAME', 'America/New_York')
|
||||
|
||||
if not get('TIMEZONE_NAME') and get('TZ'):
|
||||
@@ -58,20 +64,26 @@ if not get('TIMEZONE_NAME') and get('TZ'):
|
||||
|
||||
# Optional configuration
|
||||
|
||||
CACHE_CREDENTIALS = get_bool('CACHE_CREDENTIALS', 'true')
|
||||
CACHE_CREDENTIALS_PATH = get('CACHE_CREDENTIALS', cwd_creds_path if os.path.exists(cwd_creds_path) else global_creds_path)
|
||||
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_UNEXPECTED_NO_INDEX_SLEEP_SECONDS = get_number('AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS', '60') # 1 minute
|
||||
AUTOUPDATE_USE_FIXED_SLEEP = get_bool('AUTOUPDATE_USE_FIXED_SLEEP', 'false')
|
||||
AUTOUPDATE_NO_DATA_FAILURE_MINUTES = get_number('AUTOUPDATE_NO_DATA_FAILURE_MINUTES', '180') # 3 hours
|
||||
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15 minutes
|
||||
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true')
|
||||
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '75') # 75 minutes
|
||||
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS = get_number('AUTOUPDATE_MAX_LOOP_INVOCATIONS', '-1')
|
||||
|
||||
NIGHTSCOUT_PROFILE_UPLOAD_MODE = get_one_of('NIGHTSCOUT_PROFILE_UPLOAD_MODE', 'add', ['add', 'replace'])
|
||||
|
||||
# When set, all possible history log event types are fetched from Tandem Source
|
||||
FETCH_ALL_EVENT_TYPES = get_bool('FETCH_ALL_EVENT_TYPES', 'false')
|
||||
|
||||
# Default Nightscout profile segment fields which aren't stored by Tandem
|
||||
NIGHTSCOUT_PROFILE_CARBS_HR_VALUE = get('NIGHTSCOUT_PROFILE_CARBS_HR_VALUE', '20')
|
||||
NIGHTSCOUT_PROFILE_DELAY_VALUE = get('NIGHTSCOUT_PROFILE_DELAY_VALUE', '20')
|
||||
IGNORE_ZERO_UNIT_BASAL = get_bool('IGNORE_ZERO_UNIT_BASAL', 'false')
|
||||
|
||||
ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false')
|
||||
SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false')
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
from ..parser.nightscout import (
|
||||
BASAL_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
# Suspensions with suspendReason 'control-iq' will match a basal event found above.
|
||||
for i in basalEvents:
|
||||
if i["time"] in suspensionEvents:
|
||||
i["delivery_type"] += " (" + suspensionEvents[i["time"]]["suspendReason"] + " suspension)"
|
||||
|
||||
del suspensionEvents[i["time"]]
|
||||
|
||||
# Suspensions with suspendReason 'manual' do not have an associated basal event,
|
||||
# and require extra processing.
|
||||
|
||||
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
|
||||
|
||||
unprocessedSuspensions = list(suspensionEvents.values())
|
||||
unprocessedSuspensions.sort(key=lambda x: arrow.get(x["time"]))
|
||||
|
||||
# For the remaining suspensions which did not match with an existing basal event,
|
||||
# add a new event manually. This means we need to calculate the duration of the
|
||||
# suspension.
|
||||
newEvents = []
|
||||
for i in range(len(basalEvents)):
|
||||
if len(unprocessedSuspensions) == 0:
|
||||
break
|
||||
|
||||
existingTime = arrow.get(basalEvents[i]["time"])
|
||||
unprocessedTime = arrow.get(unprocessedSuspensions[0]["time"])
|
||||
|
||||
# If we've found an event which occurs after the suspension, then the
|
||||
# difference in their timestamps is the duration of the suspension.
|
||||
if i > 0 and existingTime > unprocessedTime:
|
||||
suspension = unprocessedSuspensions.pop(0)
|
||||
|
||||
# TConnect's internal duration object tracks the duration in seconds
|
||||
seconds = (existingTime - unprocessedTime).seconds
|
||||
|
||||
newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds)
|
||||
logger.debug("Adding basal event for unprocessed suspension: %s" % newEvent)
|
||||
newEvents.append(newEvent)
|
||||
|
||||
# Any remaining suspensions which have not been processed have not ended,
|
||||
# which means we do not know their duration; so we will skip them (for now)
|
||||
|
||||
# Add any new events and re-sort
|
||||
if newEvents:
|
||||
basalEvents += newEvents
|
||||
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
|
||||
|
||||
|
||||
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, time_start=None, time_end=None):
|
||||
logger.debug("ns_write_basal_events: querying for last uploaded entry")
|
||||
last_upload = nightscout.last_uploaded_entry(BASAL_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
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)
|
||||
|
||||
if SKIP_NS_LAST_UPLOADED_CHECK:
|
||||
logger.warning("Overriding last upload check")
|
||||
last_upload = None
|
||||
last_upload_time = None
|
||||
|
||||
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 (time range: %s - %s)" % (event, time_start, time_end))
|
||||
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
|
||||
@@ -1,117 +0,0 @@
|
||||
import arrow
|
||||
import logging
|
||||
from tconnectsync.domain.bolus import Bolus
|
||||
|
||||
from tconnectsync.sync.cgm import find_event_at
|
||||
|
||||
from ..parser.nightscout import (
|
||||
BOLUS_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
|
||||
"""
|
||||
def process_bolus_events(bolusdata, cgmEvents=None, source=""):
|
||||
bolusEvents = []
|
||||
|
||||
for b in bolusdata:
|
||||
parsed = None
|
||||
if source == "ciq":
|
||||
parsed = b.to_bolus()
|
||||
else:
|
||||
parsed = TConnectEntry.parse_bolus_entry(b)
|
||||
|
||||
assert type(parsed) == Bolus
|
||||
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 %s bolus data (was a bolus in progress?): %s parsed: %s" % (source, b, parsed))
|
||||
continue
|
||||
if parsed.is_extended_bolus:
|
||||
if not parsed.bolex_start_time and not parsed.start_time:
|
||||
logger.warning("Skipping non-completed %s extended bolus data with no start time: %s parsed: %s" % (source, b, parsed))
|
||||
elif not parsed.bolex_start_time and parsed.start_time:
|
||||
logger.warning("Setting bolex_start_time to start_time for non-completed %s extended bolus: %s parsed: %s" % (source, b, parsed))
|
||||
parsed.bolex_start_time = parsed.start_time
|
||||
|
||||
if parsed.bg and cgmEvents:
|
||||
requested_at = parsed.request_time if not parsed.extended_bolus else parsed.bolex_start_time
|
||||
parsed.bg_type = guess_bolus_bg_type(parsed.bg, requested_at, cgmEvents)
|
||||
|
||||
bolusEvents.append(parsed)
|
||||
|
||||
bolusEvents.sort(key=lambda event: arrow.get(event.request_time if not event.is_extended_bolus else event.bolex_start_time))
|
||||
|
||||
return bolusEvents
|
||||
|
||||
"""
|
||||
Determine whether the given BG specified in the bolus is identical to the
|
||||
most recent CGM reading at that time. If it is, return SENSOR.
|
||||
Otherwise, return FINGER.
|
||||
"""
|
||||
def guess_bolus_bg_type(bg, created_at, cgmEvents):
|
||||
if not cgmEvents:
|
||||
return NightscoutEntry.FINGER
|
||||
|
||||
event = find_event_at(cgmEvents, created_at)
|
||||
if event and str(event["bg"]) == str(bg):
|
||||
return NightscoutEntry.SENSOR
|
||||
|
||||
return NightscoutEntry.FINGER
|
||||
|
||||
|
||||
"""
|
||||
Given processed bolus data, adds bolus events to Nightscout.
|
||||
"""
|
||||
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None, time_start=None, time_end=None):
|
||||
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
|
||||
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
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)
|
||||
|
||||
if SKIP_NS_LAST_UPLOADED_CHECK:
|
||||
logger.warning("Overriding last upload check")
|
||||
last_upload = None
|
||||
last_upload_time = None
|
||||
|
||||
add_count = 0
|
||||
for event in bolusEvents:
|
||||
created_at = event.completion_time if not event.is_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 (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
if include_bg and event.bg:
|
||||
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 ""),
|
||||
bg=event.bg,
|
||||
bg_type=event.bg_type
|
||||
)
|
||||
else:
|
||||
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
|
||||
@@ -1,69 +0,0 @@
|
||||
import json
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..parser.nightscout import NightscoutEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process_cgm_events(readingData):
|
||||
data = []
|
||||
for r in readingData:
|
||||
data.append(TConnectEntry.parse_reading_entry(r))
|
||||
|
||||
return data
|
||||
|
||||
"""
|
||||
Given reading data and a time, finds the BG reading event which would have
|
||||
been the current one at that time. e.g., it looks before the given time,
|
||||
not after.
|
||||
This is a heuristic for checking whether the BG component of a bolus was
|
||||
manually entered or inferred based on the pump's CGM.
|
||||
"""
|
||||
def find_event_at(cgmEvents, find_time):
|
||||
find_t = arrow.get(find_time)
|
||||
events = list(map(lambda x: (arrow.get(x["time"]), x), cgmEvents))
|
||||
events.sort()
|
||||
|
||||
closestReading = None
|
||||
for t, r in events:
|
||||
if t > find_t:
|
||||
break
|
||||
closestReading = r
|
||||
|
||||
|
||||
return closestReading
|
||||
|
||||
|
||||
"""
|
||||
Given processed CGM data, adds reading entries to Nightscout.
|
||||
"""
|
||||
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False, time_start=None, time_end=None):
|
||||
logger.debug("ns_write_cgm_events: querying for last uploaded entry")
|
||||
last_upload = nightscout.last_uploaded_bg_entry(time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["dateString"])
|
||||
logger.info("Last Nightscout CGM upload: %s" % last_upload_time)
|
||||
|
||||
add_count = 0
|
||||
for event in cgmEvents:
|
||||
created_at = event["time"]
|
||||
if last_upload_time and arrow.get(created_at) <= last_upload_time:
|
||||
if pretend:
|
||||
logger.info("Skipping CGM event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
entry = NightscoutEntry.entry(
|
||||
sgv=event["bg"],
|
||||
created_at=created_at
|
||||
)
|
||||
|
||||
add_count += 1
|
||||
|
||||
logger.info(" Processing cgm reading: %s entry: %s" % (event, entry))
|
||||
if not pretend:
|
||||
nightscout.upload_entry(entry, entity='entries')
|
||||
|
||||
return add_count
|
||||
@@ -1,59 +0,0 @@
|
||||
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, time_start=None, time_end=None):
|
||||
logger.debug("ns_write_iob_events: querying for last uploaded entry")
|
||||
last_upload = nightscout.last_uploaded_activity(IOB_ACTIVITYTYPE, time_start=time_start, time_end=time_end)
|
||||
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
|
||||
@@ -1,200 +0,0 @@
|
||||
from typing import List, Tuple
|
||||
import logging
|
||||
import json
|
||||
import copy
|
||||
import arrow
|
||||
|
||||
from ..api import TConnectApi
|
||||
from ..domain.device_settings import Profile, DeviceSettings
|
||||
from ..parser.nightscout import NightscoutEntry
|
||||
from ..nightscout import NightscoutApi
|
||||
from ..secret import PUMP_SERIAL_NUMBER, NIGHTSCOUT_PROFILE_UPLOAD_MODE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_default_serial_number():
|
||||
return PUMP_SERIAL_NUMBER
|
||||
|
||||
def _get_default_upload_mode():
|
||||
return NIGHTSCOUT_PROFILE_UPLOAD_MODE
|
||||
|
||||
def get_pump_profiles(tconnect: TConnectApi, serial_number: int = None) -> Tuple[List[Profile], DeviceSettings]:
|
||||
all_devices = tconnect.webui.my_devices()
|
||||
if serial_number is None:
|
||||
serial_number = _get_default_serial_number()
|
||||
|
||||
if str(serial_number) not in all_devices:
|
||||
logger.warn("Could not find entry for provided pump serial number in t:connect device list: %s, received: %s", serial_number, all_devices)
|
||||
return [], None
|
||||
|
||||
device = all_devices[str(serial_number)]
|
||||
|
||||
logger.info("Getting profile settings for %s", device)
|
||||
device_profiles, device_settings = tconnect.webui.device_settings_from_guid(device.guid)
|
||||
logger.debug("device_profiles: %s", device_profiles)
|
||||
logger.debug("device_settings: %s", device_settings)
|
||||
|
||||
logger.info("Found pump profiles: %s", ["%s%s" % (profile.title, " (active)" if profile.active else "") for profile in device_profiles])
|
||||
|
||||
return device_profiles, device_settings
|
||||
|
||||
|
||||
"""
|
||||
Compare pump device and Nightscout profiles, and return a final dictionary of
|
||||
Nightscout profile objects, with the pump profile settings overriding what is
|
||||
currently in Nightscout.
|
||||
|
||||
ns_profile_obj is the output from NightscoutApi.current_profile() and should be the most
|
||||
recent profile object in mongo.
|
||||
|
||||
Returns the new Nightscout profile and whether it was changed.
|
||||
"""
|
||||
def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSettings, ns_profile_obj: dict) -> Tuple[bool, dict]:
|
||||
device = {profile.title: profile for profile in device_profiles}
|
||||
ns = ns_profile_obj.get('store', {})
|
||||
|
||||
logger.info("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys())
|
||||
|
||||
new_ns_profile = copy.deepcopy(ns_profile_obj)
|
||||
updated_ns_profile = False
|
||||
|
||||
missing_profiles_in_ns = set(device.keys()) - set(ns.keys())
|
||||
for profile_name in missing_profiles_in_ns:
|
||||
logger.info("Missing %s profile in Nightscout: %s", profile_name, device.get(profile_name))
|
||||
pump_configured_profile = device[profile_name]
|
||||
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
|
||||
logger.info("Will add %s profile to Nightscout: %s", profile_name, ns_translated_profile)
|
||||
new_ns_profile['store'][profile_name] = ns_translated_profile
|
||||
updated_ns_profile = True
|
||||
|
||||
existent_profiles_in_ns = set(device.keys()) & set(ns.keys())
|
||||
for profile_name in existent_profiles_in_ns:
|
||||
logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name)
|
||||
pump_configured_profile = device[profile_name]
|
||||
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
|
||||
ns_configured_profile = ns[profile_name]
|
||||
|
||||
logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile)
|
||||
if nightscout_profiles_identical(ns_configured_profile, ns_translated_profile):
|
||||
logger.info("Profile %s identical between pump and nightscout", profile_name)
|
||||
continue
|
||||
|
||||
logger.info("Profile %s needs update in nightscout: %s", profile_name, ns_translated_profile)
|
||||
new_ns_profile['store'][profile_name] = ns_translated_profile
|
||||
updated_ns_profile = True
|
||||
|
||||
current_pump_profile = None
|
||||
for profile in device_profiles:
|
||||
if profile.active:
|
||||
current_pump_profile = profile.title
|
||||
|
||||
if not current_pump_profile:
|
||||
logger.error('No current pump profile, so skipping profile update: device: %s', device_profiles)
|
||||
return False, current_ns_profile
|
||||
|
||||
current_ns_profile = ns_profile_obj.get('defaultProfile')
|
||||
if current_pump_profile != current_ns_profile:
|
||||
logger.info("Current profile changed: pump: %s nightscout: %s", current_pump_profile, current_ns_profile)
|
||||
new_ns_profile['defaultProfile'] = current_pump_profile
|
||||
updated_ns_profile = True
|
||||
|
||||
if not updated_ns_profile:
|
||||
logger.info("No Nightscout profile changes")
|
||||
return False, ns_profile_obj
|
||||
|
||||
logger.info("New Nightscout profile object: %s", new_ns_profile)
|
||||
return True, new_ns_profile
|
||||
|
||||
def nightscout_profiles_identical(configured: dict, translated: dict) -> bool:
|
||||
if configured == translated:
|
||||
logger.debug("direct dicts equal")
|
||||
return True
|
||||
|
||||
if json.dumps(configured, sort_keys=True, indent=None) == json.dumps(translated, sort_keys=True, indent=None):
|
||||
logger.debug("initial JSON dump identical")
|
||||
return True
|
||||
|
||||
# convert all JSON values into strings
|
||||
def map_nested_dicts_modify(ob, func):
|
||||
for k, v in ob.items():
|
||||
if isinstance(v, dict):
|
||||
map_nested_dicts_modify(v, func)
|
||||
elif isinstance(v, list):
|
||||
map_nested_lists_modify(v, func)
|
||||
else:
|
||||
ob[k] = func(v)
|
||||
|
||||
def map_nested_lists_modify(ob, func):
|
||||
for i in range(len(ob)):
|
||||
v = ob[i]
|
||||
if isinstance(v, dict):
|
||||
map_nested_dicts_modify(v, func)
|
||||
elif isinstance(v, list):
|
||||
map_nested_lists_modify(v, func)
|
||||
else:
|
||||
ob[i] = func(v)
|
||||
|
||||
def to_numeric(x):
|
||||
if type(x) in [int, float]:
|
||||
return '%f' % x
|
||||
try:
|
||||
return '%f' % float(x)
|
||||
except (ValueError, TypeError):
|
||||
return x
|
||||
|
||||
|
||||
convert_func = lambda x: to_numeric(x)
|
||||
|
||||
configured_str = json.loads(json.dumps(configured))
|
||||
map_nested_dicts_modify(configured_str, convert_func)
|
||||
translated_str = json.loads(json.dumps(translated))
|
||||
map_nested_dicts_modify(translated_str, convert_func)
|
||||
|
||||
if json.dumps(configured_str, sort_keys=True, indent=None) == json.dumps(translated_str, sort_keys=True, indent=None):
|
||||
logger.debug("map_nested_dicts JSON dump identical")
|
||||
return True
|
||||
|
||||
logger.debug("profiles not identical")
|
||||
return False
|
||||
|
||||
def setup_new_profile(ns_profile: dict) -> dict:
|
||||
if '_id' in ns_profile:
|
||||
del ns_profile['_id']
|
||||
|
||||
now = arrow.now().isoformat()
|
||||
ns_profile['startDate'] = now
|
||||
ns_profile['created_at'] = now
|
||||
|
||||
return ns_profile
|
||||
|
||||
def process_profiles(tconnect: TConnectApi, nightscout: NightscoutApi, pretend: bool = False, upload_mode: str = None) -> bool:
|
||||
if not upload_mode:
|
||||
upload_mode = _get_default_upload_mode()
|
||||
|
||||
logger.debug("Checking for differences between pump and nightscout profiles: %s mode", upload_mode)
|
||||
|
||||
ns_profile_obj = nightscout.current_profile()
|
||||
pump_profiles, pump_settings = get_pump_profiles(tconnect)
|
||||
diff, ns_profile_new = compare_profiles(pump_profiles, pump_settings, ns_profile_obj)
|
||||
|
||||
if not diff:
|
||||
logger.info("Pump and Nightscout profiles up to date")
|
||||
return False
|
||||
|
||||
if upload_mode == 'add':
|
||||
profile_to_upload = setup_new_profile(ns_profile_new)
|
||||
logger.info("Adding new Nightscout profiles object: %s", profile_to_upload)
|
||||
|
||||
if not pretend:
|
||||
nightscout.upload_entry(profile_to_upload, entity='profile')
|
||||
return True
|
||||
|
||||
elif upload_mode == 'replace':
|
||||
logger.info("Replacing new Nightscout profiles object: %s", ns_profile_new)
|
||||
|
||||
if not pretend:
|
||||
nightscout.put_entry(ns_profile_new, entity='profile')
|
||||
return True
|
||||
|
||||
else:
|
||||
raise RuntimeError('invalid upload_mode: %s' % upload_mode)
|
||||
@@ -1,218 +0,0 @@
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
from ..parser.nightscout import (
|
||||
SITECHANGE_EVENTTYPE,
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
EXERCISE_EVENTTYPE,
|
||||
SLEEP_EVENTTYPE,
|
||||
ACTIVITY_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
Given a list of "activity events" from the CIQ therapy timeline endpoint,
|
||||
process it into our internal events format.
|
||||
|
||||
These events contain a duration.
|
||||
"""
|
||||
def process_ciq_activity_events(data):
|
||||
events = []
|
||||
|
||||
for event in data["events"]:
|
||||
events.append(TConnectEntry.parse_ciq_activity_event(event))
|
||||
|
||||
return events
|
||||
|
||||
"""
|
||||
Given a list of "basal suspension events" from the basalsuspension WS2 endpoint,
|
||||
process it into our internal events format.
|
||||
|
||||
These events do NOT contain a duration.
|
||||
"""
|
||||
def process_basalsuspension_events(data):
|
||||
events = []
|
||||
|
||||
for event in data['BasalSuspension']:
|
||||
parsed = TConnectEntry.parse_basalsuspension_event(event)
|
||||
|
||||
if parsed:
|
||||
events.append(parsed)
|
||||
|
||||
|
||||
return events
|
||||
|
||||
"""
|
||||
Given processed pump event data (of various types), write them to Nightscout
|
||||
"""
|
||||
def ns_write_pump_events(nightscout, pumpEvents, pretend=False, time_start=None, time_end=None):
|
||||
count = 0
|
||||
|
||||
siteChangeEvents = []
|
||||
emptyCartEvents = []
|
||||
userSuspendedEvents = []
|
||||
exerciseEvents = []
|
||||
sleepEvents = []
|
||||
activityEvents = []
|
||||
|
||||
for event in pumpEvents:
|
||||
if event["event_type"] == TConnectEntry.BASALSUSPENSION_EVENTS["site-cart"]:
|
||||
siteChangeEvents.append(event)
|
||||
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["alarm"]:
|
||||
emptyCartEvents.append(event)
|
||||
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["manual"]:
|
||||
userSuspendedEvents.append(event)
|
||||
elif event["event_type"] == "Exercise":
|
||||
exerciseEvents.append(event)
|
||||
elif event["event_type"] == "Sleep":
|
||||
sleepEvents.append(event)
|
||||
elif event["event_type"] in TConnectEntry.ACTIVITY_EVENTS.values():
|
||||
activityEvents.append(event)
|
||||
|
||||
logger.debug("siteChangeEvents: %s" % siteChangeEvents)
|
||||
logger.debug("emptyCartEvents: %s" % emptyCartEvents)
|
||||
logger.debug("userSuspendedEvents: %s" % userSuspendedEvents)
|
||||
logger.debug("exerciseEvents: %s" % exerciseEvents)
|
||||
logger.debug("sleepEvents: %s" % sleepEvents)
|
||||
logger.debug("activityEvents: %s" % activityEvents)
|
||||
|
||||
count += ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
|
||||
count += ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
count += ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
count += ns_write_activity_events(nightscout, activityEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
|
||||
return count
|
||||
|
||||
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
siteChangeEvents,
|
||||
lambda event: NightscoutEntry.sitechange(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"]
|
||||
),
|
||||
SITECHANGE_EVENTTYPE,
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
emptyCartEvents,
|
||||
lambda event: NightscoutEntry.basalsuspension(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"]
|
||||
),
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
userSuspendedEvents,
|
||||
lambda event: NightscoutEntry.basalsuspension(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"]
|
||||
),
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
exerciseEvents,
|
||||
lambda event: NightscoutEntry.activity(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"],
|
||||
duration=event["duration_mins"],
|
||||
event_type=EXERCISE_EVENTTYPE
|
||||
),
|
||||
EXERCISE_EVENTTYPE,
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
sleepEvents,
|
||||
lambda event: NightscoutEntry.activity(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"],
|
||||
duration=event["duration_mins"],
|
||||
event_type=SLEEP_EVENTTYPE
|
||||
),
|
||||
SLEEP_EVENTTYPE,
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_activity_events(nightscout, activityEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
activityEvents,
|
||||
lambda event: NightscoutEntry.activity(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"],
|
||||
duration=event["duration_mins"]
|
||||
),
|
||||
ACTIVITY_EVENTTYPE,
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False, time_start=None, time_end=None):
|
||||
if len(events) == 0:
|
||||
logger.debug("No %s events to process" % eventType)
|
||||
return 0
|
||||
|
||||
logger.debug("ns_write_pump_events: querying for last %s" % eventType)
|
||||
last_upload = nightscout.last_uploaded_entry(eventType, time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout %s: %s" % (eventType, last_upload_time))
|
||||
|
||||
if SKIP_NS_LAST_UPLOADED_CHECK:
|
||||
logger.warning("Overriding last upload check")
|
||||
last_upload = None
|
||||
last_upload_time = None
|
||||
|
||||
add_count = 0
|
||||
for event in events:
|
||||
created_at = event["time"]
|
||||
if last_upload_time and arrow.get(created_at) <= last_upload_time:
|
||||
skip = True
|
||||
if "duration_mins" in event.keys() and "duration" in last_upload.keys():
|
||||
if created_at == last_upload["created_at"] and float(event["duration_mins"]) > float(last_upload["duration"]):
|
||||
logger.info("Latest %s event needs updating: duration has increased from %s to %s: %s" % (eventType, last_upload["duration"], event["duration_mins"], event))
|
||||
logger.info("Deleting previous %s: %s" % (eventType, last_upload))
|
||||
nightscout.delete_entry('treatments/%s' % last_upload["_id"])
|
||||
skip = False
|
||||
|
||||
if skip:
|
||||
if pretend:
|
||||
logger.info("Skipping %s pump event before last upload time: %s (time range: %s - %s)" % (eventType, event, time_start, time_end))
|
||||
continue
|
||||
|
||||
entry = buildNsEventFunc(event)
|
||||
|
||||
add_count += 1
|
||||
|
||||
logger.info(" Processing %s: %s entry: %s" % (eventType, event, entry))
|
||||
if not pretend:
|
||||
nightscout.upload_entry(entry)
|
||||
|
||||
return add_count
|
||||
@@ -2,24 +2,25 @@ import time
|
||||
import logging
|
||||
import datetime
|
||||
import sys
|
||||
import arrow
|
||||
|
||||
from .process import process_time_range
|
||||
from .features import DEFAULT_FEATURES
|
||||
from . import secret
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from .process import ProcessTimeRange
|
||||
from .choose_device import ChooseDevice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Autoupdate:
|
||||
class TandemSourceAutoupdate:
|
||||
"""Wrap access to secrets for easier testing."""
|
||||
def __init__(self, secret):
|
||||
self.secret = secret
|
||||
self.autoupdate_invocations = 0
|
||||
self.last_event_index = None
|
||||
self.last_event_time = None
|
||||
self.last_successful_process_time_range = None
|
||||
self.time_diffs_between_updates = []
|
||||
self.last_attempt_time = None
|
||||
self.last_max_date_with_events = None
|
||||
self.last_event_time = 0
|
||||
self.last_attempt_time = 0
|
||||
self.last_event_seqnum = None
|
||||
self.time_diffs_between_attempts = []
|
||||
self.time_diffs_between_updates = []
|
||||
|
||||
"""
|
||||
Performs the auto-update functionality. Runs indefinitely in a loop
|
||||
@@ -29,8 +30,8 @@ class Autoupdate:
|
||||
def process(self, tconnect, nightscout, time_start, time_end, pretend, features=None):
|
||||
if features is None:
|
||||
features = DEFAULT_FEATURES
|
||||
|
||||
# Read from android api, find exact interval to cut down on API calls
|
||||
|
||||
# Query for data, find exact interval to cut down on API calls
|
||||
# Refresh API token. If failure, die, have wrapper script re-run.
|
||||
|
||||
self.autoupdate_start = time.time()
|
||||
@@ -38,70 +39,42 @@ class Autoupdate:
|
||||
while True:
|
||||
logger.debug("autoupdate loop")
|
||||
now = time.time()
|
||||
last_event = tconnect.android.last_event_uploaded(self.secret.PUMP_SERIAL_NUMBER)
|
||||
if not self.last_event_index or last_event['maxPumpEventIndex'] > self.last_event_index:
|
||||
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], self.last_event_index))
|
||||
|
||||
tconnectDevice = ChooseDevice(self.secret, tconnect).choose()
|
||||
|
||||
event_seqnum = None
|
||||
cur_max_date_with_events = arrow.get(tconnectDevice['maxDateWithEvents']).float_timestamp
|
||||
if not self.last_max_date_with_events or cur_max_date_with_events > self.last_max_date_with_events:
|
||||
logger.info('New reported tandemsource data. (cur_max_date: %s last_max_date: %s)' % (cur_max_date_with_events, self.last_max_date_with_events))
|
||||
|
||||
if pretend:
|
||||
logger.info('Would update now if not in pretend mode')
|
||||
else:
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
|
||||
logger.info('Added %d items from process_time_range' % added)
|
||||
if added == 0:
|
||||
# If we've been unable to find new events, but the last_event_index is increasing,
|
||||
# suggesting there are more events being added, we might be in a bugged
|
||||
# situation where we can't get any more data without restarting.
|
||||
# We skip this check on the first process cycle, since we might
|
||||
# just already be in sync with tconnect's pump data.
|
||||
if self.last_event_index:
|
||||
|
||||
# Find the timestamp of the last time we've successfully obtained data,
|
||||
# or the time when the autoupdate run started, if we haven't at all.
|
||||
last_action_or_start = self.last_successful_process_time_range
|
||||
if not last_action_or_start:
|
||||
last_action_or_start = self.autoupdate_start
|
||||
|
||||
# If it's been AUTOUPDATE_FAILURE_MINUTES in the state of not seeing
|
||||
# event index changes reflected in the tconnect data we're pulling,
|
||||
# raise an error and potentially restart.
|
||||
# This is likely a tconnectsync problem, not a problem with the pump or app
|
||||
# (we can see the indexes increasing, so we know something's happening!)
|
||||
if (now - last_action_or_start) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
|
||||
logger.error(AutoupdateFailureError(
|
||||
("%s: An event index change was recorded, but no new data was found via the API. " % datetime.datetime.now()) +
|
||||
"The %s was %d minutes ago. This is a problem with tconnectsync." %
|
||||
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
|
||||
|
||||
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
|
||||
return 1
|
||||
else:
|
||||
logger.warning(AutoupdateFailureWarning(("%s: An event index change was recorded, but no new data was found via the API. " % datetime.datetime.now()) +
|
||||
"The %s was %d minutes ago. Resetting TConnectApi to attempt to solve this problem." %
|
||||
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
|
||||
|
||||
# As a stop-gap, try to re-initialize TConnectApi (triggering a re-login)
|
||||
# Use __class__ instead of direct TConnectApi invocation to avoid initializing a real TConnectApi over a fake
|
||||
tconnect = tconnect.__class__(self.secret.TCONNECT_EMAIL, self.secret.TCONNECT_PASSWORD)
|
||||
else:
|
||||
# Mark the last successful time we got data from tconnect
|
||||
self.last_successful_process_time_range = now
|
||||
|
||||
added, event_seqnum = ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, self.secret, features=features).process(time_start, time_end)
|
||||
logger.info('Added %d items from ProcessTimeRange' % added)
|
||||
self.last_successful_process_time_range = now
|
||||
|
||||
# Track the time it took to find a new event between runs,
|
||||
# but skip this calculation the first process cycle (since
|
||||
# we don't know at what exact point the event index changed)
|
||||
if self.last_event_index:
|
||||
self.time_diffs_between_updates.append(now - self.last_event_time)
|
||||
if self.last_event_seqnum:
|
||||
self.time_diffs_between_updates.append(now - self.last_max_date_with_events)
|
||||
logger.debug('Updating tracking of time since last update: %s' % self.time_diffs_between_updates)
|
||||
|
||||
# Mark the last event index uploaded from the pump and timestamp
|
||||
self.last_event_index = last_event['maxPumpEventIndex']
|
||||
self.last_event_time = now
|
||||
if event_seqnum:
|
||||
self.last_event_seqnum = event_seqnum
|
||||
self.last_event_time = now
|
||||
self.last_max_date_with_events = cur_max_date_with_events
|
||||
self.last_attempt_time = now
|
||||
self.time_diffs_between_attempts = []
|
||||
else:
|
||||
logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex'])
|
||||
logger.info('No new reported tandemsource data. cur_max_date: %s (%dm ago) last_event_time: %s (%dm ago)' % (
|
||||
arrow.get(cur_max_date_with_events) if cur_max_date_with_events else None,
|
||||
(now - cur_max_date_with_events)//60 if cur_max_date_with_events else None,
|
||||
arrow.get(self.last_event_time) if self.last_event_time else None,
|
||||
(now - self.last_event_time)//60 if self.last_event_time else None
|
||||
))
|
||||
|
||||
# If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
|
||||
# then trigger an error and potentially restart.
|
||||
@@ -109,7 +82,7 @@ class Autoupdate:
|
||||
if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES:
|
||||
logger.error(AutoupdateNoEventIndexesDetectedError(
|
||||
"%s: No new data event indexes have been detected for %d minutes. " % (datetime.datetime.now(), (now - self.last_event_time)//60) +
|
||||
"The t:connect app might no longer be functioning."))
|
||||
"New data might not be uploading."))
|
||||
|
||||
# TODO: restarting doesn't really help anything here.
|
||||
# Should we notify the user?
|
||||
@@ -125,7 +98,7 @@ class Autoupdate:
|
||||
# above no indexes warning.
|
||||
elif self.last_successful_process_time_range and (now - self.last_successful_process_time_range) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
|
||||
logger.error(AutoupdateNoNewDataDetectedError(
|
||||
"%s: No new data has been detected via the API for %d minutes. " % (datetime.datetime.now(), now - self.last_successful_process_time_range)//60 +
|
||||
"%s: No new data has been detected via the API for %d minutes (last: %s). " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60, self.last_successful_process_time_range) +
|
||||
"tconnectsync might not be functioning properly."))
|
||||
|
||||
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
@@ -140,12 +113,12 @@ class Autoupdate:
|
||||
|
||||
# If it's been 3 loops since the last time we found new data,
|
||||
# then we're not in sync with the rate at which pump data is being
|
||||
# uploaded, so
|
||||
# uploaded, so
|
||||
if len(self.time_diffs_between_attempts) >= 3:
|
||||
# The pump hasn't sent us data that, based on previous cadence, we were expecting
|
||||
logger.warning(AutoupdateNoIndexChangeWarning("Sleeping %d seconds after unexpected no index change based on previous cadence. (New data might be delayed.)" %
|
||||
logger.warning(AutoupdateNoIndexChangeWarning("Sleeping %d seconds after unexpected no index change based on previous cadence. (New data might be delayed.)" %
|
||||
int(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)))
|
||||
|
||||
|
||||
logger.debug("Last event time: %s, time diffs between attempts: %s" % (self.last_event_time, self.time_diffs_between_attempts))
|
||||
|
||||
time.sleep(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)
|
||||
@@ -167,7 +140,7 @@ class Autoupdate:
|
||||
if len(self.time_diffs_between_updates) > 10:
|
||||
self.time_diffs_between_updates = self.time_diffs_between_updates[1:]
|
||||
|
||||
# If we have less than 3 data points,
|
||||
# If we have less than 3 data points,
|
||||
if len(self.time_diffs_between_updates) > 2:
|
||||
sleep_secs = sum(self.time_diffs_between_updates) / len(self.time_diffs_between_updates)
|
||||
|
||||
@@ -204,4 +177,4 @@ class AutoupdateNoNewDataDetectedError(AutoupdateError):
|
||||
pass
|
||||
|
||||
class AutoupdateNoIndexChangeWarning(AutoupdateWarning):
|
||||
pass
|
||||
pass
|
||||
@@ -0,0 +1,62 @@
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ChooseDevice:
|
||||
def __init__(self, secret, tconnect):
|
||||
self.secret = secret
|
||||
self.tconnect = tconnect
|
||||
|
||||
def choose(self):
|
||||
tconnect = self.tconnect
|
||||
|
||||
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
|
||||
|
||||
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
|
||||
logger.info(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
|
||||
|
||||
tconnectDevice = None
|
||||
|
||||
if self.secret.PUMP_SERIAL_NUMBER and str(self.secret.PUMP_SERIAL_NUMBER) != '11111111':
|
||||
if not str(self.secret.PUMP_SERIAL_NUMBER) in serialNumberToPump.keys():
|
||||
raise InvalidSerialNumber(f'Serial number {self.secret.PUMP_SERIAL_NUMBER} is not present on your account: choose one of {", ".join(serialNumberToPump.keys())}')
|
||||
|
||||
tconnectDevice = serialNumberToPump[str(self.secret.PUMP_SERIAL_NUMBER)]
|
||||
|
||||
# Warn if pump is stale (no events in >3 days)
|
||||
try:
|
||||
max_event_date = arrow.get(tconnectDevice["maxDateWithEvents"])
|
||||
age_days = (arrow.utcnow() - max_event_date).days
|
||||
|
||||
if age_days > 3:
|
||||
logger.warning(
|
||||
f"The selected pump (serial {tconnectDevice['serialNumber']}) has no events in the last {age_days} days "
|
||||
f"(last seen: {tconnectDevice['maxDateWithEvents']}). "
|
||||
"You may have switched to a new pump. Consider removing or updating PUMP_SERIAL_NUMBER in your config."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse maxDateWithEvents to check for staleness: {e}")
|
||||
|
||||
logger.info(f'Using pump with serial: {tconnectDevice["serialNumber"]} (tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
|
||||
else:
|
||||
maxDateSeen = None
|
||||
for pump in pumpEventMetadata:
|
||||
if not tconnectDevice:
|
||||
tconnectDevice = pump
|
||||
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
|
||||
else:
|
||||
if arrow.get(pump['maxDateWithEvents']) > maxDateSeen:
|
||||
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
|
||||
tconnectDevice = pump
|
||||
|
||||
logger.info(f'Using most recent pump (serial: {tconnectDevice["serialNumber"]}, tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
|
||||
|
||||
|
||||
return tconnectDevice
|
||||
|
||||
|
||||
|
||||
class InvalidSerialNumber(RuntimeError):
|
||||
def __str__(self):
|
||||
return "%s: %s" % (self.__class__.__name__, super().__str__())
|
||||
@@ -0,0 +1,20 @@
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from .choose_device import ChooseDevice
|
||||
from .process import ProcessTimeRange
|
||||
from ...api import TConnectApi
|
||||
from ... import secret
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def fetch_oneshot(username, password, time_start=None, time_end=None, region='US'):
|
||||
tconnect = TConnectApi(username, password, region)
|
||||
if not time_start and not time_end:
|
||||
time_end = datetime.datetime.now()
|
||||
time_start = time_end - datetime.timedelta(days=1)
|
||||
|
||||
tconnectDevice = ChooseDevice(secret, tconnect).choose()
|
||||
return tconnect.tandemsource.pump_events(tconnectDevice['tconnectDeviceId'], time_start, time_end, fetch_all_event_types=secret.FETCH_ALL_EVENT_TYPES)
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
def insulin_float_round(amt):
|
||||
if type(amt) != float:
|
||||
return amt
|
||||
return round(amt, 2)
|
||||
|
||||
def insulin_milliunits_to_real(amtMilli):
|
||||
return insulin_float_round(amtMilli / 1000)
|
||||
@@ -0,0 +1,17 @@
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from .choose_device import ChooseDevice
|
||||
from .process import ProcessTimeRange
|
||||
from ... import secret
|
||||
|
||||
import datetime
|
||||
|
||||
def run_oneshot(tconnect, nightscout, pretend=False, features=DEFAULT_FEATURES, secret_arg=None, time_start=None, time_end=None):
|
||||
if not time_start and not time_end:
|
||||
time_end = datetime.datetime.now()
|
||||
time_start = time_end - datetime.timedelta(days=1)
|
||||
|
||||
if not secret_arg:
|
||||
secret_arg = secret
|
||||
|
||||
tconnectDevice = ChooseDevice(secret_arg, tconnect).choose()
|
||||
return ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, secret_arg, features).process(time_start, time_end)
|
||||
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import collections
|
||||
|
||||
from ...features import DEVICE_STATUS, DEFAULT_FEATURES
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from .process_basal import ProcessBasal
|
||||
from .process_basal_suspension import ProcessBasalSuspension
|
||||
from .process_basal_resume import ProcessBasalResume
|
||||
from .process_alarm import ProcessAlarm
|
||||
from .process_bolus import ProcessBolus
|
||||
from .process_cartridge import ProcessCartridge
|
||||
from .process_cgm_alert import ProcessCGMAlert
|
||||
from .process_cgm_start_join_stop import ProcessCGMStartJoinStop
|
||||
from .process_cgm_reading import ProcessCGMReading
|
||||
from .process_device_status import ProcessDeviceStatus
|
||||
from .process_user_mode import ProcessUserMode
|
||||
from .update_profiles import UpdateProfiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessTimeRange:
|
||||
def __init__(self, tconnect, nightscout, tconnectDevice, pretend, secret, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnectDevice['tconnectDeviceId']
|
||||
self.max_date_with_events = tconnectDevice['maxDateWithEvents']
|
||||
self.pretend = pretend
|
||||
self.secret = secret
|
||||
self.features = features
|
||||
|
||||
event_classes = {
|
||||
EventClass.BASAL.name: ProcessBasal,
|
||||
EventClass.BASAL_SUSPENSION.name: ProcessBasalSuspension,
|
||||
EventClass.BASAL_RESUME.name: ProcessBasalResume,
|
||||
EventClass.ALARM.name: ProcessAlarm,
|
||||
EventClass.BOLUS.name: ProcessBolus,
|
||||
EventClass.CARTRIDGE.name: ProcessCartridge,
|
||||
EventClass.CGM_ALERT.name: ProcessCGMAlert,
|
||||
EventClass.CGM_START_JOIN_STOP.name: ProcessCGMStartJoinStop,
|
||||
EventClass.CGM_READING.name: ProcessCGMReading,
|
||||
EventClass.USER_MODE.name: ProcessUserMode,
|
||||
EventClass.DEVICE_STATUS.name: ProcessDeviceStatus
|
||||
}
|
||||
|
||||
updater_classes = [
|
||||
UpdateProfiles
|
||||
]
|
||||
|
||||
def process(self, time_start, time_end):
|
||||
fetch_all_event_types = self.secret.FETCH_ALL_EVENT_TYPES or DEVICE_STATUS in self.features
|
||||
|
||||
logger.info(f"ProcessTimeRange time_start={time_start} time_end={time_end} tconnect_device_id={self.tconnect_device_id} features={self.features} fetch_all_event_types={fetch_all_event_types}")
|
||||
events = self.tconnect.tandemsource.pump_events(self.tconnect_device_id, time_start, time_end, fetch_all_event_types=fetch_all_event_types)
|
||||
|
||||
events_first_time = None
|
||||
events_last_time = None
|
||||
last_event_seqnum = None
|
||||
for_eventclass = collections.defaultdict(list)
|
||||
for event in events:
|
||||
if not events_first_time:
|
||||
events_first_time = event.eventTimestamp
|
||||
if not events_last_time:
|
||||
events_last_time = event.eventTimestamp
|
||||
if not last_event_seqnum:
|
||||
last_event_seqnum = event.seqNum
|
||||
events_first_time = min(events_first_time, event.eventTimestamp)
|
||||
events_last_time = max(events_last_time, event.eventTimestamp)
|
||||
last_event_seqnum = max(event.seqNum, last_event_seqnum)
|
||||
|
||||
clazz = EventClass.for_event(event)
|
||||
if clazz:
|
||||
for_eventclass[clazz.name].append(event)
|
||||
|
||||
count_by_eventclass = {k: len(v) for k,v in for_eventclass.items()}
|
||||
logger.info(f"Found events: {count_by_eventclass}")
|
||||
|
||||
processed_count = 0
|
||||
for clazz, events in for_eventclass.items():
|
||||
if clazz in self.event_classes.keys():
|
||||
c = self.event_classes[clazz](self.tconnect, self.nightscout, self.tconnect_device_id, self.pretend, self.features)
|
||||
if c.enabled():
|
||||
logger.info("%s is enabled from features %s" % (clazz, self.features))
|
||||
ns_entries = c.process(events, events_first_time, events_last_time)
|
||||
w = c.write(ns_entries)
|
||||
if w:
|
||||
processed_count += w
|
||||
else:
|
||||
logger.info("Skipping %s, is not enabled from features %s" % (clazz, self.features))
|
||||
|
||||
for updater_class in self.updater_classes:
|
||||
c = updater_class(self.tconnect, self.nightscout, self.tconnect_device_id, self.pretend, self.features)
|
||||
if c.enabled():
|
||||
logger.info("%s is enabled from features %s" % (updater_class.__name__, self.features))
|
||||
done = c.update(self.pretend)
|
||||
logger.info("%s completed with update required: %s" % (updater_class.__name__, done))
|
||||
else:
|
||||
logger.info("Skipping %s, is not enabled from features %s" % (updater_class.__name__, self.features))
|
||||
|
||||
logger.info("Processed %d events. Last event ID seen: %d" % (processed_count if processed_count else 0, last_event_seqnum if last_event_seqnum else -1))
|
||||
return processed_count, last_event_seqnum
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
ALARM_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessAlarm:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.PUMP_EVENTS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessAlarm: querying for last uploaded alarm")
|
||||
last_upload = self.nightscout.last_uploaded_entry(ALARM_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout alarm upload: %s" % last_upload_time)
|
||||
|
||||
ns_entries = []
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("Skipping Alarm event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
if self.skip_event(event):
|
||||
continue
|
||||
|
||||
ns_entries.append(self.alarm_to_nsentry(event))
|
||||
|
||||
|
||||
return ns_entries
|
||||
|
||||
def skip_event(self, event):
|
||||
return event.alarmid in (
|
||||
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm,
|
||||
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm2
|
||||
)
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def alarm_to_nsentry(self, event):
|
||||
if type(event) == eventtypes.LidAlarmActivated:
|
||||
return NightscoutEntry.alarm(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = "%s" % event.alarmid.name,
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
elif type(event) == eventtypes.LidMalfunctionActivated:
|
||||
return NightscoutEntry.alarm(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = "Malfunction",
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...secret import IGNORE_ZERO_UNIT_BASAL
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from .helpers import insulin_float_round, insulin_milliunits_to_real
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
BASAL_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessBasal:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.BASAL in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessBasal: querying for last uploaded entry")
|
||||
last_upload = self.nightscout.last_uploaded_entry(BASAL_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
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)
|
||||
|
||||
with_duration = []
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("Skipping basal event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
with_duration.append([event.eventTimestamp, None, event])
|
||||
|
||||
if not with_duration:
|
||||
logger.info("No basal events found to process")
|
||||
return []
|
||||
|
||||
for i in range(len(with_duration)-1):
|
||||
with_duration[i][1] = with_duration[i+1][0] - with_duration[i][0]
|
||||
|
||||
with_duration[-1][1] = time_end - with_duration[-1][0]
|
||||
|
||||
ns_entries = []
|
||||
for item in with_duration:
|
||||
ns = self.basal_to_nsentry(*item)
|
||||
if ns:
|
||||
ns_entries.append(ns)
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def basal_to_nsentry(self, start, duration, event):
|
||||
if type(event) == eventtypes.LidBasalRateChange:
|
||||
value = insulin_float_round(event.commandedbasalrate)
|
||||
if IGNORE_ZERO_UNIT_BASAL and value < 0.01:
|
||||
logger.info("Ignoring basal entry with %.2f unit basal because IGNORE_ZERO_UNIT_BASAL=true: %s" % (value, event))
|
||||
return None
|
||||
return NightscoutEntry.basal(
|
||||
value = value,
|
||||
duration_mins = duration.seconds / 60,
|
||||
created_at = start.format(),
|
||||
reason = ', '.join(bitmask_to_list(event.changetype)),
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
if type(event) == eventtypes.LidBasalDelivery:
|
||||
value = insulin_milliunits_to_real(event.commandedRate)
|
||||
if IGNORE_ZERO_UNIT_BASAL and value < 0.01:
|
||||
logger.info("Ignoring basal entry with %.2f unit basal because IGNORE_ZERO_UNIT_BASAL=true: %s" % (value, event))
|
||||
return None
|
||||
return NightscoutEntry.basal(
|
||||
value = value,
|
||||
duration_mins = duration.seconds / 60,
|
||||
created_at = start.format(),
|
||||
reason = ', '.join(bitmask_to_list(event.commandedRateSource)),
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
BASALRESUME_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessBasalResume:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.PUMP_EVENTS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessBasalResume: querying for last uploaded resume-suspension")
|
||||
last_upload = self.nightscout.last_uploaded_entry(BASALRESUME_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout BasalResume upload: %s" % last_upload_time)
|
||||
|
||||
ns_entries = []
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("Skipping BasalResume event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
ns_entries.append(self.resume_to_nsentry(event))
|
||||
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def resume_to_nsentry(self, event):
|
||||
if type(event) == eventtypes.LidPumpingResumed:
|
||||
return NightscoutEntry.basalresume(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessBasalSuspension:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.PUMP_EVENTS in self.features or features.BASAL in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessBasalSuspension: querying for last uploaded suspension")
|
||||
last_upload = self.nightscout.last_uploaded_entry(BASALSUSPENSION_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout basalsuspension upload: %s" % last_upload_time)
|
||||
|
||||
ns_entries = []
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("Skipping basalsuspension event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
ns_entries.append(self.suspension_to_nsentry(event))
|
||||
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def suspension_to_nsentry(self, event):
|
||||
if type(event) == eventtypes.LidPumpingSuspended:
|
||||
return NightscoutEntry.basalsuspension(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = ', '.join(bitmask_to_list(event.suspendreason)),
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from .helpers import insulin_float_round
|
||||
from ...parser.nightscout import (
|
||||
BOLUS_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessBolus:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.BOLUS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessBolus: querying for last uploaded entry")
|
||||
last_upload = self.nightscout.last_uploaded_entry(BOLUS_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
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)
|
||||
|
||||
# TODO EXTENDED BOLUSES
|
||||
bolusCompletedEvents = []
|
||||
bolusEventsForId = {}
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if event.bolusid not in bolusEventsForId.keys():
|
||||
bolusEventsForId[event.bolusid] = {}
|
||||
|
||||
bolusEventsForId[event.bolusid][type(event)] = event
|
||||
|
||||
if type(event) == eventtypes.LidBolusCompleted:
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("Skipping bolusCompletedEvent not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
bolusCompletedEvents.append(event)
|
||||
|
||||
bolusCompletedEvents.sort(key=lambda e: e.eventTimestamp)
|
||||
|
||||
|
||||
|
||||
ns_entries = []
|
||||
for bolusCompleted in bolusCompletedEvents:
|
||||
m = bolusEventsForId[bolusCompleted.bolusid]
|
||||
|
||||
ns_entries.append(self.bolus_to_nsentry(
|
||||
bolusCompleted,
|
||||
bolusRequested1 = m.get(eventtypes.LidBolusRequestedMsg1),
|
||||
bolusRequested2 = m.get(eventtypes.LidBolusRequestedMsg2),
|
||||
bolusRequested3 = m.get(eventtypes.LidBolusRequestedMsg3),
|
||||
))
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def bolus_to_nsentry(self, bolusCompleted, bolusRequested1, bolusRequested2, bolusRequested3):
|
||||
suffixes = []
|
||||
if bolusRequested2 and bolusRequested2.useroverride == eventtypes.LidBolusRequestedMsg2.UseroverrideEnum.Yes:
|
||||
suffixes.append('(Override)')
|
||||
|
||||
if bolusRequested2 and bolusRequested2.declinedcorrection == eventtypes.LidBolusRequestedMsg2.DeclinedcorrectionEnum.Yes:
|
||||
suffixes.append('(Declined Correction)')
|
||||
|
||||
suffix = (' ' + (' '.join(suffixes))) if suffixes else ''
|
||||
|
||||
seq_nums = []
|
||||
for e in [bolusCompleted, bolusRequested1, bolusRequested2, bolusRequested3]:
|
||||
if e:
|
||||
seq_nums.append(str(e.seqNum))
|
||||
|
||||
notes = ''
|
||||
if bolusRequested2 and str(bolusRequested2.optionsRaw) in eventtypes.LidBolusRequestedMsg2.OptionsMap:
|
||||
notes = eventtypes.LidBolusRequestedMsg2.OptionsMap['%d' % bolusRequested2.optionsRaw]
|
||||
|
||||
|
||||
return NightscoutEntry.bolus(
|
||||
bolus = insulin_float_round(bolusCompleted.insulindelivered),
|
||||
carbs = bolusRequested1.carbamount if bolusRequested1 and bolusRequested1.carbamount>0 else None,
|
||||
created_at = bolusCompleted.eventTimestamp.format(),
|
||||
notes = notes + suffix,
|
||||
bg = bolusRequested1.BG if bolusRequested1 and bolusRequested1.BG > 0 else None,
|
||||
pump_event_id = ",".join(seq_nums)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
SITECHANGE_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessCartridge:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.PUMP_EVENTS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessCartridge: querying for last uploaded entry")
|
||||
last_upload = self.nightscout.last_uploaded_entry(SITECHANGE_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout sitechange upload: %s" % last_upload_time)
|
||||
|
||||
cartFilledEvents = []
|
||||
cannulaFilledEvents = []
|
||||
tubingFilledEvents = []
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
|
||||
continue
|
||||
|
||||
if type(event) == eventtypes.LidCartridgeFilled:
|
||||
cartFilledEvents.append(event)
|
||||
elif type(event) == eventtypes.LidCannulaFilled:
|
||||
cannulaFilledEvents.append(event)
|
||||
elif type(event) == eventtypes.LidTubingFilled:
|
||||
tubingFilledEvents.append(event)
|
||||
|
||||
cartFilledEvents.sort(key=lambda e: e.eventTimestamp)
|
||||
cannulaFilledEvents.sort(key=lambda e: e.eventTimestamp)
|
||||
tubingFilledEvents.sort(key=lambda e: e.eventTimestamp)
|
||||
|
||||
ns_entries = []
|
||||
for cartFilled in cartFilledEvents:
|
||||
ns_entries.append(self.cart_to_nsentry(cartFilled))
|
||||
|
||||
for cannulaFilled in cannulaFilledEvents:
|
||||
ns_entries.append(self.cannula_to_nsentry(cannulaFilled))
|
||||
|
||||
for tubingFilled in tubingFilledEvents:
|
||||
ns_entries.append(self.tubing_to_nsentry(tubingFilled))
|
||||
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def cart_to_nsentry(self, cartFilled):
|
||||
return NightscoutEntry.sitechange(
|
||||
created_at = cartFilled.eventTimestamp.format(),
|
||||
reason = "Cartridge Filled" + (" (%du filled)" % round(cartFilled.v2Volume) if cartFilled.v2Volume else ""),
|
||||
pump_event_id = "%s" % cartFilled.seqNum
|
||||
)
|
||||
|
||||
def cannula_to_nsentry(self, cannulaFilled):
|
||||
return NightscoutEntry.sitechange(
|
||||
created_at = cannulaFilled.eventTimestamp.format(),
|
||||
reason = "Cannula Filled" + (" (%du primed)" % round(cannulaFilled.primesize, 2) if cannulaFilled.primesize else ""),
|
||||
pump_event_id = "%s" % cannulaFilled.seqNum
|
||||
)
|
||||
|
||||
def tubing_to_nsentry(self, tubingFilled):
|
||||
return NightscoutEntry.sitechange(
|
||||
created_at = tubingFilled.eventTimestamp.format(),
|
||||
reason = "Tubing Filled" + (" (%du primed)" % round(tubingFilled.primesize) if tubingFilled.primesize else ""),
|
||||
pump_event_id = "%s" % tubingFilled.seqNum
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
CGM_ALERT_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessCGMAlert:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.CGM_ALERTS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessCGMAlert: querying for last uploaded entry")
|
||||
last_upload = self.nightscout.last_uploaded_entry(CGM_ALERT_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout cgmalert upload: %s" % last_upload_time)
|
||||
|
||||
alertEvents = []
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
|
||||
continue
|
||||
|
||||
alertEvents.append(event)
|
||||
|
||||
alertEvents.sort(key=lambda e: e.eventTimestamp)
|
||||
|
||||
ns_entries = []
|
||||
for event in alertEvents:
|
||||
e = self.alert_to_nsentry(event)
|
||||
if e:
|
||||
ns_entries.append(e)
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def alert_to_nsentry(self, alert):
|
||||
if not alert.dalertid:
|
||||
logger.info("ProcessCGMAlert: Skipping alert with unknown dalertid %d: %s" % (alert.dalertidRaw, alert))
|
||||
return None
|
||||
|
||||
if type(alert) == eventtypes.LidCgmAlertActivated:
|
||||
return NightscoutEntry.cgm_alert(
|
||||
created_at = alert.eventTimestamp.format(),
|
||||
reason = ("CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "CGM Alert (Unknown)",
|
||||
pump_event_id = "%s" % alert.seqNum
|
||||
)
|
||||
elif type(alert) == eventtypes.LidCgmAlertActivatedDex:
|
||||
if alert.dalertid == eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmOutOfRange:
|
||||
logger.info("ProcessCGMAlert: Skipping alert with CgmOutOfRange dalertid %d: %s" % (alert.dalertidRaw, alert))
|
||||
return None
|
||||
return NightscoutEntry.cgm_alert(
|
||||
created_at = alert.eventTimestamp.format(),
|
||||
reason = ("Dexcom CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "Dexcom CGM Alert (Unknown)",
|
||||
pump_event_id = "%s" % alert.seqNum
|
||||
)
|
||||
elif type(alert) == eventtypes.LidCgmAlertActivatedFsl2:
|
||||
return NightscoutEntry.cgm_alert(
|
||||
created_at = alert.eventTimestamp.format(),
|
||||
reason = ("Libre CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "Libre CGM Alert (Unknown)",
|
||||
pump_event_id = "%s" % alert.seqNum
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser.raw_event import TANDEM_EPOCH
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
CGM_START_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessCGMReading:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.CGM in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessCGMReading: querying for last uploaded entry")
|
||||
last_upload = self.nightscout.last_uploaded_bg_entry(time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload and "dateString" in last_upload:
|
||||
last_upload_time = arrow.get(last_upload["dateString"])
|
||||
elif last_upload and "date" in last_upload:
|
||||
last_upload_time = arrow.get(last_upload["date"])
|
||||
logger.info("ProcessCGMReading: Last Nightscout bg upload: %s" % last_upload_time)
|
||||
|
||||
readings = []
|
||||
for event in sorted(events, key=lambda x: self.timestamp_for(x)):
|
||||
if last_upload_time and self.timestamp_for(event) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("ProcessCGMReading: Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
|
||||
continue
|
||||
|
||||
readings.append(event)
|
||||
|
||||
ns_entries = []
|
||||
for event in readings:
|
||||
ns_entries.append(self.to_nsentry(event))
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry, entity='entries')
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def timestamp_for(self, event):
|
||||
# For backfills the time the event was added to the pump's event store
|
||||
# might not be the time it actually occurred, so we use the egvTimestamp
|
||||
return arrow.get(TANDEM_EPOCH + event.egvTimestamp)
|
||||
|
||||
def to_nsentry(self, event):
|
||||
return NightscoutEntry.entry(
|
||||
sgv = event.currentglucosedisplayvalue,
|
||||
created_at = self.timestamp_for(event).format(),
|
||||
pump_event_id = "%s" % event.seqNum,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
CGM_START_EVENTTYPE,
|
||||
CGM_JOIN_EVENTTYPE,
|
||||
CGM_STOP_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessCGMStartJoinStop:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.PUMP_EVENTS in self.features or features.CGM_ALERTS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
last_upload = None
|
||||
last_upload_time = None
|
||||
for eventtype in [CGM_START_EVENTTYPE, CGM_JOIN_EVENTTYPE, CGM_STOP_EVENTTYPE]:
|
||||
logger.debug("ProcessCGMStartJoinStop: querying for last uploaded entry for %s" % eventtype)
|
||||
_last_upload = self.nightscout.last_uploaded_entry(eventtype, time_start=time_start, time_end=time_end)
|
||||
_last_upload_time = None
|
||||
if _last_upload:
|
||||
_last_upload_time = arrow.get(_last_upload["created_at"])
|
||||
|
||||
if not last_upload_time:
|
||||
last_upload = _last_upload
|
||||
last_upload_time = _last_upload_time
|
||||
elif _last_upload_time > last_upload_time:
|
||||
last_upload = _last_upload
|
||||
last_upload_time = _last_upload_time
|
||||
logger.info("ProcessCGMStartJoinStop: Last Nightscout %s upload: %s" % (eventtype, _last_upload_time))
|
||||
logger.info("ProcessCGMStartJoinStop: Overall last Nightscout upload: %s %s" % (last_upload_time, last_upload))
|
||||
|
||||
allEvents = []
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("ProcessCGMStartJoinStop: Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
|
||||
continue
|
||||
|
||||
allEvents.append(event)
|
||||
|
||||
allEvents.sort(key=lambda e: e.eventTimestamp)
|
||||
|
||||
ns_entries = []
|
||||
for event in allEvents:
|
||||
ns_entries.append(self.to_nsentry(event))
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def to_nsentry(self, event):
|
||||
if type(event) in EventClass._CGM_START:
|
||||
return NightscoutEntry.cgm_start(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = "CGM Session Started",
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
elif type(event) in EventClass._CGM_JOIN:
|
||||
return NightscoutEntry.cgm_join(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = "CGM Session Joined",
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
elif type(event) in EventClass._CGM_STOP:
|
||||
return NightscoutEntry.cgm_stop(
|
||||
created_at = event.eventTimestamp.format(),
|
||||
reason = "CGM Session Stopped",
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
EXERCISE_EVENTTYPE,
|
||||
SLEEP_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessDeviceStatus:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.DEVICE_STATUS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessDeviceStatus: querying for last uploaded devicestatus")
|
||||
last_upload = self.nightscout.last_uploaded_devicestatus(time_start=time_start, time_end=time_end)
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("ProcessDeviceStatus: Last Nightscout devicestatus upload: %s" % last_upload_time)
|
||||
|
||||
|
||||
last_daily_basal_event = None
|
||||
for event in sorted(events, key=lambda x: x.raw.timestamp):
|
||||
if last_upload_time and event.raw.timestamp <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("ProcessDeviceStatus: Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
|
||||
continue
|
||||
|
||||
if isinstance(event, eventtypes.LidDailyBasal):
|
||||
last_daily_basal_event = event
|
||||
|
||||
|
||||
if not last_daily_basal_event:
|
||||
logger.info("ProcessDeviceStatus: No last_daily_basal_event found for add (time range: %s - %s)" % (time_start, time_end))
|
||||
return []
|
||||
|
||||
logger.info("ProcessDeviceStatus: last_daily_basal_event=%s" % (last_daily_basal_event))
|
||||
|
||||
ns_entries = []
|
||||
ns_entries.append(self.daily_basal_to_nsentry(last_daily_basal_event))
|
||||
return ns_entries
|
||||
|
||||
def daily_basal_to_nsentry(self, event):
|
||||
return NightscoutEntry.devicestatus(
|
||||
created_at=event.eventTimestamp.format(),
|
||||
batteryVoltage=(float(event.batterylipomillivolts or 0)/1000),
|
||||
batteryPercent=int(100*event.batteryChargePercent),
|
||||
pump_event_id = "%s" % event.seqNum
|
||||
)
|
||||
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload devicestatus to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading devicestatus to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry, entity='devicestatus')
|
||||
count += 1
|
||||
|
||||
return count
|
||||
@@ -0,0 +1,244 @@
|
||||
import logging
|
||||
import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser import events as eventtypes
|
||||
from ...domain.tandemsource.event_class import EventClass
|
||||
from ...parser.nightscout import (
|
||||
EXERCISE_EVENTTYPE,
|
||||
SLEEP_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
|
||||
NOT_ENDED = "Not Ended"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessUserMode:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.PUMP_EVENTS in self.features
|
||||
|
||||
def process(self, events, time_start, time_end):
|
||||
logger.debug("ProcessUserMode: querying for last uploaded exercise entry")
|
||||
exercise_last_upload = self.nightscout.last_uploaded_entry(EXERCISE_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
exercise_last_upload_time = None
|
||||
if exercise_last_upload:
|
||||
exercise_last_upload_time = arrow.get(exercise_last_upload["created_at"])
|
||||
logger.info("ProcessUserMode: Last Nightscout exercise upload: %s" % exercise_last_upload_time)
|
||||
|
||||
exercise_not_ended = False
|
||||
if exercise_last_upload and NOT_ENDED in exercise_last_upload.get("reason", ""):
|
||||
exercise_not_ended = True
|
||||
logger.info("ProcessUserMode: Last exercise not ended: %s" % exercise_last_upload)
|
||||
|
||||
|
||||
logger.debug("ProcessUserMode: querying for last uploaded sleep entry")
|
||||
sleep_last_upload = self.nightscout.last_uploaded_entry(SLEEP_EVENTTYPE, time_start=time_start, time_end=time_end)
|
||||
sleep_last_upload_time = None
|
||||
if sleep_last_upload:
|
||||
sleep_last_upload_time = arrow.get(sleep_last_upload["created_at"])
|
||||
logger.info("ProcessUserMode: Last Nightscout sleep upload: %s" % sleep_last_upload_time)
|
||||
|
||||
sleep_not_ended = False
|
||||
if sleep_last_upload and NOT_ENDED in sleep_last_upload.get("reason", ""):
|
||||
sleep_not_ended = True
|
||||
logger.info("ProcessUserMode: Last sleep not ended: %s" % sleep_last_upload)
|
||||
|
||||
last_upload_time = None
|
||||
if exercise_last_upload_time and sleep_last_upload_time:
|
||||
last_upload_time = max(exercise_last_upload_time, sleep_last_upload_time)
|
||||
elif exercise_last_upload_time:
|
||||
last_upload_time = exercise_last_upload_time
|
||||
elif sleep_last_upload_time:
|
||||
last_upload_time = sleep_last_upload_time
|
||||
|
||||
logger.info("ProcessUserMode: Last Nightscout usermode upload: %s" % last_upload_time)
|
||||
|
||||
|
||||
ns_entries = []
|
||||
|
||||
processed_sleep = []
|
||||
processed_exercise = []
|
||||
start_sleep = None
|
||||
start_exercise = None
|
||||
for event in sorted(events, key=lambda x: x.eventTimestamp):
|
||||
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
|
||||
if self.pretend:
|
||||
logger.info("ProcessUserMode: Skipping usermode event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
if self.is_start_sleep(event):
|
||||
start_sleep = event
|
||||
elif self.is_stop_sleep(event):
|
||||
if start_sleep:
|
||||
processed_sleep.append((start_sleep, event))
|
||||
start_sleep = None
|
||||
else:
|
||||
if sleep_not_ended:
|
||||
logger.info("ProcessUserMode: Found StopSleep without StartSleep, with incomplete sleep event in nightscout: %s NS: %s" % (event, sleep_last_upload))
|
||||
ns_entries.append(self.process_unended_sleep_stop(event, sleep_last_upload))
|
||||
else:
|
||||
logger.warning("ProcessUserMode: Found StopSleep without StartSleep, and no active sleep event in nightscout: %s" % event)
|
||||
elif self.is_start_exercise(event):
|
||||
start_exercise = event
|
||||
elif self.is_stop_exercise(event):
|
||||
if start_exercise:
|
||||
processed_exercise.append((start_exercise, event))
|
||||
start_exercise = None
|
||||
else:
|
||||
if exercise_not_ended:
|
||||
logger.info("ProcessUserMode: Found StopExercise without StartExercise, with incomplete exercise event in nightscout: %s NS: %s" % (event, exercise_last_upload))
|
||||
ns_entries.append(self.process_unended_exercise_stop(event, exercise_last_upload))
|
||||
else:
|
||||
logger.warning("ProcessUserMode: Found StopExercise without StartExercise, and no active exercise event in nightscout: %s" % event)
|
||||
else:
|
||||
logger.warning("ProcessUserMode: not sure how to process event: %s" % event)
|
||||
|
||||
if start_sleep:
|
||||
processed_sleep.append((start_sleep, None))
|
||||
logger.info("ProcessUserMode: sleep is active")
|
||||
if start_exercise:
|
||||
processed_exercise.append((start_exercise, None))
|
||||
logger.info("ProcessUserMode: exercise is active")
|
||||
|
||||
for items in processed_sleep:
|
||||
ns_entries.append(self.sleep_to_nsentry(start=items[0], stop=items[1], time_end=time_end))
|
||||
|
||||
for items in processed_exercise:
|
||||
ns_entries.append(self.exercise_to_nsentry(start=items[0], stop=items[1], time_end=time_end))
|
||||
|
||||
return ns_entries
|
||||
|
||||
def write(self, ns_entries):
|
||||
count = 0
|
||||
for entry in ns_entries:
|
||||
if self.pretend:
|
||||
logger.info("Would upload to Nightscout: %s" % entry)
|
||||
else:
|
||||
logger.info("Uploading to Nightscout: %s" % entry)
|
||||
self.nightscout.upload_entry(entry)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def is_start_sleep(self, event):
|
||||
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep
|
||||
def is_stop_sleep(self, event):
|
||||
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep or \
|
||||
event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
|
||||
def is_start_exercise(self, event):
|
||||
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise
|
||||
def is_stop_exercise(self, event):
|
||||
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise or \
|
||||
event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
|
||||
|
||||
|
||||
def sleep_to_nsentry(self, start, stop=None, time_end=None):
|
||||
if start and stop:
|
||||
reason = None
|
||||
if start.sleepstartedbygui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
|
||||
reason = "Sleep (Manual)"
|
||||
elif start.activesleepschedule:
|
||||
reason = "Sleep (Scheduled)"
|
||||
|
||||
duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60
|
||||
return NightscoutEntry.activity(
|
||||
created_at=start.eventTimestamp.format(),
|
||||
reason=reason,
|
||||
duration=duration_mins,
|
||||
event_type=SLEEP_EVENTTYPE,
|
||||
pump_event_id = "%s,%s" % (start.seqNum, stop.seqNum)
|
||||
)
|
||||
elif start:
|
||||
reason = None
|
||||
if start.sleepstartedbygui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
|
||||
reason = "Sleep (Manual)"
|
||||
elif start.activesleepscheduleRaw:
|
||||
reason = "Sleep (Scheduled)"
|
||||
|
||||
duration_mins = (time_end - start.eventTimestamp).seconds / 60
|
||||
return NightscoutEntry.activity(
|
||||
created_at=start.eventTimestamp.format(),
|
||||
reason=reason + " - " + NOT_ENDED if reason else NOT_ENDED,
|
||||
duration=duration_mins,
|
||||
event_type=SLEEP_EVENTTYPE,
|
||||
pump_event_id = "%s" % start.seqNum
|
||||
)
|
||||
|
||||
|
||||
def exercise_to_nsentry(self, start, stop=None, time_end=None):
|
||||
if start and stop:
|
||||
reason = "Exercise"
|
||||
if start.exercisechoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
|
||||
reason = "Exercise (Timed)"
|
||||
|
||||
if stop.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
|
||||
reason += " (Stopped by timer)"
|
||||
|
||||
duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60
|
||||
return NightscoutEntry.activity(
|
||||
created_at=start.eventTimestamp.format(),
|
||||
reason=reason,
|
||||
duration=duration_mins,
|
||||
event_type=EXERCISE_EVENTTYPE,
|
||||
pump_event_id = "%s,%s" % (start.seqNum, stop.seqNum)
|
||||
)
|
||||
elif start:
|
||||
reason = "Exercise"
|
||||
if start.exercisechoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
|
||||
reason = "Exercise (Timed)"
|
||||
|
||||
duration_mins = (time_end - start.eventTimestamp).seconds / 60
|
||||
return NightscoutEntry.activity(
|
||||
created_at=start.eventTimestamp.format(),
|
||||
reason=reason + " - " + NOT_ENDED,
|
||||
duration=duration_mins,
|
||||
event_type=EXERCISE_EVENTTYPE,
|
||||
pump_event_id = "%s" % start.seqNum
|
||||
)
|
||||
|
||||
def process_unended_sleep_stop(self, event, sleep_last_upload):
|
||||
logger.info("ProcessUserMode: Deleting old sleep event treatment before pushing update (delete treatments/%s)" % sleep_last_upload["_id"])
|
||||
if self.pretend:
|
||||
logger.info("ProcessUserMode: Skipping delete in pretend mode")
|
||||
else:
|
||||
self.nightscout.delete_entry('treatments/%s' % sleep_last_upload["_id"])
|
||||
|
||||
duration_mins = (event.eventTimestamp - arrow.get(sleep_last_upload["created_at"])).seconds / 60
|
||||
return NightscoutEntry.activity(
|
||||
created_at=sleep_last_upload["created_at"],
|
||||
reason=sleep_last_upload["reason"].replace(" - %s" % NOT_ENDED, ""),
|
||||
duration=duration_mins,
|
||||
event_type=SLEEP_EVENTTYPE,
|
||||
pump_event_id="%s,%s" % (sleep_last_upload.get("pump_event_id",""), event.seqNum)
|
||||
)
|
||||
|
||||
def process_unended_exercise_stop(self, event, exercise_last_upload):
|
||||
logger.info("ProcessUserMode: Deleting old exercise event treatment before pushing update (delete treatments/%s)" % exercise_last_upload["_id"])
|
||||
if self.pretend:
|
||||
logger.info("ProcessUserMode: Skipping delete in pretend mode")
|
||||
else:
|
||||
self.nightscout.delete_entry('treatments/%s' % exercise_last_upload["_id"])
|
||||
|
||||
reason = exercise_last_upload["reason"].replace(" - %s" % NOT_ENDED, "")
|
||||
if event.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
|
||||
reason += " (Stopped by timer)"
|
||||
|
||||
duration_mins = (event.eventTimestamp - arrow.get(exercise_last_upload["created_at"])).seconds / 60
|
||||
return NightscoutEntry.activity(
|
||||
created_at=exercise_last_upload["created_at"],
|
||||
reason=reason,
|
||||
duration=duration_mins,
|
||||
event_type=EXERCISE_EVENTTYPE,
|
||||
pump_event_id="%s,%s" % (exercise_last_upload.get("pump_event_id",""), event.seqNum)
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
import logging
|
||||
import arrow
|
||||
import copy
|
||||
import json
|
||||
from typing import Tuple
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ...domain.tandemsource.pump_settings import PumpSettings
|
||||
from ...parser.nightscout import (
|
||||
NightscoutEntry, ENTERED_BY
|
||||
)
|
||||
from ...secret import NIGHTSCOUT_PROFILE_UPLOAD_MODE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_default_upload_mode():
|
||||
return NIGHTSCOUT_PROFILE_UPLOAD_MODE
|
||||
|
||||
class UpdateProfiles:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
|
||||
def enabled(self):
|
||||
return features.PROFILES in self.features
|
||||
|
||||
def update(self, pretend):
|
||||
upload_mode = _get_default_upload_mode()
|
||||
logger.debug("UpdateProfiles: getting Tandem Source profile data")
|
||||
|
||||
all_metadata = self.tconnect.tandemsource.pump_event_metadata()
|
||||
pump_meta = None
|
||||
for m in all_metadata:
|
||||
if m['tconnectDeviceId'] == self.tconnect_device_id:
|
||||
pump_meta = m
|
||||
|
||||
if not pump_meta:
|
||||
return False
|
||||
|
||||
raw_settings = pump_meta.get("lastUpload", {}).get("settings")
|
||||
if not raw_settings:
|
||||
return False
|
||||
|
||||
pump_settings = PumpSettings.from_dict(raw_settings)
|
||||
logger.info("Current pump settings: %s" % pump_settings)
|
||||
|
||||
ns_profile_obj = self.nightscout.current_profile()
|
||||
logger.debug("Current Nightscout profile: %s" % ns_profile_obj)
|
||||
if ns_profile_obj is None:
|
||||
ns_profile_obj = {}
|
||||
|
||||
logger.info("Current Nightscout profile was authored by: %s" % (ns_profile_obj.get('enteredBy')))
|
||||
|
||||
diff, ns_profile_new = self.compare_profiles(pump_settings, ns_profile_obj)
|
||||
if not diff:
|
||||
logger.info("Pump and Nightscout profiles up to date")
|
||||
return False
|
||||
|
||||
if upload_mode == 'add':
|
||||
profile_to_upload = self.setup_new_profile(ns_profile_new)
|
||||
logger.info("Adding new Nightscout profiles object: %s", profile_to_upload)
|
||||
|
||||
if not pretend:
|
||||
self.nightscout.upload_entry(profile_to_upload, entity='profile')
|
||||
return True
|
||||
|
||||
elif upload_mode == 'replace':
|
||||
logger.info("Replacing new Nightscout profiles object: %s", ns_profile_new)
|
||||
|
||||
if not pretend:
|
||||
self.nightscout.put_entry(ns_profile_new, entity='profile')
|
||||
return True
|
||||
|
||||
else:
|
||||
raise RuntimeError('invalid upload_mode: %s' % upload_mode)
|
||||
|
||||
|
||||
"""
|
||||
Compare pump device and Nightscout profiles, and return a final dictionary of
|
||||
Nightscout profile objects, with the pump profile settings overriding what is
|
||||
currently in Nightscout.
|
||||
|
||||
ns_profile_obj is the output from NightscoutApi.current_profile() and should be the most
|
||||
recent profile object in mongo.
|
||||
|
||||
Returns the new Nightscout profile and whether it was changed.
|
||||
"""
|
||||
def compare_profiles(self, pump_settings: PumpSettings, ns_profile_obj: dict) -> Tuple[bool, dict]:
|
||||
device = {profile.name: profile for profile in pump_settings.profiles.profile}
|
||||
activeIdp = pump_settings.profiles.activeIdp
|
||||
|
||||
ns = ns_profile_obj.get('store', {})
|
||||
|
||||
logger.debug("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys())
|
||||
|
||||
new_ns_profile = copy.deepcopy(ns_profile_obj)
|
||||
if not 'store' in new_ns_profile:
|
||||
new_ns_profile['store'] = {}
|
||||
updated_ns_profile = False
|
||||
|
||||
missing_profiles_in_ns = set(device.keys()) - set(ns.keys())
|
||||
for profile_name in missing_profiles_in_ns:
|
||||
logger.info("Missing %s profile in Nightscout: %s", profile_name, device.get(profile_name))
|
||||
pump_configured_profile = device[profile_name]
|
||||
ns_translated_profile = NightscoutEntry.tandemsource_profile_store(pump_configured_profile, pump_settings)
|
||||
logger.info("Will add %s profile to Nightscout: %s", profile_name, ns_translated_profile)
|
||||
new_ns_profile['store'][profile_name] = ns_translated_profile
|
||||
updated_ns_profile = True
|
||||
|
||||
existent_profiles_in_ns = set(device.keys()) & set(ns.keys())
|
||||
for profile_name in existent_profiles_in_ns:
|
||||
#logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name)
|
||||
pump_configured_profile = device[profile_name]
|
||||
ns_translated_profile = NightscoutEntry.tandemsource_profile_store(pump_configured_profile, pump_settings)
|
||||
ns_configured_profile = ns[profile_name]
|
||||
|
||||
#logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile)
|
||||
if self.nightscout_profiles_identical(ns_configured_profile, ns_translated_profile):
|
||||
logger.info("Profile %s identical between pump and nightscout", profile_name)
|
||||
continue
|
||||
|
||||
logger.info("Profile %s needs update in nightscout: %s", profile_name, ns_translated_profile)
|
||||
new_ns_profile['store'][profile_name] = ns_translated_profile
|
||||
updated_ns_profile = True
|
||||
|
||||
current_pump_profile = None
|
||||
for profile in pump_settings.profiles.profile:
|
||||
if profile.idp == activeIdp:
|
||||
current_pump_profile = profile.name
|
||||
|
||||
if not current_pump_profile:
|
||||
logger.error('No current pump profile, so skipping profile update')
|
||||
return False, ns_profile_obj
|
||||
|
||||
current_ns_profile = ns_profile_obj.get('defaultProfile')
|
||||
if current_pump_profile != current_ns_profile:
|
||||
logger.info("Current profile changed: pump: %s nightscout: %s", current_pump_profile, current_ns_profile)
|
||||
new_ns_profile['defaultProfile'] = current_pump_profile
|
||||
updated_ns_profile = True
|
||||
|
||||
if not updated_ns_profile:
|
||||
logger.info("No Nightscout profile changes")
|
||||
return False, ns_profile_obj
|
||||
|
||||
logger.info("New Nightscout profile object: %s", new_ns_profile)
|
||||
new_ns_profile['enteredBy'] = ENTERED_BY
|
||||
return True, new_ns_profile
|
||||
|
||||
def nightscout_profiles_identical(self, configured: dict, translated: dict) -> bool:
|
||||
if configured == translated:
|
||||
logger.debug("direct dicts equal")
|
||||
return True
|
||||
|
||||
if json.dumps(configured, sort_keys=True, indent=None) == json.dumps(translated, sort_keys=True, indent=None):
|
||||
logger.debug("initial JSON dump identical")
|
||||
return True
|
||||
|
||||
# convert all JSON values into strings
|
||||
def map_nested_dicts_modify(ob, func):
|
||||
for k, v in ob.items():
|
||||
if isinstance(v, dict):
|
||||
map_nested_dicts_modify(v, func)
|
||||
elif isinstance(v, list):
|
||||
map_nested_lists_modify(v, func)
|
||||
else:
|
||||
ob[k] = func(v)
|
||||
|
||||
def map_nested_lists_modify(ob, func):
|
||||
for i in range(len(ob)):
|
||||
v = ob[i]
|
||||
if isinstance(v, dict):
|
||||
map_nested_dicts_modify(v, func)
|
||||
elif isinstance(v, list):
|
||||
map_nested_lists_modify(v, func)
|
||||
else:
|
||||
ob[i] = func(v)
|
||||
|
||||
def to_numeric(x):
|
||||
if type(x) in [int, float]:
|
||||
return '%f' % x
|
||||
try:
|
||||
return '%f' % float(x)
|
||||
except (ValueError, TypeError):
|
||||
return x
|
||||
|
||||
|
||||
convert_func = lambda x: to_numeric(x)
|
||||
|
||||
configured_str = json.loads(json.dumps(configured))
|
||||
map_nested_dicts_modify(configured_str, convert_func)
|
||||
translated_str = json.loads(json.dumps(translated))
|
||||
map_nested_dicts_modify(translated_str, convert_func)
|
||||
|
||||
if json.dumps(configured_str, sort_keys=True, indent=None) == json.dumps(translated_str, sort_keys=True, indent=None):
|
||||
logger.debug("map_nested_dicts JSON dump identical")
|
||||
return True
|
||||
|
||||
logger.debug("profiles not identical")
|
||||
return False
|
||||
|
||||
def setup_new_profile(self, ns_profile: dict) -> dict:
|
||||
if '_id' in ns_profile:
|
||||
del ns_profile['_id']
|
||||
|
||||
now = arrow.now().isoformat()
|
||||
ns_profile['startDate'] = now
|
||||
ns_profile['created_at'] = now
|
||||
|
||||
return ns_profile
|
||||
@@ -28,4 +28,9 @@ def removesuffix(input_string, suffix):
|
||||
def removeprefix(input_string, prefix):
|
||||
if prefix and input_string.startswith(prefix):
|
||||
return input_string[len(prefix):]
|
||||
return input_string
|
||||
return input_string
|
||||
|
||||
def cap_length(text, maxlen):
|
||||
if not text or len(text) <= maxlen:
|
||||
return text
|
||||
return '%s[...]%s' % (text[:maxlen//2], text[maxlen//-2:])
|
||||
@@ -16,5 +16,5 @@ Returns a TConnectApi object with default secret parameters.
|
||||
"""
|
||||
def get_api():
|
||||
from ..api import TConnectApi
|
||||
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD
|
||||
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
|
||||
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION
|
||||
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION)
|
||||
+1
-1
@@ -46,7 +46,7 @@ class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
|
||||
|
||||
def my_devices(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def device_settings(self, pump_guid):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -111,12 +111,65 @@ class TestControlIQApi(unittest.TestCase):
|
||||
|
||||
text=post_callback)
|
||||
|
||||
self.assertRaises(ApiLoginException, ciq.login, 'email@email.com', 'password')
|
||||
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: Check your login credentials.', ciq.login, 'email@email.com', 'password')
|
||||
|
||||
self.assertIsNone(ciq.userGuid)
|
||||
self.assertIsNone(ciq.accessToken)
|
||||
self.assertIsNone(ciq.accessTokenExpiresAt)
|
||||
|
||||
def test_login_invalid_credentials_parsed_message(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><div class="notice_error" id="literalMessage" style="">The email address or password you entered is invalid. Please re-enter and try again.</div></body></html>'
|
||||
|
||||
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
|
||||
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
|
||||
|
||||
text=post_callback)
|
||||
|
||||
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: The email address or password you entered is invalid. Please re-enter and try again.', ciq.login, 'email@email.com', 'password')
|
||||
|
||||
self.assertIsNone(ciq.userGuid)
|
||||
self.assertIsNone(ciq.accessToken)
|
||||
self.assertIsNone(ciq.accessTokenExpiresAt)
|
||||
|
||||
def test_login_unexpected_http_code(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 = 500
|
||||
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.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect \(HTTP 500\)', 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):
|
||||
|
||||
+59
-1
@@ -18,7 +18,7 @@ from .fake import ControlIQApi
|
||||
from tconnectsync.api.controliq import ControlIQApi as RealControlIQApi
|
||||
from tconnectsync.api.common import ApiException, ApiLoginException, base_headers
|
||||
|
||||
class TestWebUIScraper(unittest.TestCase):
|
||||
class TestWebUIScraperMyDevices(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
DEVICES_HTML = """
|
||||
@@ -319,6 +319,64 @@ class TestWebUIScraper(unittest.TestCase):
|
||||
'guid': None
|
||||
})})
|
||||
|
||||
REDIRECT_HTML = """<html><head><title>Object moved</title></head><body>
|
||||
<h2>Object moved to <a href="/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx">here</a>.</h2>
|
||||
</body></html>"""
|
||||
|
||||
LOGIN_HTML = """<html></html>"""
|
||||
|
||||
def test_relogin_when_redirected_to_login_page(self):
|
||||
ciq = ControlIQApi()
|
||||
ciq.needs_relogin = lambda: False
|
||||
ciq.loginSession = requests.Session()
|
||||
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
|
||||
ciq._email = 'EMAIL'
|
||||
ciq._password = 'PASSWORD'
|
||||
|
||||
login_times = []
|
||||
def fake_login(email, password):
|
||||
self.assertEqual(email, ciq._email)
|
||||
self.assertEqual(password, ciq._password)
|
||||
|
||||
login_times.append(1)
|
||||
return True
|
||||
|
||||
ciq.login = fake_login
|
||||
webui = WebUIScraper(ciq)
|
||||
|
||||
ciq.login(ciq._email, ciq._password)
|
||||
self.assertEqual(len(login_times), 1)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/myaccount/my_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
status_code=302,
|
||||
headers={'Location': 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx'},
|
||||
text=self.REDIRECT_HTML)
|
||||
|
||||
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
status_code=200,
|
||||
text=self.LOGIN_HTML)
|
||||
|
||||
self.assertRaises(ApiException, webui.my_devices)
|
||||
self.assertEqual(len(login_times), 2)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/myaccount/my_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
text=self.DEVICES_HTML)
|
||||
|
||||
devices = webui.my_devices()
|
||||
self.assertEqual(len(login_times), 2)
|
||||
|
||||
|
||||
class TestWebUIScraperPumpSettings(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
PUMP_SETTINGS_HTML = """
|
||||
<!DOCTYPE html
|
||||
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
|
||||
@@ -20,12 +20,15 @@ class NightscoutApi(tconnectsync.nightscout.NightscoutApi):
|
||||
def put_entry(self, ns_format, entity):
|
||||
self.put_entries[entity].append(ns_format)
|
||||
|
||||
def last_uploaded_entry(self, eventType, start_date=None, end_date=None):
|
||||
def last_uploaded_entry(self, eventType, time_start=None, time_end=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def last_uploaded_activity(self, activityType):
|
||||
raise NotImplementedError
|
||||
|
||||
def last_uploaded_devicestatus(self, time_start=None, time_end=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def api_status(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import unittest
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException, tandem_to_ns_time, tandem_to_ns_time_seconds
|
||||
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
|
||||
from .test_profile_data import DEVICE_PROFILE_A, DEVICE_SETTINGS, NS_PROFILE_A
|
||||
|
||||
from ..sync.test_profile import DEVICE_PROFILE_A, DEVICE_SETTINGS, NS_PROFILE_A
|
||||
class TestNightscoutEntry(unittest.TestCase):
|
||||
maxDiff = None
|
||||
def test_basal(self):
|
||||
@@ -22,7 +22,8 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"created_at": "2021-03-16 00:25:21-04:00",
|
||||
"carbs": None,
|
||||
"insulin": None,
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,7 +42,8 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"created_at": "2021-03-16 12:25:21-04:00",
|
||||
"carbs": None,
|
||||
"insulin": None,
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
@@ -57,7 +59,8 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"carbs": 45,
|
||||
"insulin": 7.5,
|
||||
"notes": "",
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
@@ -72,7 +75,8 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"carbs": 5,
|
||||
"insulin": 0.5,
|
||||
"notes": "",
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
@@ -92,7 +96,8 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"notes": "",
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"glucose": "123",
|
||||
"glucoseType": "Sensor"
|
||||
"glucoseType": "Sensor",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
@@ -111,28 +116,14 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"notes": "",
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"glucose": "150",
|
||||
"glucoseType": "Finger"
|
||||
"glucoseType": "Finger",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
def test_bolus_with_bg_invalid_type(self):
|
||||
self.assertRaises(InvalidBolusTypeException,
|
||||
NightscoutEntry.bolus,
|
||||
bolus=0.5,
|
||||
carbs=5,
|
||||
created_at="2021-03-16 12:25:21-04:00",
|
||||
bg="150")
|
||||
|
||||
self.assertRaises(InvalidBolusTypeException,
|
||||
NightscoutEntry.bolus,
|
||||
bolus=0.5,
|
||||
carbs=5,
|
||||
created_at="2021-03-16 12:25:21-04:00",
|
||||
bg="150",
|
||||
bg_type="")
|
||||
|
||||
self.assertRaises(InvalidBolusTypeException,
|
||||
NightscoutEntry.bolus,
|
||||
NightscoutEntry.bolus,
|
||||
bolus=0.5,
|
||||
carbs=5,
|
||||
created_at="2021-03-16 12:25:21-04:00",
|
||||
@@ -163,9 +154,10 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"date": 1635041834000,
|
||||
"dateString": "2021-10-23T22:17:14-0400",
|
||||
"device": "Pump (tconnectsync)",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_sitechange(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.sitechange(
|
||||
@@ -176,10 +168,11 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"reason": "reason",
|
||||
"notes": "reason",
|
||||
"created_at": "2021-12-05T00:16:35.058Z",
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_basalsuspension(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.basalsuspension(
|
||||
@@ -190,11 +183,12 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"reason": "reason",
|
||||
"notes": "reason",
|
||||
"created_at": "2021-12-05T00:16:35.058Z",
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": ""
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_profile_store(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.profile_store(
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
|
||||
from tconnectsync.secret import NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE, TIMEZONE_NAME
|
||||
|
||||
|
||||
DEVICE_PROFILE_A = Profile(
|
||||
title='A',
|
||||
active=False,
|
||||
segments=[
|
||||
ProfileSegment(
|
||||
display_time='Midnight',
|
||||
time='12:00 AM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='6:00 AM',
|
||||
time='6:00 AM',
|
||||
basal_rate=1.25,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='11:00 AM',
|
||||
time='11:00 AM',
|
||||
basal_rate=1.0,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='Noon',
|
||||
time='12:00 PM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0)
|
||||
],
|
||||
calculated_total_daily_basal=21.65,
|
||||
insulin_duration_min=300,
|
||||
carbs_enabled=True
|
||||
)
|
||||
|
||||
DEVICE_PROFILE_B = Profile(
|
||||
title='B',
|
||||
active=False,
|
||||
segments=[
|
||||
ProfileSegment(
|
||||
display_time='Midnight',
|
||||
time='12:00 AM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='6:00 AM',
|
||||
time='6:00 AM',
|
||||
basal_rate=1.25,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='11:00 AM',
|
||||
time='11:00 AM',
|
||||
basal_rate=1.0,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='Noon',
|
||||
time='12:00 PM',
|
||||
basal_rate=0.9,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0)
|
||||
],
|
||||
calculated_total_daily_basal=22.85,
|
||||
insulin_duration_min=300,
|
||||
carbs_enabled=True
|
||||
)
|
||||
|
||||
DEVICE_SETTINGS = DeviceSettings(
|
||||
low_bg_threshold=80,
|
||||
high_bg_threshold=200,
|
||||
raw_settings={}
|
||||
)
|
||||
|
||||
NS_PROFILE_A = {
|
||||
"dia": "5.0",
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 6.0
|
||||
}
|
||||
],
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
"sens": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 30.0
|
||||
}
|
||||
],
|
||||
"basal": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 0.8
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 1.25
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 1.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 0.8
|
||||
}
|
||||
],
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 200
|
||||
}
|
||||
],
|
||||
"timezone": TIMEZONE_NAME,
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
NS_PROFILE_B = {
|
||||
"dia": "5.0",
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 12.0
|
||||
}
|
||||
],
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
"sens": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 30.0
|
||||
}
|
||||
],
|
||||
"basal": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 0.8
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 1.25
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 1.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 0.9
|
||||
}
|
||||
],
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 200
|
||||
}
|
||||
],
|
||||
"timezone": TIMEZONE_NAME,
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
NS_PROFILE_STORE = {
|
||||
'A': NS_PROFILE_A,
|
||||
'B': NS_PROFILE_B
|
||||
}
|
||||
@@ -442,6 +442,71 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"bolex_start_time": None
|
||||
}))
|
||||
|
||||
|
||||
entryExtendedComplete = {
|
||||
"Type": "Bolus",
|
||||
"Description": "Extended 50.00%/0.00",
|
||||
"BG": "131",
|
||||
"IOB": "5.87",
|
||||
"BolusRequestID": "3636.000",
|
||||
"BolusCompletionID": "3636.000",
|
||||
"CompletionDateTime": "2022-08-09T23:20:04",
|
||||
"InsulinDelivered": "0.20",
|
||||
"FoodDelivered": "0.00",
|
||||
"CorrectionDelivered": "0.00",
|
||||
"CompletionStatusID": "3",
|
||||
"CompletionStatusDesc": "Completed",
|
||||
"BolusIsComplete": "1",
|
||||
"BolexCompletionID": "16757133",
|
||||
"BolexSize": "0.20",
|
||||
"BolexStartDateTime": "2022-08-09T23:20:04",
|
||||
"BolexCompletionDateTime": "2022-08-09T23:35:03",
|
||||
"BolexInsulinDelivered": "0.20",
|
||||
"BolexIOB": "5.7",
|
||||
"BolexCompletionStatusID": "3.00",
|
||||
"BolexCompletionStatusDesc": "Completed",
|
||||
"ExtendedBolusIsComplete": "1",
|
||||
"EventDateTime": "2022-08-09T23:19:15",
|
||||
"RequestDateTime": "2022-08-09T23:19:15",
|
||||
"BolusType": "Carb",
|
||||
"BolusRequestOptions": "Extended",
|
||||
"StandardPercent": "50.00",
|
||||
"Duration": "15",
|
||||
"CarbSize": "0",
|
||||
"UserOverride": "1",
|
||||
"TargetBG": "110",
|
||||
"CorrectionFactor": "30.00",
|
||||
"FoodBolusSize": "0.00",
|
||||
"CorrectionBolusSize": "0.00",
|
||||
"ActualTotalBolusRequested": "0.40",
|
||||
"IsQuickBolus": "0",
|
||||
"EventHistoryReportEventDesc": "0",
|
||||
"EventHistoryReportDetails": "Food Bolus: 50% Extended 15 mins",
|
||||
"NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units",
|
||||
"IndexID": "0",
|
||||
"Note": "631597"
|
||||
}
|
||||
def test_parse_bolus_entry_extended_complete(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_bolus_entry(self.entryExtendedComplete),
|
||||
Bolus(**{
|
||||
"description": "Extended 50.00%/0.00",
|
||||
"complete": "1",
|
||||
"completion": "Completed",
|
||||
"request_time": None,
|
||||
"completion_time": None,
|
||||
"insulin": "0.20",
|
||||
"requested_insulin": "0.40",
|
||||
"carbs": "0",
|
||||
"bg": "131",
|
||||
"user_override": "1",
|
||||
"extended_bolus": "1",
|
||||
"bolex_completion_time": "2022-08-09 23:35:03-04:00",
|
||||
"bolex_start_time": "2022-08-09 23:20:04-04:00"
|
||||
}))
|
||||
|
||||
|
||||
|
||||
class TestTConnectEntryReading(unittest.TestCase):
|
||||
entry1 = {
|
||||
"DeviceType": "t:slim X2 Insulin Pump",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import arrow
|
||||
|
||||
from tconnectsync.sync.tandemsource.process_alarm import ProcessAlarm
|
||||
from tconnectsync.eventparser import events as eventtypes
|
||||
from tconnectsync.eventparser.generic import Event
|
||||
|
||||
from ...api.fake import TConnectApi
|
||||
from ...nightscout_fake import NightscoutApi
|
||||
|
||||
class TestProcessAlarm(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
self.process = ProcessAlarm(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
|
||||
|
||||
def test_resume_alarm_ignored(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-11-11 07:24:29-05:00
|
||||
Event(b'\x00\x05\x1f\xb8.\xad\x00\x0e\x91\xee\x00\x00\x00\x12\x00\x00 w\x01N\x0b\x16\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAlarmActivated)
|
||||
self.assertEqual(events[0].alarmid, eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 0)
|
||||
|
||||
|
||||
def test_empty_cartridge_alarm(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-11-17 08:44:17-05:00
|
||||
Event(b'\x00\x05\x1f\xc0*a\x00\x0e\xf5\x90\x00\x00\x00\x08\x00\x00 1\x00\x00\x00gA\x1a\x1e\x84')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAlarmActivated)
|
||||
self.assertEqual(events[0].alarmid, eventtypes.LidAlarmActivated.AlarmidEnum.EmptyCartridgeAlarm)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
'eventType': 'Alarm',
|
||||
'created_at': '2024-11-17 08:44:17-05:00',
|
||||
'enteredBy': 'Pump (tconnectsync)',
|
||||
'notes': 'EmptyCartridgeAlarm',
|
||||
'reason': 'EmptyCartridgeAlarm',
|
||||
'pump_event_id': '980368'
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import struct
|
||||
|
||||
from tconnectsync.sync.tandemsource.process_device_status import ProcessDeviceStatus
|
||||
from tconnectsync.eventparser import events as eventtypes
|
||||
from tconnectsync.eventparser.events import UINT16
|
||||
from tconnectsync.eventparser.generic import Event
|
||||
|
||||
from ...api.fake import TConnectApi
|
||||
from ...nightscout_fake import NightscoutApi
|
||||
|
||||
class TestProcessDeviceStatus(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
self.process = ProcessDeviceStatus(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
|
||||
|
||||
def test_single_event_no_last_uploaded(self):
|
||||
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
|
||||
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
|
||||
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
|
||||
self.assertEqual(events[0].batterylipomillivolts, 14080)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
'created_at': '2024-12-03 23:40:23-05:00',
|
||||
'device': 'Pump (tconnectsync)',
|
||||
'pump': {
|
||||
'clock': '2024-12-03 23:40:23-05:00',
|
||||
'battery': {
|
||||
'status': '32%',
|
||||
'percent': 32,
|
||||
'voltage': 14.08
|
||||
}
|
||||
},
|
||||
'pump_event_id': '1046436'
|
||||
})
|
||||
|
||||
def test_single_event_already_uploaded(self):
|
||||
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
|
||||
|
||||
events = [
|
||||
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
|
||||
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
|
||||
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
|
||||
self.assertEqual(events[0].batterylipomillivolts, 14080)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 0)
|
||||
|
||||
|
||||
def test_multiple_event_with_last_uploaded(self):
|
||||
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
|
||||
|
||||
events = [
|
||||
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'),
|
||||
Event(b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
|
||||
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
|
||||
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
|
||||
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
|
||||
self.assertEqual(events[0].batterylipomillivolts, 14080)
|
||||
|
||||
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
|
||||
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
|
||||
self.assertEqual(events[1].batterychargepercentmsbRaw, 14)
|
||||
self.assertEqual(events[1].batterychargepercentlsbRaw, 243)
|
||||
self.assertEqual(events[1].batterylipomillivolts, 13824)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
'created_at': '2024-12-04 02:30:23-05:00',
|
||||
'device': 'Pump (tconnectsync)',
|
||||
'pump': {
|
||||
'clock': '2024-12-04 02:30:23-05:00',
|
||||
'battery': {
|
||||
'status': '31%',
|
||||
'percent': 31,
|
||||
'voltage': 13.824
|
||||
}
|
||||
},
|
||||
'pump_event_id': '1046875'
|
||||
})
|
||||
|
||||
def test_multiple_event_only_latest_applied(self):
|
||||
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
|
||||
|
||||
events = [
|
||||
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'),
|
||||
Event(b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00'),
|
||||
Event(b'\x00Q\x1f\xd6k\x1f\x00\x0f\xfc>A\x0e\xa3\x80?\x9a~\xfa@\x11z6\x0e\xee5\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
|
||||
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
|
||||
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
|
||||
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
|
||||
self.assertEqual(events[0].batterylipomillivolts, 14080)
|
||||
|
||||
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
|
||||
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
|
||||
self.assertEqual(events[1].batterychargepercentmsbRaw, 14)
|
||||
self.assertEqual(events[1].batterychargepercentlsbRaw, 243)
|
||||
self.assertEqual(events[1].batterylipomillivolts, 13824)
|
||||
|
||||
self.assertEqual(type(events[2]), eventtypes.LidDailyBasal)
|
||||
self.assertEqual(events[2].raw.timestampRaw, 534145823) # 2024-12-04 05:50:23-05:00
|
||||
self.assertEqual(events[2].batterychargepercentmsbRaw, 14)
|
||||
self.assertEqual(events[2].batterychargepercentlsbRaw, 238)
|
||||
self.assertEqual(events[2].batterylipomillivolts, 13568)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
'created_at': '2024-12-04 05:50:23-05:00',
|
||||
'device': 'Pump (tconnectsync)',
|
||||
'pump': {
|
||||
'clock': '2024-12-04 05:50:23-05:00',
|
||||
'battery': {
|
||||
'status': '30%',
|
||||
'percent': 30,
|
||||
'voltage': 13.568
|
||||
}
|
||||
},
|
||||
'pump_event_id': '1047614'
|
||||
})
|
||||
|
||||
@unittest.skip
|
||||
def test_device_status_battery_calculation(self):
|
||||
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
|
||||
|
||||
def test_percentage_about(percent_str, event):
|
||||
with self.subTest(percent_str, event=event):
|
||||
# p = self.process.process([event], time_start=None, time_end=None)
|
||||
# self.assertEqual(len(p), 1)
|
||||
# self.assertEqual(p[0]['pump']['battery']['status'], percent_str)
|
||||
msb = event.batterychargepercentmsbRaw
|
||||
lsb = event.batterychargepercentlsbRaw
|
||||
|
||||
|
||||
MAX = struct.unpack(UINT16, bytearray((16, 128)))[0]
|
||||
MIN = struct.unpack(UINT16, bytearray((14, 128)))[0]
|
||||
val = struct.unpack(UINT16, bytearray((msb, lsb)))[0]
|
||||
pct = (val - MIN)/(MAX-MIN)
|
||||
# print(pct)
|
||||
# print(struct.unpack(UINT16, bytearray((msb, lsb))
|
||||
# (14, 100) =~ 0%
|
||||
# (15, 100) =~ 50%
|
||||
# (16, 98) =~ 100%
|
||||
# msb*256 + lsb - 14*256
|
||||
# pct = (msb*256 + lsb - 14*256 - 100) / 512
|
||||
|
||||
calc_str = "%.0f%s" % (100*pct, '%')
|
||||
print(f'comp {calc_str=} {percent_str=}')
|
||||
self.assertEqual(calc_str, percent_str)
|
||||
|
||||
|
||||
test_percentage_about('80%', Event(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'))
|
||||
test_percentage_about('55%', Event(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00'))
|
||||
test_percentage_about('45%', Event(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00'))
|
||||
test_percentage_about('15%', Event(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00'))
|
||||
test_percentage_about('20%', Event(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00'))
|
||||
test_percentage_about('10%', Event(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00'))
|
||||
test_percentage_about('10%', Event(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00'))
|
||||
test_percentage_about('5%', Event(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00'))
|
||||
|
||||
|
||||
# Mobi @ MAX seen
|
||||
# bytearray(b'\x00Q \x19U=\x00\x01\x0f\xa8A\xa5\x04V?L\xcc\xcd?s\x83b\x10Pd\x01')
|
||||
# batterychargepercentmsbRaw=16, batterychargepercentlsbRaw=80, batterylipomillivolts=25601
|
||||
|
||||
# Mobi @ ~80%
|
||||
# bytearray(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'):
|
||||
# batterychargepercentmsbRaw=15, batterychargepercentlsbRaw=161, batterylipomillivolts=20480
|
||||
# calc batteryChargePercent = 0.54296875
|
||||
|
||||
# Mobi @ ~55%
|
||||
# bytearray(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00')
|
||||
# batterychargepercentmsbRaw=15, batterychargepercentlsbRaw=29, batterylipomillivolts=15616
|
||||
# calc batteryChargePercent = 0.37109375
|
||||
|
||||
# Mobi @ ~45%
|
||||
# bytearray(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00')
|
||||
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=250 batterylipomillivolts=14080
|
||||
# calc batteryChargePercent = 0.3255208333333333
|
||||
|
||||
# Mobi @ ~15%
|
||||
# bytearray(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00')
|
||||
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=181 batterylipomillivolts=8192
|
||||
# calc batteryChargePercent = 0.23567708333333334
|
||||
|
||||
# Mobi @ ~20%
|
||||
# bytearray(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00')
|
||||
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=193 batterylipomillivolts=9216
|
||||
# calc batteryChargePercent = 0.2513020833333333
|
||||
|
||||
# Mobi @ ~10%
|
||||
# bytearray(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00')
|
||||
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=161 batterylipomillivolts=6912
|
||||
# calc batteryChargePercent = 0.20963541666666666
|
||||
|
||||
# Mobi @ ~10%
|
||||
# bytearray(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00')
|
||||
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=159 batterylipomillivolts=6656
|
||||
# calc batteryChargePercent = 0.20703125
|
||||
|
||||
# Mobi @ ~5%
|
||||
# bytearray(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00')
|
||||
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=136
|
||||
# calc batteryChargePercent = 0.17708333333333334 batterylipomillivolts=5376
|
||||
|
||||
# Mobi @ ~5%
|
||||
# bytearray(b'\x00Q \x1e\xb0\xcd\x00\x01C\xd3@L9W@#33@\x8b\x04\xd2\x0e\x88\x15\x00')
|
||||
# batterychargepercentmsbRaw=14 batterychargepercentlsbRaw=136 batterylipomillivolts=5376
|
||||
# calc 0.17708333333333334
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import arrow
|
||||
|
||||
from tconnectsync.sync.tandemsource.process_user_mode import ProcessUserMode
|
||||
from tconnectsync.eventparser import events as eventtypes
|
||||
from tconnectsync.eventparser.generic import Event
|
||||
|
||||
from ...api.fake import TConnectApi
|
||||
from ...nightscout_fake import NightscoutApi
|
||||
|
||||
class TestProcessUserModeSleep(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
self.process = ProcessUserMode(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
|
||||
|
||||
def test_single_start_sleep_active(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-12-04 23:00:23-05:00 - sleep start
|
||||
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x01\x00\x01\x00\x00\x01\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep)
|
||||
|
||||
time_end = arrow.get('2024-12-04T23:00:30-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
"eventType": 'Sleep',
|
||||
"reason": 'Sleep (Scheduled) - Not Ended',
|
||||
"notes": 'Sleep (Scheduled) - Not Ended',
|
||||
"duration": 0.11666666666666667,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050'
|
||||
})
|
||||
self.assertEqual(self.nightscout.deleted_entries, [])
|
||||
|
||||
def test_single_stop_sleep_no_last_uploaded(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-12-04 09:01:23-05:00 - sleep end
|
||||
Event(b'\x00\xe5\x1f\xd6\x97\xe3\x00\x0f\xfe\xb0\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
|
||||
|
||||
time_end = arrow.get('2024-12-04T23:00:30-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 0)
|
||||
self.assertEqual(self.nightscout.deleted_entries, [])
|
||||
|
||||
|
||||
def test_start_and_stop_sleep(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-12-04 23:00:23-05:00 - sleep start
|
||||
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x01\x00\x01\x00\x00\x01\x00\x00\xf0\x01\x01\x00\x00\x00\x00'),
|
||||
# 2024-12-05 09:01:23-05:00 - sleep end
|
||||
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep)
|
||||
|
||||
|
||||
self.assertEqual(type(events[1]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[1].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
|
||||
self.assertEqual(events[1].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
|
||||
self.assertEqual(events[1].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
|
||||
|
||||
time_end = arrow.get('2024-12-05T10:00:00-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
"eventType": 'Sleep',
|
||||
"reason": 'Sleep (Scheduled)',
|
||||
"notes": 'Sleep (Scheduled)',
|
||||
"duration": 601.0,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050,1052920'
|
||||
})
|
||||
self.assertEqual(self.nightscout.deleted_entries, [])
|
||||
|
||||
def test_stop_with_partial_last_uploaded(self):
|
||||
self.nightscout.last_uploaded_entry = lambda eventType, **kwargs: {
|
||||
'Sleep': {
|
||||
"eventType": 'Sleep',
|
||||
"reason": 'Sleep (Scheduled) - Not Ended',
|
||||
"notes": 'Sleep (Scheduled) - Not Ended',
|
||||
"duration": 0.11666666666666667,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050',
|
||||
"_id": "id_to_delete"
|
||||
}
|
||||
}.get(eventType)
|
||||
|
||||
events = [
|
||||
# 2024-12-05 09:01:23-05:00 - sleep end
|
||||
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
|
||||
|
||||
time_end = arrow.get('2024-12-05T10:00:00-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
"eventType": 'Sleep',
|
||||
"reason": 'Sleep (Scheduled)',
|
||||
"notes": 'Sleep (Scheduled)',
|
||||
"duration": 601.0,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050,1052920'
|
||||
})
|
||||
self.assertEqual(self.nightscout.deleted_entries, ['treatments/id_to_delete'])
|
||||
|
||||
|
||||
class TestProcessUserModeExercise(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
self.process = ProcessUserMode(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
|
||||
|
||||
def test_single_start_exercise_active(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-12-04 23:00:23-05:00 - exercise start
|
||||
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x03\x00\x02\x00\x00\x01\x00\x00\xf0\x00\x01\x00\x00\x00\x00'),
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Exercising)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise)
|
||||
|
||||
time_end = arrow.get('2024-12-04T23:00:30-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
"eventType": 'Exercise',
|
||||
"reason": 'Exercise - Not Ended',
|
||||
"notes": 'Exercise - Not Ended',
|
||||
"duration": 0.11666666666666667,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050'
|
||||
})
|
||||
self.assertEqual(self.nightscout.deleted_entries, [])
|
||||
|
||||
def test_single_stop_exercise_no_last_uploaded(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-12-04 09:01:23-05:00 - exercise end
|
||||
Event(b'\x00\xe5\x1f\xd6\x97\xe3\x00\x0f\xfe\xb0\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x01\x03\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
|
||||
|
||||
time_end = arrow.get('2024-12-04T23:00:30-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 0)
|
||||
self.assertEqual(self.nightscout.deleted_entries, [])
|
||||
|
||||
|
||||
def test_start_and_stop_exercise(self):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
events = [
|
||||
# 2024-12-04 23:00:23-05:00 - exercise start
|
||||
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x03\x00\x02\x00\x00\x01\x00\x00\xf0\x00\x01\x00\x00\x00\x00'),
|
||||
# 2024-12-05 09:01:23-05:00 - exercise end
|
||||
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Exercising)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise)
|
||||
|
||||
|
||||
self.assertEqual(type(events[1]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[1].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
|
||||
self.assertEqual(events[1].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
|
||||
self.assertEqual(events[1].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
|
||||
|
||||
time_end = arrow.get('2024-12-05T10:00:00-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
"eventType": 'Exercise',
|
||||
"reason": 'Exercise',
|
||||
"notes": 'Exercise',
|
||||
"duration": 601.0,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050,1052920'
|
||||
})
|
||||
self.assertEqual(self.nightscout.deleted_entries, [])
|
||||
|
||||
def test_stop_with_partial_last_uploaded(self):
|
||||
self.nightscout.last_uploaded_entry = lambda eventType, **kwargs: {
|
||||
'Exercise': {
|
||||
"eventType": 'Exercise',
|
||||
"reason": 'Exercise - Not Ended',
|
||||
"notes": 'Exercise - Not Ended',
|
||||
"duration": 0.11666666666666667,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050',
|
||||
"_id": 'id_to_delete'
|
||||
}
|
||||
}.get(eventType)
|
||||
|
||||
events = [
|
||||
# 2024-12-05 09:01:23-05:00 - exercise end
|
||||
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x00')
|
||||
]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
|
||||
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
|
||||
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
|
||||
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
|
||||
|
||||
time_end = arrow.get('2024-12-05T10:00:00-05:00')
|
||||
p = self.process.process(events, time_start=None, time_end=time_end)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertDictEqual(p[0], {
|
||||
"eventType": 'Exercise',
|
||||
"reason": 'Exercise',
|
||||
"notes": 'Exercise',
|
||||
"duration": 601.0,
|
||||
"created_at": '2024-12-04 23:00:23-05:00',
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"pump_event_id": '1051050,1052920'
|
||||
})
|
||||
self.assertEqual(self.nightscout.deleted_entries, ['treatments/id_to_delete'])
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import copy
|
||||
|
||||
from tconnectsync.sync.basal import process_ciq_basal_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
|
||||
class TestBasalSync(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
base = {
|
||||
"basal": {
|
||||
"profileRates": [],
|
||||
"tempDeliveryEvents": [],
|
||||
"algorithmDeliveryEvents": [],
|
||||
"profileDeliveryEvents": []
|
||||
},
|
||||
"events": [],
|
||||
"suspensionDeliveryEvents": [],
|
||||
"softwareUpdates": [],
|
||||
"pumpFeatures": []
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_example_ciq_basal_events():
|
||||
data = copy.deepcopy(TestBasalSync.base)
|
||||
data["basal"]["tempDeliveryEvents"] = [
|
||||
{
|
||||
"y": 0.8,
|
||||
"duration": 1221,
|
||||
"x": 1615878000 # 12:00:00
|
||||
}
|
||||
]
|
||||
data["basal"]["algorithmDeliveryEvents"] = [
|
||||
{
|
||||
"y": 0.797,
|
||||
"duration": 300,
|
||||
"x": 1615879521 # 12:25:21
|
||||
},
|
||||
{
|
||||
"y": 0,
|
||||
"duration": 2693,
|
||||
"x": 1615879821 # 12:30:21
|
||||
},
|
||||
]
|
||||
data["basal"]["profileDeliveryEvents"] = [
|
||||
{
|
||||
"y": 0.799,
|
||||
"duration": 300,
|
||||
"x": 1615879221 # 12:20:21
|
||||
}
|
||||
]
|
||||
|
||||
data["suspensionDeliveryEvents"] = [
|
||||
{
|
||||
"suspendReason": "control-iq",
|
||||
"continuation": None,
|
||||
"x": 1615879821 # 12:30:21
|
||||
},
|
||||
]
|
||||
|
||||
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], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][1],
|
||||
delivery_type="algorithmDelivery (control-iq suspension)")
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_example_ciq_basal_events_with_manual_suspension():
|
||||
data = copy.deepcopy(TestBasalSync.base)
|
||||
data["basal"]["tempDeliveryEvents"] = []
|
||||
data["basal"]["algorithmDeliveryEvents"] = [
|
||||
{
|
||||
"y": 1.14,
|
||||
"duration": 300,
|
||||
"x": 1635187357 # 11:42:37
|
||||
},
|
||||
{
|
||||
"y": 0.8,
|
||||
"duration": 1198,
|
||||
"x": 1635187657 # 11:47:37
|
||||
},
|
||||
{
|
||||
"y": 0.8,
|
||||
"duration": 599,
|
||||
"x": 1635190967 # 12:42:47
|
||||
}
|
||||
]
|
||||
data["basal"]["profileDeliveryEvents"] = []
|
||||
data["suspensionDeliveryEvents"] = [
|
||||
{
|
||||
"suspendReason": "manual",
|
||||
"continuation": None,
|
||||
"x": 1635188855 # 12:07:35
|
||||
},
|
||||
{
|
||||
"suspendReason": "manual",
|
||||
"continuation": None,
|
||||
"x": 1635191566 # 12:52:46
|
||||
},
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
def test_process_ciq_basal_events_with_manual_suspension(self):
|
||||
data = TestBasalSync.get_example_ciq_basal_events_with_manual_suspension()
|
||||
|
||||
basalEvents = process_ciq_basal_events(data)
|
||||
self.assertEqual(len(basalEvents), 4)
|
||||
|
||||
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
|
||||
|
||||
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][1], delivery_type="algorithmDelivery"))
|
||||
|
||||
self.assertEqual(basalEvents[2], TConnectEntry.manual_suspension_to_basal_entry(
|
||||
TConnectEntry.parse_suspension_entry(data["suspensionDeliveryEvents"][0]),
|
||||
seconds=2112, # 2112 seconds between 12:07:35 and 12:42:47
|
||||
))
|
||||
|
||||
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][2], delivery_type="algorithmDelivery"))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import random
|
||||
|
||||
|
||||
from tconnectsync.sync.bolus import process_bolus_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry
|
||||
|
||||
from ..parser.test_tconnect import TestTConnectEntryBolus, TestTConnectEntryCGM, TestTConnectEntryReading
|
||||
|
||||
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_cgmevents_not_matching(self):
|
||||
bolusData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
TestTConnectEntryBolus.entryStd,
|
||||
TestTConnectEntryBolus.entryStdAutomatic
|
||||
]
|
||||
|
||||
cgmEvents = [
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3)
|
||||
]
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry.bg_type = type
|
||||
return entry
|
||||
|
||||
# Expect FINGER for bolus entries with a BG because there's no matching event with the same BG
|
||||
expected = [
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.FINGER),
|
||||
# No BG specified for the automatic bolus
|
||||
TConnectEntry.parse_bolus_entry(bolusData[2])
|
||||
]
|
||||
|
||||
self.assertListEqual(bolusEvents, expected)
|
||||
|
||||
def test_process_bolus_events_cgmevents_matches(self):
|
||||
bolusData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
TestTConnectEntryBolus.entryStd,
|
||||
TestTConnectEntryBolus.entryStdAutomatic
|
||||
]
|
||||
|
||||
cgmEvents = [
|
||||
{
|
||||
"time": "2021-04-01 12:45:30-04:00",
|
||||
"bg": "100",
|
||||
"type": "EGV"
|
||||
},
|
||||
# Matches entryStdCorrection time but with wrong BG
|
||||
{
|
||||
"time": "2021-04-01 12:50:30-04:00",
|
||||
"bg": "105",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 13:00:30-04:00",
|
||||
"bg": "110",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 23:15:30-04:00",
|
||||
"bg": "150",
|
||||
"type": "EGV"
|
||||
},
|
||||
# Matches entryStd time with correct BG
|
||||
{
|
||||
"time": "2021-04-01 23:20:30-04:00",
|
||||
"bg": "159",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 23:25:30-04:00",
|
||||
"bg": "160",
|
||||
"type": "EGV"
|
||||
},
|
||||
]
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry.bg_type = type
|
||||
return entry
|
||||
|
||||
expected = [
|
||||
# Time found but BG doesn't match
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
|
||||
# Time found and BG matches
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.SENSOR),
|
||||
# No BG specified for the automatic bolus
|
||||
TConnectEntry.parse_bolus_entry(bolusData[2])
|
||||
]
|
||||
|
||||
self.assertListEqual(bolusEvents, expected)
|
||||
|
||||
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()
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
|
||||
from tconnectsync.sync.cgm import find_event_at, process_cgm_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
|
||||
from ..parser.test_tconnect import TestTConnectEntryReading
|
||||
|
||||
class TestProcessCGMEvents(unittest.TestCase):
|
||||
def test_process_cgm_events(self):
|
||||
rawReadings = [
|
||||
TestTConnectEntryReading.entry1,
|
||||
TestTConnectEntryReading.entry2,
|
||||
TestTConnectEntryReading.entry3,
|
||||
TestTConnectEntryReading.entry4
|
||||
]
|
||||
self.assertListEqual(
|
||||
process_cgm_events(rawReadings),
|
||||
[TConnectEntry.parse_reading_entry(r) for r in rawReadings]
|
||||
)
|
||||
|
||||
class TestFindEventAt(unittest.TestCase):
|
||||
readingData = [
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry4)
|
||||
]
|
||||
|
||||
def test_find_event_at_exact(self):
|
||||
for r in self.readingData:
|
||||
self.assertEqual(find_event_at(self.readingData, r["time"]), r)
|
||||
|
||||
def test_find_event_at_before_not_found(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-22 10:30:00-04:00"), None)
|
||||
|
||||
def test_find_event_at_large_gap(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 13:30:00-04:00"), self.readingData[0])
|
||||
|
||||
def test_find_event_at_between_close(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:17:52-04:00"), self.readingData[1])
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:21:52-04:00"), self.readingData[2])
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:25:59-04:00"), self.readingData[3])
|
||||
|
||||
def test_find_event_at_most_recent(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 18:00:00-04:00"), self.readingData[3])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,524 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from typing import Dict
|
||||
import copy
|
||||
|
||||
from tconnectsync.sync.profile import get_pump_profiles, compare_profiles, nightscout_profiles_identical
|
||||
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
|
||||
from tconnectsync.secret import NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE, TIMEZONE_NAME
|
||||
|
||||
from ..parser.test_tconnect import TestTConnectEntryReading
|
||||
|
||||
DEVICE_PROFILE_A = Profile(
|
||||
title='A',
|
||||
active=False,
|
||||
segments=[
|
||||
ProfileSegment(
|
||||
display_time='Midnight',
|
||||
time='12:00 AM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='6:00 AM',
|
||||
time='6:00 AM',
|
||||
basal_rate=1.25,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='11:00 AM',
|
||||
time='11:00 AM',
|
||||
basal_rate=1.0,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='Noon',
|
||||
time='12:00 PM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0)
|
||||
],
|
||||
calculated_total_daily_basal=21.65,
|
||||
insulin_duration_min=300,
|
||||
carbs_enabled=True
|
||||
)
|
||||
|
||||
DEVICE_PROFILE_B = Profile(
|
||||
title='B',
|
||||
active=False,
|
||||
segments=[
|
||||
ProfileSegment(
|
||||
display_time='Midnight',
|
||||
time='12:00 AM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='6:00 AM',
|
||||
time='6:00 AM',
|
||||
basal_rate=1.25,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='11:00 AM',
|
||||
time='11:00 AM',
|
||||
basal_rate=1.0,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='Noon',
|
||||
time='12:00 PM',
|
||||
basal_rate=0.9,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0)
|
||||
],
|
||||
calculated_total_daily_basal=22.85,
|
||||
insulin_duration_min=300,
|
||||
carbs_enabled=True
|
||||
)
|
||||
|
||||
DEVICE_SETTINGS = DeviceSettings(
|
||||
low_bg_threshold=80,
|
||||
high_bg_threshold=200,
|
||||
raw_settings={}
|
||||
)
|
||||
|
||||
NS_PROFILE_A = {
|
||||
"dia": 5.0,
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 6.0
|
||||
}
|
||||
],
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
"sens": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 30.0
|
||||
}
|
||||
],
|
||||
"basal": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 0.8
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 1.25
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 1.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 0.8
|
||||
}
|
||||
],
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 200
|
||||
}
|
||||
],
|
||||
"timezone": TIMEZONE_NAME,
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
NS_PROFILE_B = {
|
||||
"dia": 5.0,
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 12.0
|
||||
}
|
||||
],
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
"sens": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 30.0
|
||||
}
|
||||
],
|
||||
"basal": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 0.8
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 1.25
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 1.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 0.9
|
||||
}
|
||||
],
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 200
|
||||
}
|
||||
],
|
||||
"timezone": TIMEZONE_NAME,
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
NS_PROFILE_STORE = {
|
||||
'A': NS_PROFILE_A,
|
||||
'B': NS_PROFILE_B
|
||||
}
|
||||
|
||||
def build_ns_profile(profiles: Dict[str, dict], current_profile: str) -> dict:
|
||||
return copy.deepcopy({
|
||||
"store": profiles,
|
||||
"defaultProfile": current_profile,
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"mills": 0,
|
||||
"units": "mg/dl",
|
||||
})
|
||||
|
||||
class TestCompareProfiles(unittest.TestCase):
|
||||
maxDiff = None
|
||||
def test_compare_profiles_identical_a(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertFalse(changed)
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_identical_b(self):
|
||||
pump_profiles = [DEVICE_PROFILE_B.activeProfile()]
|
||||
ns_profile_obj = build_ns_profile({'B': NS_PROFILE_B}, 'B')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertFalse(changed)
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_extra_profile_in_nightscout_ignored(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A, 'B': NS_PROFILE_B}, 'A')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertFalse(changed)
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_current_nightscout_profile_changed(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A, 'B': NS_PROFILE_B}, 'B')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertNotEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
ns_profile_obj['defaultProfile'] = 'A'
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_new_pump_profile_added(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A.activeProfile(), DEVICE_PROFILE_B]
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertNotEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
ns_profile_obj['store']['B'] = new_profiles['store']['B']
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_new_pump_profile_added_and_active(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()]
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertNotEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
ns_profile_obj['store']['B'] = new_profiles['store']['B']
|
||||
ns_profile_obj['defaultProfile'] = 'B'
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_existing_profile_edited_basal(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
|
||||
pump_profiles[0].segments[0].basal_rate = 0.1
|
||||
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertNotEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
ns_profile_obj['store']['A']['basal'][0]['value'] = 0.1
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_existing_profile_edited_new_chunk(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
|
||||
pump_profiles[0].segments.append(ProfileSegment(
|
||||
display_time='06:00 PM',
|
||||
time='06:00 PM',
|
||||
basal_rate=0.7,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0)
|
||||
)
|
||||
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertNotEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
ns_profile_obj['store']['A']['basal'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 0.7})
|
||||
ns_profile_obj['store']['A']['carbratio'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 6})
|
||||
ns_profile_obj['store']['A']['sens'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 30})
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def test_compare_profiles_existing_profile_edited_removed_chunk(self):
|
||||
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
|
||||
pump_profiles[0].segments.pop()
|
||||
|
||||
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
changed, new_profiles = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
ns_profile_obj
|
||||
)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertNotEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
ns_profile_obj['store']['A']['basal'].pop()
|
||||
ns_profile_obj['store']['A']['carbratio'].pop()
|
||||
ns_profile_obj['store']['A']['sens'].pop()
|
||||
self.assertDictEqual(ns_profile_obj, new_profiles)
|
||||
|
||||
self.ensure_stabilized(pump_profiles, new_profiles)
|
||||
|
||||
def ensure_stabilized(self, pump_profiles, new_profiles):
|
||||
changed, _ = compare_profiles(
|
||||
pump_profiles,
|
||||
DEVICE_SETTINGS,
|
||||
new_profiles
|
||||
)
|
||||
self.assertFalse(changed, 'did not stabilize')
|
||||
|
||||
class TestNightscoutProfilesIdentical(unittest.TestCase):
|
||||
def test_same_dict(self):
|
||||
self.assertTrue(nightscout_profiles_identical(NS_PROFILE_A, NS_PROFILE_A))
|
||||
|
||||
def test_different_dict(self):
|
||||
self.assertFalse(nightscout_profiles_identical(NS_PROFILE_A, NS_PROFILE_B))
|
||||
|
||||
def test_same_re_jsoned(self):
|
||||
self.assertTrue(nightscout_profiles_identical(NS_PROFILE_A, json.loads(json.dumps(NS_PROFILE_A))))
|
||||
|
||||
def test_same_different_number_types(self):
|
||||
a = b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
a['dia'] = 5
|
||||
b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
b['dia'] = 5.0
|
||||
self.assertTrue(nightscout_profiles_identical(a, b))
|
||||
|
||||
def test_same_different_number_strings_1(self):
|
||||
a = b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
a['dia'] = 5
|
||||
b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
b['dia'] = '5.0'
|
||||
self.assertTrue(nightscout_profiles_identical(a, b))
|
||||
|
||||
def test_same_different_number_strings_2(self):
|
||||
a = b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
a['dia'] = '5'
|
||||
b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
b['dia'] = 5.0
|
||||
self.assertTrue(nightscout_profiles_identical(a, b))
|
||||
|
||||
def test_same_different_number_strings_3(self):
|
||||
a = b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
a['dia'] = '5'
|
||||
b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
b['dia'] = '5.000'
|
||||
self.assertTrue(nightscout_profiles_identical(a, b))
|
||||
|
||||
def test_different_numbers(self):
|
||||
a = b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
a['dia'] = 5
|
||||
b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
b['dia'] = 5.1
|
||||
self.assertFalse(nightscout_profiles_identical(a, b))
|
||||
|
||||
def test_different_numbers_strings(self):
|
||||
a = b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
a['dia'] = '5'
|
||||
b = json.loads(json.dumps(NS_PROFILE_A))
|
||||
b['dia'] = '5.01'
|
||||
self.assertFalse(nightscout_profiles_identical(a, b))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,368 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
import datetime
|
||||
import contextlib
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tconnectsync.autoupdate import Autoupdate, AutoupdateFailureError, AutoupdateFailureWarning, AutoupdateNoEventIndexesDetectedError, AutoupdateNoIndexChangeWarning
|
||||
|
||||
from .api.fake import TConnectApi
|
||||
from .nightscout_fake import NightscoutApi
|
||||
from .secrets import build_secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def stub(*args, **kwargs):
|
||||
pass
|
||||
|
||||
@contextlib.contextmanager
|
||||
def build_mock_logger():
|
||||
def fake_error(*args, **kwargs):
|
||||
logger.error(*args, **kwargs)
|
||||
|
||||
def fake_warn(*args, **kwargs):
|
||||
logger.warning(*args, **kwargs)
|
||||
|
||||
with patch("tconnectsync.autoupdate.logger.error") as mock_error, patch("tconnectsync.autoupdate.logger.warning") as mock_warn:
|
||||
mock_error.side_effect = fake_error
|
||||
mock_warn.side_effect = fake_warn
|
||||
yield (mock_error, mock_warn)
|
||||
|
||||
def num_instances_of(cls, m):
|
||||
return sum([isinstance(i[0][0], cls) for i in m.call_args_list])
|
||||
|
||||
class TestAutoupdate(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
# datetimes are unused
|
||||
start = datetime.datetime(2021, 4, 20, 12, 0)
|
||||
end = datetime.datetime(2021, 4, 21, 12, 0)
|
||||
|
||||
# each time the returned function is called, it returns the next argument
|
||||
# until the end is reached, at which point it will repeat the last argument
|
||||
def fake_last_event_uploaded(self, *indexes):
|
||||
index = 0
|
||||
def fake(*args, **kwargs):
|
||||
nonlocal index
|
||||
if index < len(indexes):
|
||||
index += 1
|
||||
data = {'maxPumpEventIndex': indexes[index-1], 'processingStatus': 1}
|
||||
return data
|
||||
|
||||
return fake
|
||||
|
||||
# each time the returned function is called, it returns the next argument
|
||||
# until the end is reached, at which point it will repeat the last argument
|
||||
def fake_process_time_range(self, *returns):
|
||||
index = 0
|
||||
def fake(*args, **kwargs):
|
||||
nonlocal index
|
||||
if index < len(returns):
|
||||
index += 1
|
||||
return returns[index - 1]
|
||||
|
||||
return fake
|
||||
|
||||
"""process_time_range should always be invoked the first time"""
|
||||
def test_process_time_range_called_on_start(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
|
||||
mock_process_time_range.return_value = 0
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
|
||||
|
||||
"""process_time_range should never be called with pretend"""
|
||||
def test_process_time_range_never_called_with_pretend(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
|
||||
mock_process_time_range.return_value = 0
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=True)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 0)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
|
||||
|
||||
"""
|
||||
If the event index increases without process_time_range detecting new data,
|
||||
AutoupdateFailureWarning should be raised.
|
||||
"""
|
||||
def test_autoupdate_failure_warning_on_index_process_time_range_discrepancy(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=2,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
|
||||
TCONNECT_EMAIL="test@email.com",
|
||||
TCONNECT_PASSWORD="testpassword"
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 2)
|
||||
self.assertEqual(u.autoupdate_invocations, 2)
|
||||
self.assertEqual(u.last_event_index, 2)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 1)
|
||||
|
||||
"""
|
||||
On the first attempt, an AutoupdateFailureWarning should never be raised.
|
||||
"""
|
||||
def test_autoupdate_no_failure_warning_on_index_process_time_range_discrepancy_first_attempt(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 0)
|
||||
|
||||
|
||||
"""
|
||||
If the event index increases without process_time_range detecting new data
|
||||
for AUTOUPDATE_FAILURE_MINUTES, an AutoupdateFailureError should be raised.
|
||||
"""
|
||||
def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=2,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
|
||||
AUTOUPDATE_FAILURE_MINUTES=0,
|
||||
AUTOUPDATE_RESTART_ON_FAILURE=False,
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 2)
|
||||
self.assertEqual(u.autoupdate_invocations, 2)
|
||||
self.assertEqual(u.last_event_index, 2)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1)
|
||||
|
||||
"""
|
||||
If the event index increases without process_time_range detecting new data
|
||||
for AUTOUPDATE_FAILURE_MINUTES, and AUTOUPDATE_RESTART_ON_FAILURE is true,
|
||||
then an AutoupdateFailureError should be raised AND 1 should be returned.
|
||||
"""
|
||||
def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes_performs_restart(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=3,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
|
||||
AUTOUPDATE_FAILURE_MINUTES=0,
|
||||
AUTOUPDATE_RESTART_ON_FAILURE=True,
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 1)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 2)
|
||||
self.assertEqual(u.autoupdate_invocations, 1) # exits before invocations is incremented
|
||||
self.assertEqual(u.last_event_index, 1) # exits before changed to 2
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1)
|
||||
|
||||
|
||||
"""Validate state after first successful update"""
|
||||
def test_state_after_first_successful_update(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(1)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
self.assertTrue(u.last_event_time == u.last_attempt_time == u.last_successful_process_time_range)
|
||||
self.assertEqual(len(u.time_diffs_between_updates), 0)
|
||||
|
||||
"""Validate sleep occurs for the given fixed length when set"""
|
||||
def test_sleep_for_fixed_length_when_set(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
sleep_length = 3 # sentinel
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=sleep_length
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep:
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(1)
|
||||
mock_sleep.side_effect = None
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
self.assertTrue(mock_sleep.called)
|
||||
self.assertEqual(mock_sleep.call_args[0], (sleep_length,))
|
||||
|
||||
|
||||
"""
|
||||
If there is no event index update for AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
|
||||
then a AutoupdateNoEventIndexesDetectedError should be raised and
|
||||
a restart should be triggered with AUTOUPDATE_RESTART_ON_FAILURE.
|
||||
"""
|
||||
def test_autoupdate_no_event_indexes_detected_error_on_no_index_change_less_than_three_iterations(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=100,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1,
|
||||
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2,
|
||||
AUTOUPDATE_RESTART_ON_FAILURE=True,
|
||||
AUTOUPDATE_NO_DATA_FAILURE_MINUTES=1/600 # 0.1 second
|
||||
)
|
||||
|
||||
# with more than 3 iterations, the sleep iteration will change
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(1, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 1)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateNoEventIndexesDetectedError, mock_error), 1)
|
||||
|
||||
|
||||
"""
|
||||
If there has been 3 failed attempts since the last time we found new data,
|
||||
we should sleep for AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS and
|
||||
a AutoupdateNoIndexChangeWarning should be logged.
|
||||
"""
|
||||
def test_autoupdate_no_index_change_warning_on_unexpected_no_index_sleep(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=5,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1,
|
||||
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0)
|
||||
mock_sleep.side_effect = None
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(u.autoupdate_invocations, 5)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
self.assertTrue(mock_sleep.called)
|
||||
self.assertEqual(len(mock_sleep.call_args_list), 5)
|
||||
self.assertEqual(mock_sleep.call_args_list[0][0], (0.1,))
|
||||
self.assertEqual(mock_sleep.call_args_list[1][0], (0.1,))
|
||||
self.assertEqual(mock_sleep.call_args_list[2][0], (0.1,))
|
||||
self.assertEqual(mock_sleep.call_args_list[3][0], (0.2,))
|
||||
self.assertEqual(mock_sleep.call_args_list[4][0], (0.2,))
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateNoIndexChangeWarning, mock_warn), 2)
|
||||
|
||||
@@ -1,978 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import datetime
|
||||
import pprint
|
||||
import copy
|
||||
from unittest.mock import patch
|
||||
|
||||
from tconnectsync.process import process_time_range
|
||||
from tconnectsync.parser.nightscout import EXERCISE_EVENTTYPE, IOB_ACTIVITYTYPE, SLEEP_EVENTTYPE, NightscoutEntry
|
||||
from tconnectsync.features import BASAL, BOLUS, IOB, PROFILES, PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION
|
||||
from tconnectsync.domain.device_settings import Device
|
||||
from tests.secrets import build_secrets
|
||||
from tests.sync.test_profile import DEVICE_PROFILE_A, DEVICE_PROFILE_B, DEVICE_SETTINGS, NS_PROFILE_A, NS_PROFILE_B, build_ns_profile
|
||||
|
||||
|
||||
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
|
||||
from .domain.test_therapy_event import BOLUS_FULL_EXAMPLES, TestCGMTherapyEvent
|
||||
|
||||
class TestProcessTimeRange(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def stub_therapy_timeline(self, time_start, time_end):
|
||||
return copy.deepcopy(TestBasalSync.base)
|
||||
|
||||
def stub_ciq_therapy_events(self, time_start, time_end):
|
||||
return {
|
||||
"event": []
|
||||
}
|
||||
|
||||
def stub_therapy_timeline_csv(self, time_start, time_end):
|
||||
return {
|
||||
"readingData": [],
|
||||
"iobData": [],
|
||||
"basalData": [],
|
||||
"bolusData": []
|
||||
}
|
||||
|
||||
def stub_ws2_basalsuspension(self, time_start, time_end):
|
||||
return {"BasalSuspension": []}
|
||||
|
||||
def stub_last_uploaded_entry(self, event_type, **kwargs):
|
||||
return None
|
||||
|
||||
def stub_last_uploaded_activity(self, activity_type, **kwargs):
|
||||
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.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
|
||||
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 (control-iq suspension)")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 4)
|
||||
|
||||
"""No data in Nightscout. Nothing should be updated in Nightscout without the BASAL feature."""
|
||||
def test_basal_data_not_updated_without_feature(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.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, IOB])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
"""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.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
|
||||
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 (control-iq suspension)")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
|
||||
"""
|
||||
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.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
|
||||
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 (control-iq suspension)")
|
||||
]})
|
||||
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, [])
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
"""No data in Nightscout. Uploads all bolus data from tconnect via the WS2 API."""
|
||||
def test_new_ciq_bolus_data_from_ws2(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
|
||||
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
|
||||
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, [])
|
||||
self.assertEqual(count, 4)
|
||||
|
||||
"""No data in Nightscout. Uploads all bolus data from tconnect via CIQ therapy_events."""
|
||||
def test_new_ciq_bolus_data_from_ciq_therapy_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2022, 8, 9, 12, 0)
|
||||
end = datetime.datetime(2021, 8, 10, 12, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
|
||||
def fake_ciq_therapy_events(time_start, time_end):
|
||||
return {
|
||||
"event": BOLUS_FULL_EXAMPLES
|
||||
}
|
||||
tconnect.controliq.therapy_events = fake_ciq_therapy_events
|
||||
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
|
||||
pprint.pprint(nightscout.uploaded_entries)
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), len(BOLUS_FULL_EXAMPLES))
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
# BGs are excluded because BG is not enabled in features for this test
|
||||
NightscoutEntry.bolus(2.9, 0, "2022-07-21 11:55:24-04:00", notes="Automatic Bolus/Correction"),
|
||||
NightscoutEntry.bolus(4.17, 25, "2022-07-21 12:29:21-04:00", notes="Standard"),
|
||||
# NOTE: the extended bolus is 0.2+0.2 but we currently only surface standard
|
||||
# BUG: inconsistency: we use the completion timestamp as the event timestamp for standard boluses,
|
||||
# but the standard strand time for extended
|
||||
NightscoutEntry.bolus(0.2, 0, "2022-08-09 23:20:04-04:00", notes="Extended 50.00%/0.00 (Override) (Extended)"),
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
"""No data in Nightscout. Nothing should be updated in Nightscout without the BOLUS feature."""
|
||||
def test_bolus_data_not_updated_without_feature(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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, IOB])
|
||||
|
||||
pprint.pprint(nightscout.uploaded_entries)
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
"""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
|
||||
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
|
||||
|
||||
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, [])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
"""No data in Nightscout. Nothing should be updated in Nightscout without the IOB feature."""
|
||||
def test_iob_data_not_updated_without_feature(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
|
||||
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
|
||||
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
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, BOLUS])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
"""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, **kwargs):
|
||||
if activityType == IOB_ACTIVITYTYPE:
|
||||
return iobData[0]
|
||||
return self.stub_last_uploaded_activity(activityType)
|
||||
|
||||
nightscout.last_uploaded_activity = fake_last_uploaded_activity
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[IOB])
|
||||
|
||||
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"
|
||||
])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
"""No pump activity events in Nightscout. New CIQ activity events."""
|
||||
def test_new_ciq_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
def fake_therapy_timeline(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {
|
||||
**TestBasalSync.base,
|
||||
"events": [{
|
||||
"duration": 1200,
|
||||
"eventType": 2, # Exercise
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912 # 2021-05-01 13:45:12-04:00
|
||||
}, {
|
||||
"duration": 30661,
|
||||
"eventType": 1, # Sleep
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619992000 # 2021-05-02 14:46:40-04:00
|
||||
}]
|
||||
}
|
||||
|
||||
tconnect.controliq.therapy_timeline = fake_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
NightscoutEntry.activity(created_at="2021-05-01 13:45:12-04:00", duration=20, reason="Exercise", event_type=EXERCISE_EVENTTYPE),
|
||||
NightscoutEntry.activity(created_at="2021-05-02 14:46:40-04:00", duration=30661/60, reason="Sleep", event_type=SLEEP_EVENTTYPE),
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
"""No pump activity events in Nightscout. New CIQ activity events, but feature is disabled."""
|
||||
def test_no_ciq_activity_events_without_feature(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
def fake_therapy_timeline(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {
|
||||
**TestBasalSync.base,
|
||||
"events": [{
|
||||
"duration": 1200,
|
||||
"eventType": 2, # Exercise
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912 # 2021-05-01 13:45:12-04:00
|
||||
}, {
|
||||
"duration": 30661,
|
||||
"eventType": 1, # Sleep
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619992000 # 2021-05-02 14:46:40-04:00
|
||||
}]
|
||||
}
|
||||
|
||||
tconnect.controliq.therapy_timeline = fake_therapy_timeline
|
||||
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
|
||||
"""
|
||||
Existing Sleep event in Nightscout with shorter duration than current, as well as a past Exercise event.
|
||||
Ensures that the old sleep event is deleted and a new one is created with the correct duration."""
|
||||
def test_existing_ciq_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
def fake_therapy_timeline(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {
|
||||
**TestBasalSync.base,
|
||||
"events": [{
|
||||
"duration": 1200,
|
||||
"eventType": 2, # Exercise
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912 # 2021-05-01 13:45:12-04:00
|
||||
}, {
|
||||
"duration": 4200, # Currently 60 mins (3600), changing to 70 mins (4200)
|
||||
"eventType": 1, # Sleep
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619992000 # 2021-05-02 14:46:40-04:00
|
||||
}]
|
||||
}
|
||||
|
||||
tconnect.controliq.therapy_timeline = fake_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
if event_type == "Sleep":
|
||||
return {
|
||||
"created_at": "2021-05-02 14:46:40-04:00",
|
||||
"duration": 60,
|
||||
"_id": "old_sleep"
|
||||
}
|
||||
elif event_type == "Exercise":
|
||||
return {
|
||||
"created_at": "2021-05-01 13:45:12-04:00",
|
||||
"duration": 20,
|
||||
"_id": "exercise"
|
||||
}
|
||||
return self.stub_last_uploaded_entry(**kwargs)
|
||||
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
# Already exists:
|
||||
# NightscoutEntry.activity(created_at="2021-05-01 13:45:12-04:00", duration=20, reason="Exercise", event_type=EXERCISE_EVENTTYPE),
|
||||
# Updated event duration:
|
||||
NightscoutEntry.activity(created_at="2021-05-02 14:46:40-04:00", duration=70, reason="Sleep", event_type=SLEEP_EVENTTYPE),
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertEqual(len(nightscout.deleted_entries), 1)
|
||||
self.assertListEqual(nightscout.deleted_entries, [
|
||||
"treatments/old_sleep"
|
||||
])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
"""No pump activity events in Nightscout. New WS2 activity events."""
|
||||
def test_new_ws2_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
def fake_basalsuspension(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {"BasalSuspension": [
|
||||
{
|
||||
'EventDateTime': '/Date(1638663490000-0000)/',
|
||||
'SuspendReason': 'site-cart'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1637863616000-0000)/',
|
||||
'SuspendReason': 'alarm'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1638662852000-0000)/',
|
||||
'SuspendReason': 'manual'
|
||||
}
|
||||
]}
|
||||
|
||||
tconnect.ws2.basalsuspension = fake_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 3)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
NightscoutEntry.sitechange(created_at="2021-12-04 16:18:10-05:00", reason="Site/Cartridge Change"),
|
||||
NightscoutEntry.basalsuspension(created_at="2021-11-25 10:06:56-05:00", reason="Empty Cartridge/Pump Shutdown"),
|
||||
NightscoutEntry.basalsuspension(created_at="2021-12-04 16:07:32-05:00", reason="User Suspended")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
"""Existing pump activity events in Nightscout. New WS2 activity events. Only adds new events."""
|
||||
def test_existing_ws2_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
def fake_basalsuspension(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {"BasalSuspension": [
|
||||
{
|
||||
'EventDateTime': '/Date(1638663490000-0000)/',
|
||||
'SuspendReason': 'site-cart'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1637863616000-0000)/',
|
||||
'SuspendReason': 'alarm'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1638662852000-0000)/',
|
||||
'SuspendReason': 'manual'
|
||||
},
|
||||
# This event is new:
|
||||
{
|
||||
'EventDateTime': '/Date(1638672852000-0000)/',
|
||||
'SuspendReason': 'manual'
|
||||
}
|
||||
]}
|
||||
|
||||
tconnect.ws2.basalsuspension = fake_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
if event_type == "Site Change":
|
||||
return {
|
||||
"created_at": "2021-12-04 16:18:10-05:00"
|
||||
}
|
||||
elif event_type == "Basal Suspension":
|
||||
return {
|
||||
"created_at": "2021-12-04 16:07:32-05:00"
|
||||
}
|
||||
return self.stub_last_uploaded_entry(**kwargs)
|
||||
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
NightscoutEntry.basalsuspension(created_at="2021-12-04 18:54:12-05:00", reason="User Suspended")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
"""No pump activity events in nightscout. New WS2 activity events, but only of skipped types. None should be added."""
|
||||
def test_skipped_ws2_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
def fake_basalsuspension(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {"BasalSuspension": [
|
||||
{
|
||||
'EventDateTime': '/Date(1638659343000-0000)/',
|
||||
'SuspendReason': 'basal-profile',
|
||||
},
|
||||
{
|
||||
'Continuation': 'continuation',
|
||||
'EventDateTime': '/Date(1638604800000-0000)/',
|
||||
'SuspendReason': 'previous',
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1638659343000-0000)/',
|
||||
'SuspendReason': 'basal-profile',
|
||||
}
|
||||
]}
|
||||
|
||||
tconnect.ws2.basalsuspension = fake_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
"""Profile present on pump and not in Nightscout with PROFILES feature enabled, adds profile."""
|
||||
def test_pump_profile_added(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
pump_guid = '00000000-0000-0000-0000-000000000001'
|
||||
serial_number = '12345'
|
||||
|
||||
def fake_my_devices():
|
||||
return {
|
||||
serial_number: Device(
|
||||
name='test',
|
||||
model_number=serial_number,
|
||||
status='OK',
|
||||
guid=pump_guid)
|
||||
}
|
||||
tconnect.webui.my_devices = fake_my_devices
|
||||
|
||||
def fake_device_settings_from_guid(guid):
|
||||
if guid != pump_guid:
|
||||
raise RuntimeError('invalid guid')
|
||||
return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS
|
||||
|
||||
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_current_profile(time_start=None, time_end=None):
|
||||
return build_ns_profile({}, '')
|
||||
|
||||
nightscout.current_profile = fake_current_profile
|
||||
|
||||
count = None
|
||||
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
|
||||
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
|
||||
mock_upload_mode.return_value = 'add'
|
||||
mock_serial_number.return_value = serial_number
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1)
|
||||
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
"""Profile present on pump and in Nightscout with PROFILES feature enabled, does not add profile."""
|
||||
def test_pump_profile_not_updated(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
pump_guid = '00000000-0000-0000-0000-000000000001'
|
||||
serial_number = '12345'
|
||||
|
||||
def fake_my_devices():
|
||||
return {
|
||||
serial_number: Device(
|
||||
name='test',
|
||||
model_number=serial_number,
|
||||
status='OK',
|
||||
guid=pump_guid)
|
||||
}
|
||||
tconnect.webui.my_devices = fake_my_devices
|
||||
|
||||
def fake_device_settings_from_guid(guid):
|
||||
if guid != pump_guid:
|
||||
raise RuntimeError('invalid guid')
|
||||
return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS
|
||||
|
||||
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_current_profile(time_start=None, time_end=None):
|
||||
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
nightscout.current_profile = fake_current_profile
|
||||
|
||||
count = None
|
||||
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
|
||||
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
|
||||
mock_upload_mode.return_value = 'add'
|
||||
mock_serial_number.return_value = serial_number
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
"""Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump, adds new profile object."""
|
||||
def test_pump_profile_new_entry_added(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
pump_guid = '00000000-0000-0000-0000-000000000001'
|
||||
serial_number = '12345'
|
||||
|
||||
def fake_my_devices():
|
||||
return {
|
||||
serial_number: Device(
|
||||
name='test',
|
||||
model_number=serial_number,
|
||||
status='OK',
|
||||
guid=pump_guid)
|
||||
}
|
||||
tconnect.webui.my_devices = fake_my_devices
|
||||
|
||||
def fake_device_settings_from_guid(guid):
|
||||
if guid != pump_guid:
|
||||
raise RuntimeError('invalid guid')
|
||||
return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS
|
||||
|
||||
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_current_profile(time_start=None, time_end=None):
|
||||
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
nightscout.current_profile = fake_current_profile
|
||||
|
||||
count = None
|
||||
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
|
||||
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
|
||||
mock_upload_mode.return_value = 'add'
|
||||
mock_serial_number.return_value = serial_number
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1)
|
||||
self.assertEqual(len(nightscout.uploaded_entries["profile"][0]['store']), 2)
|
||||
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A)
|
||||
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['B'], NS_PROFILE_B)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
"""
|
||||
Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump,
|
||||
replaces existing profile object with NIGHTSCOUT_PROFILE_UPLOAD_MODE=replace.
|
||||
"""
|
||||
def test_pump_profile_new_entry_replaced(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
pump_guid = '00000000-0000-0000-0000-000000000001'
|
||||
serial_number = '12345'
|
||||
|
||||
def fake_my_devices():
|
||||
return {
|
||||
serial_number: Device(
|
||||
name='test',
|
||||
model_number=serial_number,
|
||||
status='OK',
|
||||
guid=pump_guid)
|
||||
}
|
||||
tconnect.webui.my_devices = fake_my_devices
|
||||
|
||||
def fake_device_settings_from_guid(guid):
|
||||
if guid != pump_guid:
|
||||
raise RuntimeError('invalid guid')
|
||||
return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS
|
||||
|
||||
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_current_profile(time_start=None, time_end=None):
|
||||
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
|
||||
|
||||
nightscout.current_profile = fake_current_profile
|
||||
|
||||
count = None
|
||||
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
|
||||
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
|
||||
mock_upload_mode.return_value = 'replace'
|
||||
mock_serial_number.return_value = serial_number
|
||||
|
||||
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
|
||||
|
||||
self.assertDictEqual(nightscout.uploaded_entries, {})
|
||||
self.assertEqual(len(nightscout.put_entries["profile"]), 1)
|
||||
self.assertEqual(len(nightscout.put_entries["profile"][0]['store']), 2)
|
||||
self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['A'], NS_PROFILE_A)
|
||||
self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['B'], NS_PROFILE_B)
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user