mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-24 10:14:36 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86b6b28803 | ||
|
|
4409b78890 | ||
|
|
e52cd73549 | ||
|
|
10fca69038 | ||
|
|
1f95dd99f6 | ||
|
|
1e16b7ad01 | ||
|
|
cffee9cdca | ||
|
|
ee85226c2f | ||
|
|
db7d34e62e | ||
|
|
7b17304efc | ||
|
|
d0af9c5c91 | ||
|
|
85303bca82 | ||
|
|
c6820b7a0d | ||
|
|
04c5a37b8d | ||
|
|
31e6737ca7 | ||
|
|
b9fae36dda | ||
|
|
1507398eb0 | ||
|
|
4208ffa709 | ||
|
|
3b66718e1f | ||
|
|
d9e49e1fc8 | ||
|
|
dd9a9ea72c | ||
|
|
d69a760f5b | ||
|
|
df4e9e4ed0 | ||
|
|
98ee986941 | ||
|
|
3f6a0e007d | ||
|
|
48ec97434f | ||
|
|
cf0a88c53a | ||
|
|
a4ab6398f0 | ||
|
|
f67671e1ef | ||
|
|
5e4d81a618 | ||
|
|
cabbc783c7 | ||
|
|
62f1322ef6 | ||
|
|
139ccb1878 | ||
|
|
e60e9ce537 | ||
|
|
6e180d9f2a | ||
|
|
b929d1151e | ||
|
|
4d4ee7b425 | ||
|
|
0c5a6d5453 | ||
|
|
0b7e33db96 | ||
|
|
5d58b23d27 | ||
|
|
40aea810cc | ||
|
|
ec84afdb7a | ||
|
|
fca7f573a3 | ||
|
|
76fc043a2b | ||
|
|
86e07880a9 | ||
|
|
751087373c | ||
|
|
f47421e482 | ||
|
|
dfc39d61bb | ||
|
|
e8a12e291f | ||
|
|
0ebfb2b744 | ||
|
|
8f6d03797a | ||
|
|
6f15ef8857 | ||
|
|
4da4d6e2a6 | ||
|
|
92bed13878 | ||
|
|
955ac68914 | ||
|
|
26d4c63a4d | ||
|
|
81a62b8902 | ||
|
|
2f5775cbfb | ||
|
|
f13929a45a | ||
|
|
a18c2f3d17 | ||
|
|
1b400d3b22 | ||
|
|
d3816b18b2 | ||
|
|
4a6dd5f51d | ||
|
|
51abc15512 |
+9
-4
@@ -1,11 +1,16 @@
|
||||
coverage:
|
||||
status:
|
||||
patch: no
|
||||
changes: no
|
||||
project:
|
||||
default: false
|
||||
tconnectsync:
|
||||
paths: "tconnectsync/"
|
||||
target: 75%
|
||||
paths:
|
||||
- "tconnectsync/"
|
||||
target: '75%'
|
||||
threshold: '5%'
|
||||
tests:
|
||||
paths: "tests/"
|
||||
target: 95%
|
||||
paths:
|
||||
- "tests/"
|
||||
target: '95%'
|
||||
threshold: '5%'
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: 'Setup help: tconnectsync-heroku'
|
||||
about: When experiencing an issue setting up tconnectsync-heroku
|
||||
title: ''
|
||||
labels: heroku, setup help
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the problem**
|
||||
A clear and concise description of the issue you're experiencing.
|
||||
|
||||
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
|
||||
|
||||
**Have you followed the [Troubleshooting steps in the README?](https://github.com/jwoglom/tconnectsync-heroku#troubleshooting)** (Yes or No)
|
||||
|
||||
|
||||
|
||||
**Setup details**
|
||||
|
||||
* **On what platform are you using the t:connect mobile app?** (Android or iOS)
|
||||
* **What version are you using of the t:connect mobile app?**
|
||||
|
||||
|
||||
|
||||
**Heroku log output**
|
||||
If applicable, add the full output from the heroku logs in More > View Logs. ([See the instructions in the README.](https://github.com/jwoglom/tconnectsync-heroku#testing))
|
||||
|
||||
|
||||
|
||||
**Check Login output**
|
||||
If applicable, [please follow the instructions to visit the `check_login` page](https://github.com/jwoglom/tconnectsync-heroku#testing), and copy and paste the output here.
|
||||
|
||||
This file may contain sensitive details like your Nightscout URL and pump serial number. Copy the output into a text editor like Textedit or Notepad first and find-and-replace sensitive strings, like your Nightscout URL and pump serial number, if they appear.
|
||||
|
||||
The output will also contain pump and CGM related information such as delivery events and blood sugars. If you would prefer to keep the contents private, send an email to tconnectsync<at>wogloms.net with the log output so @jwoglom can investigate.
|
||||
|
||||
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: 'Setup help: tconnectsync'
|
||||
about: When experiencing an issue setting up tconnectsync on Windows, Linux, or MacOS.
|
||||
title: ''
|
||||
labels: setup help
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the problem**
|
||||
A clear and concise description of the issue you're experiencing.
|
||||
|
||||
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
|
||||
|
||||
**Setup details**
|
||||
|
||||
* **Operating system:**
|
||||
* **tconnectsync version** (from `--version`):
|
||||
* **On what platform are you using the t:connect mobile app?** (Android or iOS)
|
||||
* **What version are you using of the t:connect mobile app?**
|
||||
|
||||
|
||||
|
||||
**Terminal output**
|
||||
If applicable, add the full output from your terminal from running tconnectsync. Copy the output into a text editor like Textedit or Notepad first and find-and-replace sensitive strings, like your Nightscout URL and pump serial number, if they appear.
|
||||
|
||||
|
||||
|
||||
**Check Login output**
|
||||
If applicable, please run `--check-login` and upload the tconnectsync-check-output.log file as an attachment on the issue. This file should have sensitive details like your Nightscout URL and pump serial number automatically removed, but double-check before uploading.
|
||||
|
||||
This log will contain pump and CGM related information such as delivery events and blood sugars. If you would prefer to keep the contents private, send an email to tconnectsync<at>wogloms.net with the log output so @jwoglom can investigate.
|
||||
|
||||
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -30,7 +30,10 @@ jobs:
|
||||
pipenv install --system
|
||||
- name: Run pipenv check
|
||||
run: |
|
||||
pipenv check
|
||||
# DDoS attacks in wheel and setuptools packages, not relevant
|
||||
pipenv check \
|
||||
--ignore 51499 \
|
||||
--ignore 52495
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
@@ -40,6 +43,14 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -8,3 +8,4 @@ build
|
||||
*.egg-info
|
||||
.env
|
||||
tconnectsync-check-output.log
|
||||
ignore_*
|
||||
Generated
+39
-39
@@ -16,18 +16,18 @@
|
||||
"default": {
|
||||
"arrow": {
|
||||
"hashes": [
|
||||
"sha256:05caf1fd3d9a11a1135b2b6f09887421153b94558e5ef4d090b567b47173ac2b",
|
||||
"sha256:d622c46ca681b5b3e3574fcb60a04e5cc81b9625112d5fb2b44220c36c892177"
|
||||
"sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1",
|
||||
"sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.2.2"
|
||||
"version": "==1.2.3"
|
||||
},
|
||||
"beautifulsoup4": {
|
||||
"hashes": [
|
||||
"sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30",
|
||||
"sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.0'",
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==4.11.1"
|
||||
},
|
||||
"bs4": {
|
||||
@@ -39,27 +39,27 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d",
|
||||
"sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"
|
||||
"sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14",
|
||||
"sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.0'",
|
||||
"version": "==2022.6.15"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2022.9.24"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5",
|
||||
"sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"
|
||||
"sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845",
|
||||
"sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.0'",
|
||||
"version": "==2.1.0"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.1.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
|
||||
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.3"
|
||||
"version": "==3.4"
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
@@ -156,11 +156,11 @@
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f",
|
||||
"sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"
|
||||
"sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5",
|
||||
"sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.20.0"
|
||||
"version": "==0.21.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
@@ -172,11 +172,11 @@
|
||||
},
|
||||
"requests-mock": {
|
||||
"hashes": [
|
||||
"sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970",
|
||||
"sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba"
|
||||
"sha256:2fdbb637ad17ee15c06f33d31169e71bf9fe2bdb7bc9da26185be0dd8d842699",
|
||||
"sha256:59c9c32419a9fb1ae83ec242d98e889c45bd7d7a65d48375cc243ec08441658b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.9.3"
|
||||
"version": "==1.10.0"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
@@ -191,20 +191,20 @@
|
||||
"sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759",
|
||||
"sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.0'",
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.3.2.post1"
|
||||
},
|
||||
"tconnectsync": {
|
||||
"path": ".",
|
||||
"version": "==0.7.1"
|
||||
"version": "==0.8.6"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec",
|
||||
"sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6"
|
||||
"sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc",
|
||||
"sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'",
|
||||
"version": "==1.26.10"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@@ -217,11 +217,11 @@
|
||||
},
|
||||
"jedi": {
|
||||
"hashes": [
|
||||
"sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d",
|
||||
"sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"
|
||||
"sha256:203c1fd9d969ab8f2119ec0a3342e0b49910045abe6af0a3ae83a5764d54639e",
|
||||
"sha256:bae794c30d07f6d910d32a7048af09b5a39ed740918da923c6b780790ebac612"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.18.1"
|
||||
"version": "==0.18.2"
|
||||
},
|
||||
"parso": {
|
||||
"hashes": [
|
||||
@@ -233,27 +233,27 @@
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:859b283c50bde45f5f97829f77a4674d1c1fcd88539364f1b28a37805cfd89c0",
|
||||
"sha256:d8916d3f62a7b67ab353a952ce4ced6a1d2587dfe9ef8ebc30dd7c386751f289"
|
||||
"sha256:535c29c31216c77302877d5120aef6c94ff573748a5b5ca5b1b1f76f5e700c73",
|
||||
"sha256:ced598b222f6f4029c0800cefaa6a17373fb580cd093223003475ce32805c35b"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.2'",
|
||||
"version": "==3.0.30"
|
||||
"version": "==3.0.33"
|
||||
},
|
||||
"ptpython": {
|
||||
"hashes": [
|
||||
"sha256:99636899ab0e4d026e2ecc9368269114f387b4bb5411e57f072b0bde724d9f99",
|
||||
"sha256:eafd4ced27ca5dc370881d4358d1ab5041b32d88d31af8e3c24167fe4af64ed6"
|
||||
"sha256:1ec28ba37959e8e6c4880fcf4748ad5c61341d4c1bfd24a38a4d5b97af1f475a",
|
||||
"sha256:a57b9952eac4a12540a4dfb4ccd4a243403eceb27b0d13245b5e4144c8731d32"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.20"
|
||||
"version": "==3.0.21"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb",
|
||||
"sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"
|
||||
"sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1",
|
||||
"sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.12.0"
|
||||
"version": "==2.13.0"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
|
||||
@@ -37,6 +37,7 @@ Here are a few examples of reasons why you might want to adjust the enabled sync
|
||||
|
||||
* 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_.
|
||||
|
||||
These synchronization features are enabled by default:
|
||||
|
||||
@@ -45,6 +46,7 @@ These synchronization features are enabled by default:
|
||||
|
||||
The following synchronization features can be optionally enabled:
|
||||
|
||||
* `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)
|
||||
@@ -60,7 +62,7 @@ The following synchronization features are under development, [**but are not yet
|
||||
To specify custom synchronization features, pass the names of the desired features to the `--features` flag, e.g.:
|
||||
|
||||
```bash
|
||||
$ tconnectsync --features BASAL BOLUS PUMP_EVENTS
|
||||
$ tconnectsync --features BASAL BOLUS PUMP_EVENTS PROFILES
|
||||
```
|
||||
|
||||
If you're using tconnectsync-heroku, see [this section in its README](https://github.com/jwoglom/tconnectsync-heroku#Updating-synchronization-features).
|
||||
@@ -116,7 +118,10 @@ 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 Debian/Ubuntu based distros, `sudo apt install python3 python3-pip`
|
||||
- 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.
|
||||
@@ -126,6 +131,12 @@ Now install the `tconnectsync` package with pip:
|
||||
```
|
||||
$ pip3 install tconnectsync
|
||||
```
|
||||
To install into a user environment instead of system-wide for a more contained install:
|
||||
````
|
||||
$ pip3 install --user tconnectsync
|
||||
````
|
||||
- This will place the tconnectsync binary file at ``/home/<username>/.local/bin/tconnectsync``
|
||||
|
||||
|
||||
If the pip3 command is not found, run `python3 -m pip install tconnectsync` instead.
|
||||
|
||||
@@ -377,22 +388,27 @@ invoking tconnectsync with no arguments via cron.
|
||||
|
||||
If using Pipenv or a virtualenv, make sure that you either prefix the call to main.py with `pipenv run` or source the `bin/activate` file within the virtualenv, so that the proper dependencies are loaded. If not using any kind of virtualenv, you can instead just install the necessary dependencies as specified inside Pipfile globally.
|
||||
|
||||
An example configuration in `/etc/crontab` which runs every 15 minutes:
|
||||
An system-wide example configuration in `/etc/crontab` which runs every 15 minutes, on the 15 minute mark:
|
||||
|
||||
```bash
|
||||
# m h dom mon dow user command
|
||||
0,15,30,45 * * * * root /path/to/tconnectsync/run.sh
|
||||
```
|
||||
|
||||
An example of a user crontab `crontab -e` if not running system-wide, which runs every 15 minutes:
|
||||
```
|
||||
*/15 * * * * /path/to/tconnectsync/run.sh
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Tandem APIs
|
||||
|
||||
This application utilizes three separate Tandem APIs for obtaining t:connect data, referenced here by the identifying part of their URLs:
|
||||
|
||||
* [**controliq**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/controliq.py) - Contains Control:IQ related data, namely a timeline of all Basal events uploaded by the pump, separated by type (temp basals, algorithmically-updated basals, or profile-updated basals).
|
||||
* [**controliq**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/controliq.py) - Contains Control:IQ related data, namely a timeline of all Basal events uploaded by the pump, separated by type (temp basals, algorithmically-updated basals, or profile-updated basals). Additionally includes CGM and Bolus data.
|
||||
* [**android**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/android.py) - Used internally by the t:connect Android app, these API endpoints were discovered by reverse-engineering the Android app. Most of the API endpoints are used for uploading pump data, and tconnectsync uses one endpoint which returns the most recent event ID uploaded by the pump, so we know when more data has been uploaded.
|
||||
* [**tconnectws2**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/ws2.py) - More legacy than the others, this seems to power the bulk of the main t:connect website. It is used to retrieve a CSV export of non-ControlIQ basal data, as well as bolus and IOB data. (I haven't found any mentions of bolus or IOB data in the Control:IQ-specific API.)
|
||||
* [**tconnectws2**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/ws2.py) - More legacy than the others, this seems to power the bulk of the main t:connect website. It is used as a last resort due to severe performance issues with this API (see https://github.com/jwoglom/tconnectsync/issues/43). We can use it to retrieve a CSV export of non-ControlIQ basal data, as well as bolus and IOB data. It is only used for bolus data as a fallback, and for pump-reported IOB data if requested. Full tracking of pump events also uses a limited version of this API.
|
||||
|
||||
I have only tested tconnectsync with a Tandem pump set in the US Eastern timezone. Tandem's (to us, undocumented) APIs are [a bit loose with timezones](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/parser.py#L15), so please let me know if you notice any timezone-related bugs.
|
||||
## Backfilling t:connect Data
|
||||
@@ -406,3 +422,15 @@ python3 main.py --start-date 2020-01-01 --end-date 2020-03-01
|
||||
In order to bulk-import a lot of data, you may need to use shorter intervals, and invoke tconnectsync multiple times. Tandem's API endpoints occasionally return invalid data if you request too large of a data window which causes tconnectsync to error out mid-way through.
|
||||
|
||||
One oddity when backfilling data is that the Control:IQ specific API endpoints return errors if they are queried before you updated your pump to utilize Control:IQ. This is [partially worked around in tconnectsync's code](https://github.com/jwoglom/tconnectsync/blob/d841c3811aeff3671d941a7d3ff4b80cce6a219e/main.py#L238), but you might need to update the logic if you did not switch to a Control:IQ enabled pump immediately after launch.
|
||||
|
||||
## t:connect API Testing
|
||||
|
||||
To test t:connect API endpoints in a Python shell, you can do something like the following:
|
||||
|
||||
```python
|
||||
import tconnectsync
|
||||
tconnectsync.util.cli.enable_logging()
|
||||
api = tconnectsync.util.cli.get_api()
|
||||
# Make API calls, e.g.
|
||||
therapy_timeline = api.controliq.therapy_timeline('2022-08-01', '2022-08-10')
|
||||
```
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 50%
|
||||
threshold: null
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.7.2
|
||||
version = 0.9.3
|
||||
author = James Woglom
|
||||
author_email = j@wogloms.net
|
||||
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
|
||||
|
||||
@@ -17,7 +17,8 @@ try:
|
||||
TCONNECT_EMAIL,
|
||||
TCONNECT_PASSWORD,
|
||||
NS_URL,
|
||||
NS_SECRET
|
||||
NS_SECRET,
|
||||
NS_SKIP_TLS_VERIFY
|
||||
)
|
||||
from . import secret
|
||||
except Exception:
|
||||
@@ -74,7 +75,7 @@ def main(*args, **kwargs):
|
||||
|
||||
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
|
||||
|
||||
nightscout = NightscoutApi(NS_URL, NS_SECRET)
|
||||
nightscout = NightscoutApi(NS_URL, NS_SECRET, NS_SKIP_TLS_VERIFY)
|
||||
|
||||
if args.check_login:
|
||||
return check_login(tconnect, time_start, time_end)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import datetime
|
||||
from typing import List, Tuple
|
||||
import requests
|
||||
import random
|
||||
import arrow
|
||||
|
||||
from tconnectsync import secret
|
||||
|
||||
@@ -9,6 +11,9 @@ def parse_date(date):
|
||||
return date
|
||||
return (date or datetime.datetime.now()).strftime('%m-%d-%Y')
|
||||
|
||||
def parsed_date_to_arrow(date):
|
||||
return arrow.get(datetime.datetime.strptime(date, '%m-%d-%Y'))
|
||||
|
||||
USER_AGENTS = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36',
|
||||
@@ -97,6 +102,28 @@ def base_session():
|
||||
s.request = wrapped_request.__get__(s, requests.Session)
|
||||
return s
|
||||
|
||||
def days_between(start, end) -> int:
|
||||
diff = arrow.get(end) - arrow.get(start)
|
||||
return diff.days
|
||||
|
||||
# both inclusive
|
||||
def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[str, str]]:
|
||||
ranges = []
|
||||
start = arrow.get(start_a)
|
||||
end = arrow.get(end_a)
|
||||
cur_s = start
|
||||
cur = start
|
||||
while cur <= end:
|
||||
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
|
||||
|
||||
@@ -14,9 +14,12 @@ 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'
|
||||
|
||||
userGuid = None
|
||||
accessToken = None
|
||||
accessTokenExpiresAt = None
|
||||
tconnect_software_ver = None
|
||||
|
||||
def __init__(self, email, password):
|
||||
self.login(email, password)
|
||||
@@ -47,6 +50,22 @@ class ControlIQApi:
|
||||
return True
|
||||
|
||||
def _build_login_data(self, email, password, soup):
|
||||
try:
|
||||
version = soup.select_one("#footer_version").text.strip()
|
||||
self.tconnect_software_ver = version
|
||||
logger.info("Reported tconnect software version: %s" % version)
|
||||
if version != self.LAST_CONFIRMED_SOFTWARE_VERSION:
|
||||
logger.warning("Newer API version than last confirmed working. Saw %s and expected %s" % (version, self.LAST_CONFIRMED_SOFTWARE_VERSION))
|
||||
logger.warning("If you experience any issues, please report them to https://github.com/jwoglom/tconnectsync")
|
||||
except Exception:
|
||||
logger.warning("Unable to find tconnect software version.")
|
||||
contents = "<unknown>"
|
||||
if soup:
|
||||
contents = "%s" % soup.encode_contents()
|
||||
if len(contents) > 1000:
|
||||
contents = "%s[SNIP]%s" % (contents[:500], contents[-500:])
|
||||
logger.info("BeautifulSoup parsed contents: %s" % contents)
|
||||
pass
|
||||
return {
|
||||
"__LASTFOCUS": "",
|
||||
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
|
||||
@@ -105,6 +124,7 @@ class ControlIQApi:
|
||||
|
||||
"""
|
||||
Returns detailed basal event information and reasons for delivery suspension.
|
||||
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
|
||||
"""
|
||||
def therapy_timeline(self, start=None, end=None):
|
||||
startDate = parse_date(start)
|
||||
@@ -125,10 +145,7 @@ class ControlIQApi:
|
||||
startDate = parse_date(start)
|
||||
endDate = parse_date(end)
|
||||
|
||||
return self.get('tconnect/controliq/api/summary/users/%s' % (self.userGuid), {
|
||||
"startDate": startDate,
|
||||
"endDate": endDate
|
||||
})
|
||||
return self.get('tconnect/controliq/api/summary/users/%s?startDate=%s&endDate=%s' % (self.userGuid, startDate, endDate), {})
|
||||
|
||||
"""
|
||||
Returns active account features, including the date when ControlIQ was enabled.
|
||||
|
||||
+71
-21
@@ -1,3 +1,4 @@
|
||||
from typing import Dict, List, Tuple
|
||||
import requests
|
||||
import urllib
|
||||
import datetime
|
||||
@@ -7,6 +8,10 @@ import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment, DeviceSettings
|
||||
from tconnectsync.util import removesuffix, removeprefix
|
||||
from tconnectsync.util.constants import MMOLL_TO_MGDL
|
||||
|
||||
from .common import base_headers, ApiException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,7 +70,7 @@ class WebUIScraper:
|
||||
Returns a mapping between pump/device IDs and information about that device,
|
||||
including the GUID used for obtaining pump settings.
|
||||
"""
|
||||
def my_devices(self):
|
||||
def my_devices(self) -> Dict[str, DeviceSettings]:
|
||||
devices = {}
|
||||
r = self.get('myaccount/my_devices.aspx')
|
||||
soup = BeautifulSoup(r.content, features='lxml')
|
||||
@@ -92,21 +97,20 @@ class WebUIScraper:
|
||||
settings_guid = settings_a.attrs['href'].split('?guid=')[1]
|
||||
|
||||
if serial_number:
|
||||
devices[serial_number] = {
|
||||
'name': device_name,
|
||||
'model_number': model_number,
|
||||
'status': status,
|
||||
'guid': settings_guid
|
||||
}
|
||||
devices[serial_number] = Device(
|
||||
name=device_name,
|
||||
model_number=model_number,
|
||||
status=status,
|
||||
guid=settings_guid)
|
||||
|
||||
return devices
|
||||
|
||||
"""
|
||||
Returns a parsed representation of a pump's settings.
|
||||
Note that pump_guid is NOT the serial number of the pump, and
|
||||
should be obtained from my_devices()[str(serial_number)]['guid']
|
||||
should be obtained from my_devices()[str(serial_number)].guid
|
||||
"""
|
||||
def device_settings_from_guid(self, pump_guid):
|
||||
def device_settings_from_guid(self, pump_guid: str) -> Tuple[List[Profile], DeviceSettings]:
|
||||
profiles = []
|
||||
settings = {}
|
||||
r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid)
|
||||
@@ -122,14 +126,43 @@ class WebUIScraper:
|
||||
else:
|
||||
settings.update(self._parse_settings_tbl(tbl))
|
||||
|
||||
return profiles, settings
|
||||
low_bg_threshold, high_bg_threshold = self._extract_bg_thresholds(settings)
|
||||
|
||||
dev_settings = DeviceSettings(
|
||||
low_bg_threshold=low_bg_threshold,
|
||||
high_bg_threshold=high_bg_threshold,
|
||||
raw_settings=settings
|
||||
)
|
||||
|
||||
return profiles, dev_settings
|
||||
|
||||
def _parse_profile_tbl(self, tbl):
|
||||
def _parse_profile_tbl(self, tbl) -> Profile:
|
||||
profile = {}
|
||||
profile["title"] = self.strip(tbl.select_one('.setting_title').text)
|
||||
profile["active"] = bool(tbl.find(text='Active at the time of upload'))
|
||||
profile["segments"] = []
|
||||
|
||||
def parse_basal_rate(rate) -> float:
|
||||
return float(removesuffix(rate, ' u/hr'))
|
||||
|
||||
def parse_factor(ratio) -> int:
|
||||
return parse_bg_mgdl(removeprefix(ratio, '1u:'))
|
||||
|
||||
def parse_ratio(ratio) -> float:
|
||||
return float(removesuffix(removeprefix(ratio, '1u:'), ' g'))
|
||||
|
||||
def parse_bg_mgdl(bg) -> int:
|
||||
if bg.endswith(' mg/dL'):
|
||||
return float(removesuffix(bg, ' mg/dL'))
|
||||
elif bg.endswith(' mmol/L'):
|
||||
return float(removesuffix(bg, ' mmol/L')) * MMOLL_TO_MGDL
|
||||
raise ValueError(bg)
|
||||
|
||||
def hours_to_mins(text) -> int:
|
||||
hrmin = removesuffix(text, " hours")
|
||||
hr, min = hrmin.split(":", 1)
|
||||
return int(min) + int(hr)*60
|
||||
|
||||
for tr in tbl.select('tr'):
|
||||
# Skip header rows
|
||||
if tr.select_one('.setting_bg'):
|
||||
@@ -149,19 +182,20 @@ class WebUIScraper:
|
||||
t = "12:00 AM"
|
||||
elif display_time == "Noon":
|
||||
t = "12:00 PM"
|
||||
|
||||
segment = {
|
||||
"display_time": display_time,
|
||||
"time": t,
|
||||
"basal_rate": self.strip(tds[1].text),
|
||||
"correction_factor": self.strip(tds[2].text),
|
||||
"carb_ratio": self.strip(tds[3].text),
|
||||
"target_bg": self.strip(tds[4].text)
|
||||
"basal_rate": parse_basal_rate(self.strip(tds[1].text)),
|
||||
"correction_factor": parse_factor(self.strip(tds[2].text)),
|
||||
"carb_ratio": parse_ratio(self.strip(tds[3].text)),
|
||||
"target_bg_mgdl": parse_bg_mgdl(self.strip(tds[4].text))
|
||||
}
|
||||
profile["segments"].append(segment)
|
||||
profile["segments"].append(ProfileSegment(**segment))
|
||||
continue
|
||||
|
||||
if tr.find(text='Calculated Total Daily Basal'):
|
||||
profile["calculated_total_daily_basal"] = self.strip(tds[1].text)
|
||||
profile["calculated_total_daily_basal"] = float(removesuffix(self.strip(tds[1].text), " units"))
|
||||
continue
|
||||
|
||||
# Last row
|
||||
@@ -175,12 +209,12 @@ class WebUIScraper:
|
||||
key = self.strip(key)
|
||||
val = self.strip(val)
|
||||
if key == 'Duration of Insulin':
|
||||
profile["insulin_duration"] = val
|
||||
profile["insulin_duration_min"] = hours_to_mins(val)
|
||||
elif key == 'Carbohydrates':
|
||||
profile["carbohydrates"] = val
|
||||
profile["carbs_enabled"] = self.strip(val.lower()) == "on"
|
||||
|
||||
|
||||
return profile
|
||||
return Profile(**profile)
|
||||
|
||||
def _parse_settings_tbl(self, tbl):
|
||||
outer_tr = tbl.select('tr')[2]
|
||||
@@ -220,11 +254,27 @@ class WebUIScraper:
|
||||
|
||||
return settings
|
||||
|
||||
def _extract_bg_thresholds(self, settings):
|
||||
# Nightscout needs default values
|
||||
low_bg_threshold = 70
|
||||
high_bg_threshold = 180
|
||||
if 'CGM Alerts' in settings:
|
||||
if 'Low Alert' in settings['CGM Alerts']:
|
||||
low = settings['CGM Alerts']['Low Alert']
|
||||
if low['value']:
|
||||
low_bg_threshold = int(low['text'].split(' mg/dL')[0])
|
||||
if 'High Alert' in settings['CGM Alerts']:
|
||||
high = settings['CGM Alerts']['High Alert']
|
||||
if high['value']:
|
||||
high_bg_threshold = int(high['text'].split(' mg/dL')[0])
|
||||
|
||||
return low_bg_threshold, high_bg_threshold
|
||||
|
||||
"""
|
||||
Wraps a call to my_devices to identify the device GUID from the
|
||||
given pump serial, and then returns device_settings_from_guid.
|
||||
"""
|
||||
def device_settings(self, pump_serial):
|
||||
def device_settings(self, pump_serial: str) -> Tuple[List[Profile], DeviceSettings]:
|
||||
devices = self.my_devices()
|
||||
if str(pump_serial) in devices.keys():
|
||||
dev = devices[str(pump_serial)]
|
||||
|
||||
+38
-8
@@ -5,7 +5,7 @@ import logging
|
||||
import time
|
||||
import json
|
||||
|
||||
from .common import base_session, parse_date, base_headers, ApiException
|
||||
from .common import base_session, parse_date, parsed_date_to_arrow, base_headers, days_between, split_days_range, ApiException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,14 +21,14 @@ class WS2Api:
|
||||
self.userGuid = userGuid
|
||||
self.session = base_session()
|
||||
|
||||
def get(self, endpoint):
|
||||
r = self.session.get(self.BASE_URL + endpoint, headers=base_headers())
|
||||
def get(self, endpoint, **kwargs):
|
||||
r = self.session.get(self.BASE_URL + endpoint, headers=base_headers(), **kwargs)
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
|
||||
return r.text
|
||||
|
||||
def get_jsonp(self, endpoint):
|
||||
r = self.session.get(self.BASE_URL + endpoint + '?callback=cb', headers=base_headers())
|
||||
def get_jsonp(self, endpoint, **kwargs):
|
||||
r = self.session.get(self.BASE_URL + endpoint + '?callback=cb', headers=base_headers(), **kwargs)
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
|
||||
|
||||
@@ -71,13 +71,40 @@ class WS2Api:
|
||||
Basal data does NOT appear for the specified time range if using Control-IQ.
|
||||
The ControlIQ API endpoints must be used for basal data instead.
|
||||
However, all other fields are still accessed via this endpoint.
|
||||
|
||||
This has its own built-in retry logic because Tandem's frontend serving
|
||||
the API returns 500s when its backend times out.
|
||||
"""
|
||||
MAX_THERAPY_TIMELINE_DAYS = 2
|
||||
def therapy_timeline_csv(self, start=None, end=None, tries=0):
|
||||
startDate = parse_date(start)
|
||||
endDate = parse_date(end)
|
||||
|
||||
pStart = parsed_date_to_arrow(startDate)
|
||||
pEnd = parsed_date_to_arrow(endDate)
|
||||
if days_between(pStart, pEnd) > self.MAX_THERAPY_TIMELINE_DAYS:
|
||||
ranges = split_days_range(pStart, pEnd, self.MAX_THERAPY_TIMELINE_DAYS)
|
||||
logger.debug("Splitting call to therapy_timeline_csv(%s, %s) into: %s", start, end, ranges)
|
||||
outputs = []
|
||||
for rng in ranges:
|
||||
rStart, rEnd = rng
|
||||
logger.debug("split therapy_timeline_csv(%s, %s)", rStart, rEnd)
|
||||
output = self.therapy_timeline_csv(rStart, rEnd, tries=tries)
|
||||
logger.debug("split therapy_timeline_csv(%s, %s) = %s", rStart, rEnd, ["%s: %s items" % (key, len(val)) for key, val in output.items()])
|
||||
outputs.append(output)
|
||||
full = {}
|
||||
for o in outputs:
|
||||
for key, val in o.items():
|
||||
if key not in full:
|
||||
full[key] = val
|
||||
elif val is not None:
|
||||
full[key] += val
|
||||
|
||||
logger.debug("therapy_timeline_csv merge: %s", ["%s: %s items" % (key, len(val)) for key, val in full.items()])
|
||||
return full
|
||||
|
||||
try:
|
||||
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate))
|
||||
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), timeout=10)
|
||||
except ApiException as e:
|
||||
# This seems to occur as some kind of soft rate-limit.
|
||||
logger.warning("Received ApiException in therapy_timeline_csv: (retry count %d) %s" % (tries, e))
|
||||
@@ -89,6 +116,7 @@ class WS2Api:
|
||||
return self.therapy_timeline_csv(start, end, tries+1)
|
||||
raise e
|
||||
|
||||
logger.debug('req_text: %s', req_text)
|
||||
sections = self._split_empty_sections(req_text)
|
||||
|
||||
readingData = None
|
||||
@@ -124,6 +152,8 @@ class WS2Api:
|
||||
- "manual"
|
||||
- "previous"
|
||||
- "alarm"
|
||||
|
||||
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
|
||||
|
||||
{"BasalSuspension":[{"EventDateTime":"/Date(EPOCH_MS-0000)/", "SuspendReason": "reason"}]}
|
||||
"""
|
||||
@@ -132,7 +162,7 @@ class WS2Api:
|
||||
endDate = parse_date(end)
|
||||
arg = "filterbasal/1" if filterbasal else ""
|
||||
|
||||
return self.get_jsonp('basalsuspension/%s/%s/%s/%s' % (self.userGuid, startDate, endDate, arg))
|
||||
return self.get_jsonp('basalsuspension/%s/%s/%s/%s' % (self.userGuid, startDate, endDate, arg), timeout=10)
|
||||
|
||||
"""
|
||||
Returns info on BasalIQ in JSONP format.
|
||||
@@ -141,4 +171,4 @@ class WS2Api:
|
||||
startDate = parse_date(start)
|
||||
endDate = parse_date(end)
|
||||
|
||||
return self.get_jsonp('basaliqtech/%s/%s/%s' % (self.userGuid, startDate, endDate))
|
||||
return self.get_jsonp('basaliqtech/%s/%s/%s' % (self.userGuid, startDate, endDate), timeout=10)
|
||||
@@ -1,5 +1,6 @@
|
||||
import time
|
||||
import logging
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
from .process import process_time_range
|
||||
@@ -67,7 +68,7 @@ class Autoupdate:
|
||||
# (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(
|
||||
"An event index change was recorded, but no new data was found via the API. " +
|
||||
("%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)))
|
||||
|
||||
@@ -75,7 +76,7 @@ class Autoupdate:
|
||||
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
|
||||
return 1
|
||||
else:
|
||||
logger.warn(AutoupdateFailureWarning("An event index change was recorded, but no new data was found via the API. " +
|
||||
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)))
|
||||
|
||||
@@ -107,7 +108,7 @@ class Autoupdate:
|
||||
# The most likely case here is that the pump isn't uploading right now.
|
||||
if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES:
|
||||
logger.error(AutoupdateNoEventIndexesDetectedError(
|
||||
"No new data event indexes have been detected for %d minutes. " % ((now - self.last_event_time)//60) +
|
||||
"%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."))
|
||||
|
||||
# TODO: restarting doesn't really help anything here.
|
||||
@@ -124,11 +125,11 @@ 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(
|
||||
"No new data has been detected via the API for %d minutes. " % (now - self.last_successful_process_time_range)//60 +
|
||||
"%s: No new data has been detected via the API for %d minutes. " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60) +
|
||||
"tconnectsync might not be functioning properly."))
|
||||
|
||||
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
|
||||
logger.error("%s: Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE" % datetime.datetime.now())
|
||||
return 1
|
||||
|
||||
# Track how long we've been retrying
|
||||
@@ -142,7 +143,7 @@ class Autoupdate:
|
||||
# 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.warn(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))
|
||||
@@ -203,4 +204,4 @@ class AutoupdateNoNewDataDetectedError(AutoupdateError):
|
||||
pass
|
||||
|
||||
class AutoupdateNoIndexChangeWarning(AutoupdateWarning):
|
||||
pass
|
||||
pass
|
||||
|
||||
+43
-23
@@ -2,9 +2,10 @@ import sys
|
||||
import time
|
||||
import arrow
|
||||
import logging
|
||||
import traceback
|
||||
import pkg_resources
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
from pprint import pformat as pformat_base
|
||||
|
||||
from .nightscout import NightscoutApi
|
||||
from .parser.nightscout import BASAL_EVENTTYPE, BOLUS_EVENTTYPE
|
||||
@@ -21,7 +22,7 @@ Attempts to authenticate with each t:connect API,
|
||||
and returns the output of a sample API call from each.
|
||||
Also attempts to connect to the Nightscout API.
|
||||
"""
|
||||
def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
errors = 0
|
||||
|
||||
loglines = []
|
||||
@@ -29,6 +30,13 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
print(*args)
|
||||
loglines.append(" ".join([str(i) for i in args]) + "\n")
|
||||
|
||||
def log_err(e):
|
||||
try:
|
||||
out = ''.join(list(traceback.TracebackException.from_exception(e).format()))
|
||||
log(out)
|
||||
except Exception:
|
||||
log("could not log exception traceback: {}".format(e))
|
||||
|
||||
def debug(*args):
|
||||
if verbose:
|
||||
print(*args)
|
||||
@@ -46,7 +54,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
|
||||
except ImportError as e:
|
||||
log("Error: Unable to load config file. Please check your .env file or environment variables")
|
||||
log(e)
|
||||
log_err(e)
|
||||
|
||||
if not TCONNECT_EMAIL or TCONNECT_EMAIL == 'email@email.com':
|
||||
log("Error: You have not specified a TCONNECT_EMAIL")
|
||||
@@ -76,9 +84,12 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
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(e)
|
||||
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...")
|
||||
@@ -96,7 +107,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
lastBasalDuration = processed_tt[-1]['duration_mins']
|
||||
except Exception as e:
|
||||
log("Error occurred querying ControlIQ therapy_timeline:")
|
||||
log(e)
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
log("Querying ControlIQ therapy_events...")
|
||||
@@ -105,32 +116,37 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
debug("controliq therapy_events: \n%s" % pformat(androidevents))
|
||||
except Exception as e:
|
||||
log("Error occurred querying ControlIQ therapy_events:")
|
||||
log(e)
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
|
||||
log("Logging in to t:connect WS2 API...")
|
||||
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
|
||||
except Exception as e:
|
||||
log("Error occurred querying WS2 API:")
|
||||
log(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_err(e)
|
||||
errors += 1
|
||||
|
||||
log("Querying WS2 therapy_timeline_csv...")
|
||||
lastReadingTime = None
|
||||
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:")
|
||||
log(e)
|
||||
errors += 1
|
||||
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("-----")
|
||||
|
||||
@@ -144,7 +160,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
debug("Android last uploaded event: \n%s" % pformat(event))
|
||||
except Exception as e:
|
||||
log("Error occurred querying Android API:")
|
||||
log(e)
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
@@ -162,7 +178,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=False):
|
||||
debug("Nightscout last uploaded bolus: \n%s" % pformat(last_upload_bolus))
|
||||
except Exception as e:
|
||||
log("Error occurred querying Nightscout API:")
|
||||
log(e)
|
||||
log_err(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
@@ -216,4 +232,8 @@ def run_sanitize(s, sanitizedData):
|
||||
for k, v in sanitizedData.items():
|
||||
if v and len(str(v)) > 0:
|
||||
ret = ret.replace(str(v), '[%s]' % k)
|
||||
return ret
|
||||
return ret
|
||||
|
||||
def pformat(*args, **kwargs):
|
||||
kwargs['width'] = 160
|
||||
return pformat_base(*args, **kwargs)
|
||||
@@ -0,0 +1,25 @@
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bolus:
|
||||
description: str
|
||||
complete: str # "1" / "0"
|
||||
completion: str
|
||||
request_time: str # _datetime_parse timestamp
|
||||
completion_time: str # _datetime_parse timestamp
|
||||
insulin: str
|
||||
requested_insulin: str
|
||||
carbs: str
|
||||
bg: str # potentially ""
|
||||
user_override: str
|
||||
extended_bolus: str # "1" / "0"
|
||||
bolex_completion_time: str
|
||||
bolex_start_time: str
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
@property
|
||||
def is_extended_bolus(self):
|
||||
return self.extended_bolus == "1"
|
||||
@@ -0,0 +1,44 @@
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
name: str
|
||||
model_number: str
|
||||
status: str
|
||||
guid: Optional[str]
|
||||
|
||||
@dataclass
|
||||
class ProfileSegment:
|
||||
display_time: str # Identical to time except written out as Midnight or Noon
|
||||
time: str
|
||||
basal_rate: float # _ u/hr
|
||||
correction_factor: int # 1u: _ mg/dL
|
||||
carb_ratio: float # 1u: _ g
|
||||
target_bg_mgdl: int
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
title: str
|
||||
active: bool
|
||||
segments: List[ProfileSegment]
|
||||
calculated_total_daily_basal: float # in units
|
||||
insulin_duration_min: int
|
||||
carbs_enabled: bool
|
||||
|
||||
def activeProfile(self):
|
||||
p = self.copy()
|
||||
p.active = True
|
||||
return p
|
||||
|
||||
def copy(self):
|
||||
p = replace(self)
|
||||
p.segments = [replace(s) for s in p.segments]
|
||||
return p
|
||||
|
||||
# Settings stored globally in the pump that are stored per-profile in Nightscout
|
||||
@dataclass
|
||||
class DeviceSettings:
|
||||
low_bg_threshold: int
|
||||
high_bg_threshold: int
|
||||
raw_settings: dict
|
||||
@@ -0,0 +1,483 @@
|
||||
import arrow
|
||||
|
||||
from tconnectsync.domain.bolus import Bolus
|
||||
|
||||
from ..secret import TIMEZONE_NAME
|
||||
|
||||
|
||||
def _datetime_parse(date):
|
||||
# consistent format with ws2 endpoint
|
||||
return arrow.get(date, tzinfo=TIMEZONE_NAME).format("YYYY-MM-DD HH:mm:ssZZ")
|
||||
|
||||
class TherapyEvent:
|
||||
type = None
|
||||
eventDateTime = None
|
||||
sourceRecId = None
|
||||
|
||||
def parse(self, json):
|
||||
self.type = json['type']
|
||||
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
|
||||
egv = None
|
||||
"""
|
||||
{
|
||||
"eventDateTime": "2022-07-21T00:00:08",
|
||||
"eventID": 256,
|
||||
"requestDateTime": "0001-01-01T00:00:00",
|
||||
"type": "CGM",
|
||||
"description": "EGV",
|
||||
"sourceRecId": 0,
|
||||
"eventTypeId": 0,
|
||||
"deviceType": "t:slim X2 Insulin Pump",
|
||||
"serialNumber": "xxx",
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0,
|
||||
"egv": {
|
||||
"estimatedGlucoseValue": 174,
|
||||
"hypo": 0,
|
||||
"belowTarget": 0,
|
||||
"withinTarget": 1,
|
||||
"aboveTarget": 0,
|
||||
"hyper": 0
|
||||
}
|
||||
},
|
||||
"""
|
||||
@classmethod
|
||||
def parse(_, json):
|
||||
self = CGMTherapyEvent()
|
||||
TherapyEvent.parse(self, json)
|
||||
self.eventID = json['eventID']
|
||||
self.egv = json['egv']['estimatedGlucoseValue']
|
||||
return self
|
||||
|
||||
class BGTherapyEvent(TherapyEvent):
|
||||
eventID = None
|
||||
egv = None
|
||||
"""
|
||||
{
|
||||
'bg': 160, # note in EGV
|
||||
'cgmCalibration': 1, # not in EGV
|
||||
'description': 'BG',
|
||||
'deviceType': 't:slim X2 Insulin Pump',
|
||||
'eventDateTime': '2022-08-20T07:25:24',
|
||||
'eventTypeId': 16,
|
||||
'indexId': 844955,
|
||||
'interactive': 0,
|
||||
'iob': 0.75,
|
||||
'note': { 'active': False,
|
||||
'eventId': 0, # different location than EGV
|
||||
'eventTypeId': 16,
|
||||
'id': 0,
|
||||
'indexId': '',
|
||||
'sourceRecordId': 0},
|
||||
'requestDateTime': '0001-01-01T00:00:00',
|
||||
'serialNumber': 'xxx',
|
||||
'sourceRecId': 793549667,
|
||||
'tempRateActivated': 0,
|
||||
'tempRateCompleted': 0,
|
||||
'tempRateId': 0,
|
||||
'type': 'BG',
|
||||
'uploadId': 748700213}
|
||||
"""
|
||||
@classmethod
|
||||
def parse(_, json):
|
||||
self = BGTherapyEvent()
|
||||
TherapyEvent.parse(self, json)
|
||||
self.eventID = json['note']['eventId']
|
||||
# This is probably not how we want to provide CGM calibrations to Nightscout,
|
||||
# but will just include it as egv data for now to keep the thing from crashing :)
|
||||
self.egv = json['bg']
|
||||
return self
|
||||
|
||||
class BolusTherapyEvent(TherapyEvent):
|
||||
bolusRequestOptions = None
|
||||
REQUEST_AUTOMATIC = "Automatic Bolus/Correction"
|
||||
REQUEST_STANDARD = "Standard"
|
||||
|
||||
bolusType = None
|
||||
TYPE_AUTOMATIC = "Automatic Correction"
|
||||
TYPE_CARB = "Carb"
|
||||
|
||||
carbSize = None
|
||||
correctionBolusSize = None
|
||||
foodBolusSize = None
|
||||
|
||||
insulinDelivered = None
|
||||
insulinRequested = None
|
||||
completionDateTime = None
|
||||
|
||||
completionStatus = None
|
||||
STATUS_COMPLETED = "Completed"
|
||||
|
||||
eventHistoryReportDetails = None
|
||||
|
||||
standardPercent = None
|
||||
sourceRecId = None
|
||||
|
||||
@classmethod
|
||||
def parse(_, json):
|
||||
self = BolusTherapyEvent()
|
||||
TherapyEvent.parse(self, json)
|
||||
self.description = json.get("description")
|
||||
self.complete = json.get("standard", {}).get("bolusIsComplete")
|
||||
self.completion = json.get("standard", {}).get("completionStatusDesc")
|
||||
self.request_time = json.get("requestDateTime")
|
||||
self.completion_time = json.get("standard", {}).get("insulinDelivered", {}).get("completionDateTime")
|
||||
# TODO: separate extended vs standard bolus into separate fields
|
||||
self.insulin = json.get("standard", {}).get("insulinDelivered", {}).get("value")
|
||||
self.requested_insulin = json.get("standard", {}).get("insulinRequested")
|
||||
self.carbs = json.get("carbSize")
|
||||
self.bg = json.get("bg")
|
||||
self.user_override = json.get("userOverride")
|
||||
self.extended_bolus = json.get("bolusRequestOptions") == "Extended"
|
||||
if self.extended_bolus and self.complete:
|
||||
# TODO(https://github.com/jwoglom/tconnectsync/issues/19): read more extended bolus info
|
||||
self.complete = json.get("bolex", {}).get("extendedBolusIsComplete")
|
||||
self.completion = json.get("bolex", {}).get("completionStatusDesc")
|
||||
self.bolex_completion_time = json.get("bolex", {}).get("insulinDelivered", {}).get("completionDateTime")
|
||||
self.bolex_start_time = json.get("bolex", {}).get("bolexStartDateTime")
|
||||
else:
|
||||
self.bolex_completion_time = ""
|
||||
self.bolex_start_time = ""
|
||||
return self
|
||||
|
||||
def to_bolus(self):
|
||||
return Bolus(
|
||||
description=self.description,
|
||||
complete="1" if self.complete else "0",
|
||||
completion=self.completion or "",
|
||||
request_time=_datetime_parse(self.request_time),
|
||||
completion_time=_datetime_parse(self.completion_time),
|
||||
insulin=str(self.insulin),
|
||||
requested_insulin=str(self.requested_insulin),
|
||||
carbs=str(self.carbs or "0"), # Nightscout expects non-empty carbs
|
||||
bg=str(self.bg or ""),
|
||||
user_override=str(self.user_override),
|
||||
extended_bolus="1" if self.extended_bolus else "0",
|
||||
bolex_completion_time=_datetime_parse(self.bolex_completion_time) if self.bolex_completion_time else "",
|
||||
bolex_start_time=_datetime_parse(self.bolex_start_time) if self.bolex_start_time else ""
|
||||
)
|
||||
|
||||
"""
|
||||
Correction:
|
||||
{
|
||||
"actualTotalBolusRequested": 2.9,
|
||||
"bg": 254,
|
||||
"bolusRequestOptions": "Automatic Bolus/Correction",
|
||||
"bolusType": "Automatic Correction",
|
||||
"carbSize": 0,
|
||||
"correctionBolusSize": 2.9,
|
||||
"correctionFactor": 30,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 0,
|
||||
"eventDateTime": "2022-07-21T11:53:08",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
|
||||
"eventHistoryReportEventDesc": "Correction Bolus",
|
||||
"foodBolusSize": 0,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "572946",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": false
|
||||
},
|
||||
"requestDateTime": "2022-07-21T11:53:08",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-07-21T11:55:24",
|
||||
"value": 2.9
|
||||
},
|
||||
"foodDelivered": 0,
|
||||
"correctionDelivered": 2.9,
|
||||
"insulinRequested": 2.9,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3361,
|
||||
"bolusCompletionId": 3361
|
||||
},
|
||||
"standardPercent": 100,
|
||||
"targetBG": 110,
|
||||
"userOverride": 0,
|
||||
"type": "Bolus",
|
||||
"description": "Automatic Bolus/Correction",
|
||||
"sourceRecId": 1171791787,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
},
|
||||
Standard:
|
||||
{
|
||||
"actualTotalBolusRequested": 4.17,
|
||||
"bolusRequestOptions": "Standard",
|
||||
"bolusType": "Carb",
|
||||
"carbSize": 25,
|
||||
"correctionBolusSize": 0,
|
||||
"correctionFactor": 30,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 0,
|
||||
"eventDateTime": "2022-07-21T12:27:36",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
|
||||
"eventHistoryReportEventDesc": "Food Bolus",
|
||||
"foodBolusSize": 4.17,
|
||||
"iob": 2.62,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "573042",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": false
|
||||
},
|
||||
"requestDateTime": "2022-07-21T12:27:36",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-07-21T12:29:21",
|
||||
"value": 4.17
|
||||
},
|
||||
"foodDelivered": 4.17,
|
||||
"correctionDelivered": 0,
|
||||
"insulinRequested": 4.17,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3362,
|
||||
"bolusCompletionId": 3362
|
||||
},
|
||||
"standardPercent": 100,
|
||||
"targetBG": 110,
|
||||
"userOverride": 0,
|
||||
"type": "Bolus",
|
||||
"description": "Standard",
|
||||
"sourceRecId": 1171853319,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
},
|
||||
Extended bolus incomplete:
|
||||
{
|
||||
"actualTotalBolusRequested": 0.4,
|
||||
"bg": 131,
|
||||
"bolex": {
|
||||
"size": 0.2,
|
||||
"bolexStartDateTime": "2022-08-09T23:20:04",
|
||||
"iob": 0,
|
||||
"completionStatusId": 0,
|
||||
"extendedBolusIsComplete": 0,
|
||||
"insulinRequested": 0,
|
||||
"bolexCompletionId": 0
|
||||
},
|
||||
"bolusRequestOptions": "Extended",
|
||||
"bolusType": "Carb",
|
||||
"carbSize": 0,
|
||||
"correctionBolusSize": 0.0,
|
||||
"correctionFactor": 30.0,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 15,
|
||||
"eventDateTime": "2022-08-09T23:19:15",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
|
||||
"eventHistoryReportEventDesc": "Food Bolus: 50% Extended 15 mins",
|
||||
"foodBolusSize": 0.0,
|
||||
"iob": 5.87,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "631597",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": false
|
||||
},
|
||||
"requestDateTime": "2022-08-09T23:19:15",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-08-09T23:20:04",
|
||||
"value": 0.2
|
||||
},
|
||||
"foodDelivered": 0.0,
|
||||
"correctionDelivered": 0.0,
|
||||
"insulinRequested": 0.2,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3636.0,
|
||||
"bolusCompletionId": 3636.0
|
||||
},
|
||||
"standardPercent": 50.0,
|
||||
"targetBG": 110,
|
||||
"userOverride": 1,
|
||||
"type": "Bolus",
|
||||
"description": "Extended 50.00%/0.00",
|
||||
"sourceRecId": 1209631944,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
}
|
||||
Extended bolus (complete):
|
||||
{
|
||||
"actualTotalBolusRequested": 0.4,
|
||||
"bg": 131,
|
||||
"bolex": {
|
||||
"size": 0.2,
|
||||
"bolexStartDateTime": "2022-08-09T23:20:04",
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-08-09T23:35:03",
|
||||
"value": 0.2
|
||||
},
|
||||
"iob": 5.7,
|
||||
"completionStatusId": 3.0,
|
||||
"completionStatusDesc": "Completed",
|
||||
"extendedBolusIsComplete": 1,
|
||||
"insulinRequested": 0.2,
|
||||
"bolexCompletionId": 16757133
|
||||
},
|
||||
"bolusRequestOptions": "Extended",
|
||||
"bolusType": "Carb",
|
||||
"carbSize": 0,
|
||||
"correctionBolusSize": 0.0,
|
||||
"correctionFactor": 30.0,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 15,
|
||||
"eventDateTime": "2022-08-09T23:19:15",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
|
||||
"eventHistoryReportEventDesc": "Food Bolus: 50% Extended 15 mins",
|
||||
"foodBolusSize": 0.0,
|
||||
"iob": 5.87,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "631597",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": false
|
||||
},
|
||||
"requestDateTime": "2022-08-09T23:19:15",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-08-09T23:20:04",
|
||||
"value": 0.2
|
||||
},
|
||||
"foodDelivered": 0.0,
|
||||
"correctionDelivered": 0.0,
|
||||
"insulinRequested": 0.2,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3636.0,
|
||||
"bolusCompletionId": 3636.0
|
||||
},
|
||||
"standardPercent": 50.0,
|
||||
"targetBG": 110,
|
||||
"userOverride": 1,
|
||||
"type": "Bolus",
|
||||
"description": "Extended 50.00%/0.00",
|
||||
"sourceRecId": 1209631944,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
}
|
||||
CGM Calibration (Therapy Event Type BG):
|
||||
{ 'bg': 160,
|
||||
'cgmCalibration': 1,
|
||||
'description': 'BG',
|
||||
'deviceType': 't:slim X2 Insulin Pump',
|
||||
'eventDateTime': '2022-08-20T07:25:24',
|
||||
'eventTypeId': 16,
|
||||
'indexId': 844955,
|
||||
'interactive': 0,
|
||||
'iob': 0.75,
|
||||
'note': { 'active': False,
|
||||
'eventId': 0,
|
||||
'eventTypeId': 16,
|
||||
'id': 0,
|
||||
'indexId': '',
|
||||
'sourceRecordId': 0},
|
||||
'requestDateTime': '0001-01-01T00:00:00',
|
||||
'serialNumber': 'xxx',
|
||||
'sourceRecId': 793549667,
|
||||
'tempRateActivated': 0,
|
||||
'tempRateCompleted': 0,
|
||||
'tempRateId': 0,
|
||||
'type': 'BG',
|
||||
'uploadId': 0}
|
||||
"""
|
||||
|
||||
class BasalTherapyEvent(TherapyEvent):
|
||||
"""
|
||||
{
|
||||
'basalRate': {
|
||||
'duration': 0,
|
||||
'percent': 0,
|
||||
'value': 0.0
|
||||
},
|
||||
'displayInHistory': 0,
|
||||
'eventDateTime': '2022-12-02T00:00:00',
|
||||
'note': {
|
||||
'id': 0,
|
||||
'indexId': '16403',
|
||||
'eventTypeId': 90,
|
||||
'sourceRecordId': 0,
|
||||
'eventId': 0,
|
||||
'active': False
|
||||
},
|
||||
'noteDate': {},
|
||||
'requestDateTime': '0001-01-01T00:00:00',
|
||||
'type': 'Basal',
|
||||
'description': 'NDE',
|
||||
'sourceRecId': xxx,
|
||||
'eventTypeId': 0,
|
||||
'indexId': 0,
|
||||
'uploadId': 0,
|
||||
'interactive': 1,
|
||||
'tempRateId': 0,
|
||||
'tempRateCompleted': 0,
|
||||
'tempRateActivated': 0
|
||||
}
|
||||
"""
|
||||
basalRateValue = None
|
||||
basalRatePercent = None
|
||||
basalRateDuration = None
|
||||
eventTime = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def parse(_, json):
|
||||
self = CGMTherapyEvent()
|
||||
TherapyEvent.parse(self, json)
|
||||
if 'basalRate' in json:
|
||||
self.basalRateValue = json['basalRate']['value']
|
||||
self.basalRatePercent = json['basalRate']['percent']
|
||||
self.basalRateDuration = json['basalRate']['duration']
|
||||
self.eventTime = json['eventDateTime']
|
||||
return self
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
class Time:
|
||||
def __init__(self, hour: int, min: int):
|
||||
self.hour = hour
|
||||
self.min = min
|
||||
|
||||
@classmethod
|
||||
def parse(cls, input):
|
||||
if ' ' not in input:
|
||||
raise ValueError('unable to parse time: %s' % input)
|
||||
|
||||
hrmin, ampm = input.split(' ')
|
||||
|
||||
hr, min = hrmin.split(':')
|
||||
hr = int(hr)
|
||||
min = int(min)
|
||||
|
||||
if ampm.lower() == 'pm':
|
||||
hr += 12
|
||||
elif ampm.lower() != 'am':
|
||||
raise ValueError('unable to parse time: %s' % input)
|
||||
|
||||
return cls(hr, min)
|
||||
@@ -7,17 +7,22 @@ IOB = "IOB"
|
||||
BOLUS_BG = "BOLUS_BG"
|
||||
CGM = "CGM"
|
||||
PUMP_EVENTS = "PUMP_EVENTS"
|
||||
PUMP_EVENTS_BASAL_SUSPENSION = "PUMP_EVENTS_BASAL_SUSPENSION"
|
||||
PROFILES = "PROFILES"
|
||||
|
||||
DEFAULT_FEATURES = [
|
||||
BASAL,
|
||||
BOLUS
|
||||
BOLUS,
|
||||
PUMP_EVENTS
|
||||
]
|
||||
|
||||
ALL_FEATURES = [
|
||||
BASAL,
|
||||
BOLUS,
|
||||
IOB,
|
||||
PUMP_EVENTS
|
||||
PUMP_EVENTS,
|
||||
PUMP_EVENTS_BASAL_SUSPENSION,
|
||||
PROFILES
|
||||
]
|
||||
|
||||
|
||||
|
||||
+110
-45
@@ -1,24 +1,39 @@
|
||||
import sys
|
||||
import datetime
|
||||
import requests
|
||||
import hashlib
|
||||
import time
|
||||
import urllib.parse
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from .api.common import ApiException
|
||||
from .parser.nightscout import ENTERED_BY
|
||||
|
||||
# try:
|
||||
# from .secret import NS_URL, NS_SECRET
|
||||
# except Exception:
|
||||
# print('Unable to import Nightscout secrets from secret.py')
|
||||
# sys.exit(1)
|
||||
def format_datetime(date):
|
||||
return arrow.get(date).isoformat()
|
||||
|
||||
def time_range(field_name, start_time, end_time, t_to_space=False):
|
||||
def fmt(date):
|
||||
ret = format_datetime(date)
|
||||
if t_to_space:
|
||||
return ret.replace('T', ' ')
|
||||
return ret
|
||||
arg = ''
|
||||
if start_time:
|
||||
arg += '&find[%s][$gte]=%s' % (field_name, fmt(start_time))
|
||||
if end_time:
|
||||
arg += '&find[%s][$lte]=%s' % (field_name, fmt(end_time))
|
||||
return arg
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class NightscoutApi:
|
||||
def __init__(self, url, secret):
|
||||
def __init__(self, url, secret, skip_verify=False):
|
||||
self.url = url
|
||||
self.secret = secret
|
||||
self.verify = False if skip_verify else None
|
||||
|
||||
|
||||
def upload_entry(self, ns_format, entity='treatments'):
|
||||
@@ -26,63 +41,99 @@ class NightscoutApi:
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
}, verify=self.verify)
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "Nightscout upload response: %s" % r.text)
|
||||
raise ApiException(r.status_code, "Nightscout upload %s response: %s" % (r.status_code, r.text))
|
||||
|
||||
def delete_entry(self, entity):
|
||||
r = requests.delete(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json={}, headers={
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
}, verify=self.verify)
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "Nightscout delete response: %s" % r.text)
|
||||
raise ApiException(r.status_code, "Nightscout delete %s response: %s" % (r.status_code, r.text))
|
||||
|
||||
def put_entry(self, ns_format, entity):
|
||||
r = requests.put(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
}, verify=self.verify)
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "Nightscout put response: %s" % r.text)
|
||||
raise ApiException(r.status_code, "Nightscout put %s response: %s" % (r.status_code, r.text))
|
||||
|
||||
def last_uploaded_entry(self, eventType):
|
||||
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + '&ts=' + str(time.time())), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
if latest.status_code != 200:
|
||||
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry response: %s" % latest.text)
|
||||
def last_uploaded_entry(self, eventType, 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/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + dateFilter + '&ts=' + str(time.time())), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
}, verify=self.verify)
|
||||
if latest.status_code != 200:
|
||||
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
|
||||
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)
|
||||
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
|
||||
|
||||
def last_uploaded_bg_entry(self):
|
||||
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + '&ts=' + str(time.time())), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
if latest.status_code != 200:
|
||||
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry response: %s" % latest.text)
|
||||
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)
|
||||
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&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:
|
||||
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
|
||||
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
|
||||
|
||||
def last_uploaded_activity(self, activityType):
|
||||
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + '&ts=' + str(time.time())), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
if latest.status_code != 200:
|
||||
raise ApiException(latest.status_code, "Nightscout activity response: %s" % latest.text)
|
||||
def last_uploaded_activity(self, activityType, 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/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + dateFilter + '&ts=' + str(time.time())), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
}, verify=self.verify)
|
||||
if latest.status_code != 200:
|
||||
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
|
||||
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
|
||||
|
||||
"""
|
||||
Returns general status information about the Nightscout server.
|
||||
@@ -90,7 +141,21 @@ class NightscoutApi:
|
||||
def api_status(self):
|
||||
status = requests.get(urljoin(self.url, 'api/v1/status.json'), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
}, verify=self.verify)
|
||||
if status.status_code != 200:
|
||||
raise Exception('HTTP error status code (%d) from Nightscout: %s' % (status.status_code, status.text))
|
||||
return status.json()
|
||||
return status.json()
|
||||
|
||||
"""
|
||||
Returns information on the currently configured Nightscout profile data store
|
||||
(contains all profiles in Nightscout under one mongo object).
|
||||
"""
|
||||
def current_profile(self, time_start=None, time_end=None):
|
||||
r = requests.get(urljoin(self.url, 'api/v1/profile/current?api_secret=' + self.secret), json={}, headers={
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
}, verify=self.verify)
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "Nightscout current_profile %s response: %s" % (r.status_code, r.text))
|
||||
return r.json()
|
||||
@@ -0,0 +1,27 @@
|
||||
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent, BGTherapyEvent, BasalTherapyEvent
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def split_therapy_events(ciqTherapyEvents):
|
||||
bolusEvents = []
|
||||
cgmEvents = []
|
||||
bgEvents = []
|
||||
basalEvents = []
|
||||
for e in ciqTherapyEvents['event']:
|
||||
event = TConnectEntry.parse_therapy_event(e)
|
||||
if isinstance(event, BolusTherapyEvent):
|
||||
bolusEvents.append(event)
|
||||
elif isinstance(event, CGMTherapyEvent):
|
||||
cgmEvents.append(event)
|
||||
elif isinstance(event, BGTherapyEvent):
|
||||
bgEvents.append(event)
|
||||
elif isinstance(event, BasalTherapyEvent):
|
||||
basalEvents.append(event)
|
||||
|
||||
|
||||
logger.debug("split_therapy_events: %d bolus, %d CGM, %d BG, %d basal" % (len(bolusEvents), len(cgmEvents), len(bgEvents), len(basalEvents)))
|
||||
# TODO: BG events (CGM Calibration) values are not currently returned from ciq_therapy_events.py
|
||||
return bolusEvents, cgmEvents
|
||||
@@ -1,5 +1,8 @@
|
||||
import arrow
|
||||
|
||||
from ..domain.device_settings import Profile, DeviceSettings
|
||||
from ..secret import TIMEZONE_NAME, NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE
|
||||
|
||||
ENTERED_BY = "Pump (tconnectsync)"
|
||||
|
||||
BASAL_EVENTTYPE = "Temp Basal"
|
||||
@@ -48,7 +51,7 @@ class NightscoutEntry:
|
||||
}
|
||||
if bg:
|
||||
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
|
||||
raise InvalidBolusTypeException
|
||||
raise InvalidBolusTypeException("bg_type: %s (%s)" % (bg_type, data))
|
||||
|
||||
data.update({
|
||||
"glucose": str(bg),
|
||||
@@ -106,6 +109,76 @@ class NightscoutEntry:
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
}
|
||||
|
||||
# Tandem-scraped profile to Nightscout profile store entry
|
||||
@staticmethod
|
||||
def profile_store(profile: Profile, device_settings: DeviceSettings) -> dict:
|
||||
return {
|
||||
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
|
||||
"dia": "%s" % (profile.insulin_duration_min / 60),
|
||||
"carbratio": [
|
||||
{
|
||||
"time": tandem_to_ns_time(segment.time),
|
||||
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
|
||||
"value": segment.carb_ratio
|
||||
} for segment in profile.segments
|
||||
],
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
"sens": [ # Correction factor
|
||||
{
|
||||
"time": tandem_to_ns_time(segment.time),
|
||||
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
|
||||
"value": segment.correction_factor
|
||||
} for segment in profile.segments
|
||||
],
|
||||
"basal": [
|
||||
{
|
||||
"time": tandem_to_ns_time(segment.time),
|
||||
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
|
||||
"value": segment.basal_rate
|
||||
} for segment in profile.segments
|
||||
],
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": device_settings.low_bg_threshold
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": device_settings.high_bg_threshold
|
||||
}
|
||||
],
|
||||
"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(':')
|
||||
if ampm.lower().strip() == 'am':
|
||||
return "%02d:%02d" % (int(hr) % 12, int(min))
|
||||
elif ampm.lower().strip() == 'pm':
|
||||
return "%02d:%02d" % (12 + (int(hr) % 12), int(min))
|
||||
raise InvalidTimeException(tandem_time)
|
||||
|
||||
def tandem_to_ns_time_seconds(tandem_time: str) -> int:
|
||||
numbers, ampm = tandem_time.split(' ')
|
||||
hr, min = numbers.split(':')
|
||||
if ampm.lower().strip() == 'am':
|
||||
return 60 * (60 * (int(hr) % 12) + int(min))
|
||||
elif ampm.lower().strip() == 'pm':
|
||||
return 60 * (60 * (12 + (int(hr) % 12)) + int(min))
|
||||
raise InvalidTimeException(tandem_time)
|
||||
|
||||
class InvalidBolusTypeException(RuntimeError):
|
||||
pass
|
||||
|
||||
class InvalidTimeException(RuntimeError):
|
||||
pass
|
||||
@@ -1,6 +1,9 @@
|
||||
from os import stat
|
||||
import sys
|
||||
import arrow
|
||||
from tconnectsync.domain.bolus import Bolus
|
||||
|
||||
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent, BGTherapyEvent, BasalTherapyEvent
|
||||
|
||||
try:
|
||||
from ..secret import TIMEZONE_NAME
|
||||
@@ -105,7 +108,7 @@ class TConnectEntry:
|
||||
complete = is_complete(data["ExtendedBolusIsComplete"]) or is_complete(data["BolusIsComplete"])
|
||||
extended_bolus = ("extended" in data["Description"].lower())
|
||||
|
||||
return {
|
||||
return Bolus(**{
|
||||
"description": data["Description"],
|
||||
"complete": "1" if complete else "",
|
||||
"completion": data["CompletionStatusDesc"] if not extended_bolus else data["BolexCompletionStatusDesc"],
|
||||
@@ -120,7 +123,7 @@ class TConnectEntry:
|
||||
# Note: completion time can be empty if the extended bolus is in progress
|
||||
"bolex_completion_time": TConnectEntry._datetime_parse(data["BolexCompletionDateTime"]).format() if data["BolexCompletionDateTime"] and complete and extended_bolus else None,
|
||||
"bolex_start_time": TConnectEntry._datetime_parse(data["BolexStartDateTime"]).format() if data["BolexStartDateTime"] and complete and extended_bolus else None,
|
||||
}
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def parse_reading_entry(data):
|
||||
@@ -189,7 +192,20 @@ class TConnectEntry:
|
||||
"time": time.format(),
|
||||
"event_type": TConnectEntry.BASALSUSPENSION_EVENTS[data["SuspendReason"]]
|
||||
}
|
||||
|
||||
|
||||
# Parses an entry from controliq.therapy_events() and returns a TherapyEvent
|
||||
@staticmethod
|
||||
def parse_therapy_event(data):
|
||||
if data["type"] == "Bolus":
|
||||
return BolusTherapyEvent.parse(data)
|
||||
elif data["type"] == "CGM":
|
||||
return CGMTherapyEvent.parse(data)
|
||||
elif data["type"] == "BG":
|
||||
return BGTherapyEvent.parse(data)
|
||||
elif data["type"] == "Basal":
|
||||
return BasalTherapyEvent.parse(data)
|
||||
|
||||
raise UnknownTherapyEventException(data)
|
||||
|
||||
class UnknownCIQActivityEventException(Exception):
|
||||
def __init__(self, data):
|
||||
@@ -197,4 +213,9 @@ class UnknownCIQActivityEventException(Exception):
|
||||
|
||||
class UnknownBasalSuspensionEventException(Exception):
|
||||
def __init__(self, data):
|
||||
super().__init__("Unknown basal suspension event type: %s" % data)
|
||||
super().__init__("Unknown basal suspension event type: %s" % data)
|
||||
|
||||
class UnknownTherapyEventException(Exception):
|
||||
def __init__(self, data):
|
||||
typ = data["type"]
|
||||
super().__init__(f"Unknown therapy event type: {typ} in {data}")
|
||||
|
||||
+136
-47
@@ -3,6 +3,8 @@ import datetime
|
||||
import arrow
|
||||
import time
|
||||
|
||||
from tconnectsync.parser.ciq_therapy_events import split_therapy_events
|
||||
|
||||
from .util import timeago
|
||||
from .api.common import ApiException
|
||||
from .sync.basal import (
|
||||
@@ -27,8 +29,10 @@ from .sync.pump_events import (
|
||||
process_basalsuspension_events,
|
||||
ns_write_pump_events
|
||||
)
|
||||
from .sync.profile import process_profiles
|
||||
from .parser.tconnect import TConnectEntry
|
||||
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS
|
||||
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS, PROFILES, PUMP_EVENTS_BASAL_SUSPENSION
|
||||
from tconnectsync.sync import basal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,45 +42,95 @@ cycle of synchronizing data within the time range.
|
||||
If pretend is true, then doesn't actually write data to Nightscout.
|
||||
"""
|
||||
def process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
|
||||
logger.info("Downloading t:connect ControlIQ data")
|
||||
try:
|
||||
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
|
||||
except ApiException as e:
|
||||
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
|
||||
# device in the time range which is queried. Since it launched in early 2020,
|
||||
# ignore 404's before February.
|
||||
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
|
||||
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
|
||||
ciqTherapyTimelineData = None
|
||||
ciqTherapyTimelineData = None
|
||||
if BASAL in features or PUMP_EVENTS in features:
|
||||
logger.info("Downloading t:connect ControlIQ data")
|
||||
|
||||
try:
|
||||
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
|
||||
except ApiException as e:
|
||||
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
|
||||
# device in the time range which is queried. Since it launched in early 2020,
|
||||
# ignore 404's before February.
|
||||
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
|
||||
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
|
||||
ciqTherapyTimelineData = None
|
||||
else:
|
||||
raise e
|
||||
|
||||
csvReadingData = None
|
||||
csvIobData = None
|
||||
csvBasalData = None
|
||||
csvBolusData = None
|
||||
|
||||
ciqBolusData = None
|
||||
ciqReadingData = None
|
||||
if BOLUS in features:
|
||||
logger.info("Downloading t:connect therapy_events")
|
||||
ciqTherapyEventsData = tconnect.controliq.therapy_events(time_start, time_end)
|
||||
ciqBolusData, ciqReadingData = split_therapy_events(ciqTherapyEventsData)
|
||||
|
||||
if ciqReadingData and len(ciqReadingData) > 0:
|
||||
lastReading = ciqReadingData[-1].eventDateTime
|
||||
lastReading = TConnectEntry._datetime_parse(lastReading)
|
||||
logger.debug(ciqReadingData[-1])
|
||||
logger.info("Last CGM reading from t:connect CIQ: %s (%s)" % (lastReading, timeago(lastReading)))
|
||||
else:
|
||||
raise e
|
||||
logger.warning("No last CGM reading is able to be determined from CIQ")
|
||||
|
||||
if ciqBolusData and len(ciqBolusData) > 0:
|
||||
lastBolus = ciqBolusData[-1].eventDateTime
|
||||
lastReading = TConnectEntry._datetime_parse(lastBolus)
|
||||
logger.debug(ciqBolusData[-1].to_bolus())
|
||||
logger.info("Last bolus from t:connect CIQ: %s (%s)" % (lastBolus, timeago(lastBolus)))
|
||||
|
||||
logger.info("Downloading t:connect CSV data")
|
||||
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
|
||||
bolusFallingBack = (BOLUS in features and not ciqBolusData)
|
||||
ciqFallingBack = (CGM in features and not ciqReadingData)
|
||||
if bolusFallingBack or \
|
||||
ciqFallingBack or \
|
||||
BOLUS_BG in features or \
|
||||
IOB in features:
|
||||
logger.warning("Downloading t:connect CSV data")
|
||||
if bolusFallingBack:
|
||||
logger.warning("Falling back on WS2 CSV data source because BOLUS is an enabled feature and CIQ bolus data was empty!!")
|
||||
if ciqFallingBack:
|
||||
logger.warning("Falling back on WS2 CSV data source because CGM is an enabled feature and CIQ cgm data was empty!!")
|
||||
if BOLUS_BG in features:
|
||||
logger.warning("Falling back on WS2 CSV data source because BOLUS_BG is an enabled feature. " +
|
||||
"Please consider disabling this feature to improve synchronization reliability.")
|
||||
if IOB in features:
|
||||
logger.warning("Falling back on WS2 CSV data source because IOB is an enabled feature. " +
|
||||
"Please consider disabling this feature to improve synchronization reliability.")
|
||||
|
||||
logger.warning("<!!> The WS2 data source is unreliable and may prevent timely synchronization")
|
||||
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
|
||||
|
||||
readingData = csvdata["readingData"]
|
||||
iobData = csvdata["iobData"]
|
||||
csvBasalData = csvdata["basalData"]
|
||||
bolusData = csvdata["bolusData"]
|
||||
csvReadingData = csvdata["readingData"]
|
||||
csvIobData = csvdata["iobData"]
|
||||
csvBasalData = csvdata["basalData"]
|
||||
csvBolusData = csvdata["bolusData"]
|
||||
|
||||
if csvReadingData and len(csvReadingData) > 0:
|
||||
lastReading = csvReadingData[-1]['EventDateTime'] if 'EventDateTime' in csvReadingData[-1] else 0
|
||||
lastReading = TConnectEntry._datetime_parse(lastReading)
|
||||
logger.debug(csvReadingData[-1])
|
||||
logger.info("Last CGM reading from t:connect CSV: %s (%s)" % (lastReading, timeago(lastReading)))
|
||||
else:
|
||||
logger.warning("No last CGM reading is able to be determined from CSV")
|
||||
|
||||
if readingData and len(readingData) > 0:
|
||||
lastReading = readingData[-1]['EventDateTime'] if 'EventDateTime' in readingData[-1] else 0
|
||||
lastReading = TConnectEntry._datetime_parse(lastReading)
|
||||
logger.debug(readingData[-1])
|
||||
logger.info("Last CGM reading from t:connect: %s (%s)" % (lastReading, timeago(lastReading)))
|
||||
else:
|
||||
logger.warning("No last CGM reading is able to be determined")
|
||||
|
||||
added = 0
|
||||
|
||||
cgmData = None
|
||||
if CGM in features or BOLUS_BG in features:
|
||||
logger.debug("Processing CGM events")
|
||||
cgmData = process_cgm_events(readingData)
|
||||
|
||||
if CGM in features:
|
||||
logger.debug("Writing CGM events")
|
||||
added += ns_write_cgm_events(nightscout, cgmData, pretend)
|
||||
if csvReadingData:
|
||||
cgmData = None
|
||||
if CGM in features or BOLUS_BG in features:
|
||||
logger.debug("Processing CGM events")
|
||||
cgmData = process_cgm_events(csvReadingData)
|
||||
|
||||
if CGM in features:
|
||||
logger.debug("Writing CGM events")
|
||||
added += ns_write_cgm_events(nightscout, cgmData, pretend, time_start=time_start, time_end=time_end)
|
||||
logger.debug("Finished writing CGM events")
|
||||
|
||||
if BASAL in features:
|
||||
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
|
||||
@@ -85,29 +139,64 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
|
||||
add_csv_basal_events(basalEvents, csvBasalData)
|
||||
else:
|
||||
logger.debug("No CSV basal data found")
|
||||
|
||||
if basalEvents and len(basalEvents) > 0:
|
||||
logger.info("Last basal event from CIQ: %s" % basalEvents[-1])
|
||||
|
||||
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend)
|
||||
|
||||
if PUMP_EVENTS in features:
|
||||
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
|
||||
logger.debug("CIQ activity events: %s" % pumpEvents)
|
||||
logger.debug("Writing basal events")
|
||||
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
logger.debug("Finished writing basal events")
|
||||
|
||||
if PUMP_EVENTS_BASAL_SUSPENSION in features:
|
||||
logger.warning("Using WS2 data source for basalsuspension because PUMP_EVENTS_BASAL_SUSPENSION is an enabled feature")
|
||||
logger.warning("<!!> The WS2 data source is unreliable and may prevent timely synchronization")
|
||||
ws2BasalSuspension = tconnect.ws2.basalsuspension(time_start, time_end)
|
||||
|
||||
bsPumpEvents = process_basalsuspension_events(ws2BasalSuspension)
|
||||
logger.debug("basalsuspension events: %s" % bsPumpEvents)
|
||||
|
||||
pumpEvents += bsPumpEvents
|
||||
logger.debug("Writing pump basalsuspension events")
|
||||
added += ns_write_pump_events(nightscout, bsPumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
logger.debug("Finished writing basal events")
|
||||
|
||||
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend)
|
||||
if PUMP_EVENTS in features:
|
||||
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
|
||||
logger.debug("CIQ activity events: %s" % pumpEvents)
|
||||
|
||||
logger.debug("Writing pump events")
|
||||
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
|
||||
logger.debug("Finished writing basal events")
|
||||
|
||||
if BOLUS in features:
|
||||
bolusEvents = process_bolus_events(bolusData)
|
||||
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features))
|
||||
bolusEvents = []
|
||||
if ciqBolusData:
|
||||
logger.info("Processing ciqBolusData (%d entries)" % len(ciqBolusData))
|
||||
bolusEvents = process_bolus_events(ciqBolusData, source="ciq")
|
||||
|
||||
if IOB in features:
|
||||
iobEvents = process_iob_events(iobData)
|
||||
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
|
||||
if csvBolusData and not bolusEvents:
|
||||
logger.warning("Falling back on non-CIQ csvBolusData")
|
||||
bolusEvents = process_bolus_events(csvBolusData, source="csv")
|
||||
logger.debug("ciq bolusEvents: %s" % bolusEvents)
|
||||
|
||||
logger.info("finalized bolusEvents: %s" % bolusEvents)
|
||||
logger.debug("Writing bolus events")
|
||||
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features), time_start=time_start, time_end=time_end)
|
||||
logger.debug("Finished writing bolus events")
|
||||
|
||||
logger.info("Wrote %d events to Nightscout this process cycle" % added)
|
||||
return added
|
||||
if csvIobData:
|
||||
if IOB in features:
|
||||
iobEvents = process_iob_events(csvIobData)
|
||||
logger.debug("Writing iob events")
|
||||
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
|
||||
logger.debug("Finished writing iob events")
|
||||
|
||||
if PROFILES in features:
|
||||
logger.debug("Running profiles feature")
|
||||
if process_profiles(tconnect, nightscout, pretend=pretend):
|
||||
added += 1
|
||||
|
||||
if pretend:
|
||||
logger.info("Would have written %d events to Nightscout this process cycle (in pretend mode)" % added)
|
||||
else:
|
||||
logger.info("Wrote %d events to Nightscout this process cycle" % added)
|
||||
return added
|
||||
|
||||
+17
-1
@@ -16,6 +16,14 @@ else:
|
||||
def get(val, default=None):
|
||||
return os.environ.get(val, values.get(val, default))
|
||||
|
||||
def get_one_of(name, default=None, options=[]):
|
||||
val = get(name, default)
|
||||
if val not in options:
|
||||
print("Error: %s must be one of: %s" % (name, options))
|
||||
print("Current value: %s" % val)
|
||||
sys.exit(1)
|
||||
return val
|
||||
|
||||
def get_number(name, default):
|
||||
val = get(name, default)
|
||||
try:
|
||||
@@ -31,7 +39,7 @@ def get_bool(name, default):
|
||||
TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com')
|
||||
TCONNECT_PASSWORD = get('TCONNECT_PASSWORD', 'password')
|
||||
|
||||
PUMP_SERIAL_NUMBER = get_number('PUMP_SERIAL_NUMBER', '11111111')
|
||||
PUMP_SERIAL_NUMBER = int(get_number('PUMP_SERIAL_NUMBER', '11111111'))
|
||||
|
||||
NS_URL = get('NS_URL', 'https://yournightscouturl/')
|
||||
NS_SECRET = get('NS_SECRET', 'apisecret')
|
||||
@@ -40,6 +48,8 @@ if not get('NS_SECRET') and get('API_SECRET'):
|
||||
print('API_SECRET environment variable is set, overriding NS_SECRET')
|
||||
NS_SECRET = get('API_SECRET')
|
||||
|
||||
NS_SKIP_TLS_VERIFY = get_bool('NS_SKIP_TLS_VERIFY', 'false')
|
||||
|
||||
TIMEZONE_NAME = get('TIMEZONE_NAME', 'America/New_York')
|
||||
|
||||
if not get('TIMEZONE_NAME') and get('TZ'):
|
||||
@@ -57,6 +67,12 @@ AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15
|
||||
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true')
|
||||
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'])
|
||||
|
||||
# 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')
|
||||
|
||||
ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false')
|
||||
SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false')
|
||||
REQUESTS_PROXY = get('REQUESTS_PROXY', '')
|
||||
|
||||
@@ -104,9 +104,9 @@ def add_csv_basal_events(basalEvents, data):
|
||||
"""
|
||||
Given processed basal data, adds basal events to Nightscout.
|
||||
"""
|
||||
def ns_write_basal_events(nightscout, basalEvents, pretend=False):
|
||||
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)
|
||||
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"])
|
||||
@@ -121,7 +121,7 @@ def ns_write_basal_events(nightscout, basalEvents, pretend=False):
|
||||
for event in basalEvents:
|
||||
if last_upload_time and arrow.get(event["time"]) < last_upload_time:
|
||||
if pretend:
|
||||
logger.info("Skipping basal event before last upload time: %s" % event)
|
||||
logger.info("Skipping basal event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
recent_needs_update = False
|
||||
|
||||
+39
-23
@@ -1,5 +1,6 @@
|
||||
import arrow
|
||||
import logging
|
||||
from tconnectsync.domain.bolus import Bolus
|
||||
|
||||
from tconnectsync.sync.cgm import find_event_at
|
||||
|
||||
@@ -15,26 +16,41 @@ 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):
|
||||
def process_bolus_events(bolusdata, cgmEvents=None, source=""):
|
||||
bolusEvents = []
|
||||
|
||||
for b in bolusdata:
|
||||
parsed = TConnectEntry.parse_bolus_entry(b)
|
||||
if parsed["completion"] != "Completed":
|
||||
if parsed["insulin"] and float(parsed["insulin"]) > 0:
|
||||
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"])
|
||||
parsed.description += " (%s: requested %s units)" % (parsed.completion, parsed.requested_insulin)
|
||||
else:
|
||||
logger.warning("Skipping non-completed bolus data (was a bolus in progress?): %s parsed: %s" % (b, parsed))
|
||||
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.request_time:
|
||||
logger.warning("Skipping non-completed %s extended bolus data with no request_time: %s parsed: %s" % (source, b, parsed))
|
||||
elif not parsed.bolex_start_time and parsed.request_time:
|
||||
logger.warning("Setting bolex_start_time to request_time for non-completed %s extended bolus: %s parsed: %s" % (source, b, parsed))
|
||||
parsed.bolex_start_time = parsed.request_time
|
||||
logger.debug("process_bolus_events for incomplete bolus: %s parsed: %s" % (b, parsed))
|
||||
elif parsed.is_extended_bolus:
|
||||
logger.debug("process_bolus_events for complete extended bolus: %s parsed: %s" % (b, parsed))
|
||||
|
||||
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)
|
||||
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["extended_bolus"] else event["bolex_start_time"]))
|
||||
bolusEvents.sort(key=lambda event: arrow.get(event.request_time if not event.is_extended_bolus else event.bolex_start_time))
|
||||
|
||||
return bolusEvents
|
||||
|
||||
@@ -57,9 +73,9 @@ def guess_bolus_bg_type(bg, created_at, cgmEvents):
|
||||
"""
|
||||
Given processed bolus data, adds bolus events to Nightscout.
|
||||
"""
|
||||
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None):
|
||||
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)
|
||||
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"])
|
||||
@@ -72,27 +88,27 @@ def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=Fal
|
||||
|
||||
add_count = 0
|
||||
for event in bolusEvents:
|
||||
created_at = event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]
|
||||
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" % event)
|
||||
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"]:
|
||||
if include_bg and event.bg:
|
||||
entry = NightscoutEntry.bolus(
|
||||
bolus=event["insulin"],
|
||||
carbs=event["carbs"],
|
||||
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"]
|
||||
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"],
|
||||
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 "")
|
||||
notes="{}{}{}".format(event.description, " (Override)" if event.user_override == "1" else "", " (Extended)" if event.extended_bolus == "1" else "")
|
||||
)
|
||||
|
||||
add_count += 1
|
||||
|
||||
@@ -39,9 +39,9 @@ def find_event_at(cgmEvents, find_time):
|
||||
"""
|
||||
Given processed CGM data, adds reading entries to Nightscout.
|
||||
"""
|
||||
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False):
|
||||
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()
|
||||
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"])
|
||||
@@ -52,7 +52,7 @@ def ns_write_cgm_events(nightscout, cgmEvents, pretend=False):
|
||||
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" % event)
|
||||
logger.info("Skipping CGM event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
|
||||
continue
|
||||
|
||||
entry = NightscoutEntry.entry(
|
||||
|
||||
@@ -24,9 +24,9 @@ def process_iob_events(iobdata):
|
||||
"""
|
||||
Given processed IOB data, creates a single Nightscout activity definition to store IOB.
|
||||
"""
|
||||
def ns_write_iob_events(nightscout, iobEvents, pretend=False):
|
||||
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)
|
||||
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"])
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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, 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)
|
||||
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)
|
||||
@@ -49,7 +49,7 @@ def process_basalsuspension_events(data):
|
||||
"""
|
||||
Given processed pump event data (of various types), write them to Nightscout
|
||||
"""
|
||||
def ns_write_pump_events(nightscout, pumpEvents, pretend=False):
|
||||
def ns_write_pump_events(nightscout, pumpEvents, pretend=False, time_start=None, time_end=None):
|
||||
count = 0
|
||||
|
||||
siteChangeEvents = []
|
||||
@@ -80,17 +80,17 @@ def ns_write_pump_events(nightscout, pumpEvents, pretend=False):
|
||||
logger.debug("sleepEvents: %s" % sleepEvents)
|
||||
logger.debug("activityEvents: %s" % activityEvents)
|
||||
|
||||
count += ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=pretend)
|
||||
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend)
|
||||
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend)
|
||||
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)
|
||||
count += ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=pretend)
|
||||
count += ns_write_activity_events(nightscout, activityEvents, pretend=pretend)
|
||||
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):
|
||||
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
siteChangeEvents,
|
||||
@@ -99,9 +99,11 @@ def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False)
|
||||
reason=event["event_type"]
|
||||
),
|
||||
SITECHANGE_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False):
|
||||
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
emptyCartEvents,
|
||||
@@ -110,9 +112,11 @@ def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False):
|
||||
reason=event["event_type"]
|
||||
),
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False):
|
||||
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
userSuspendedEvents,
|
||||
@@ -121,9 +125,11 @@ def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=Fals
|
||||
reason=event["event_type"]
|
||||
),
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False):
|
||||
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
exerciseEvents,
|
||||
@@ -134,9 +140,11 @@ def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False)
|
||||
event_type=EXERCISE_EVENTTYPE
|
||||
),
|
||||
EXERCISE_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False):
|
||||
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
sleepEvents,
|
||||
@@ -147,9 +155,11 @@ def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False):
|
||||
event_type=SLEEP_EVENTTYPE
|
||||
),
|
||||
SLEEP_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def ns_write_activity_events(nightscout, activityEvents, pretend=False):
|
||||
def ns_write_activity_events(nightscout, activityEvents, pretend=False, time_start=None, time_end=None):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
activityEvents,
|
||||
@@ -159,15 +169,17 @@ def ns_write_activity_events(nightscout, activityEvents, pretend=False):
|
||||
duration=event["duration_mins"]
|
||||
),
|
||||
ACTIVITY_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
pretend=pretend,
|
||||
time_start=time_start,
|
||||
time_end=time_end)
|
||||
|
||||
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False):
|
||||
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)
|
||||
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"])
|
||||
@@ -192,7 +204,7 @@ def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, prete
|
||||
|
||||
if skip:
|
||||
if pretend:
|
||||
logger.info("Skipping %s event before last upload time: %s" % (eventType, event))
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import arrow
|
||||
|
||||
from . import cli
|
||||
from . import constants
|
||||
|
||||
def timeago(timestamp):
|
||||
seconds = (arrow.get() - arrow.get(timestamp)).total_seconds()
|
||||
fmt = '%s ago' if seconds >= 0 else 'in %s'
|
||||
@@ -15,3 +18,14 @@ def timeago(timestamp):
|
||||
ret += '%d minutes' % (seconds//60)
|
||||
|
||||
return fmt % ret
|
||||
|
||||
# String methods only available in python 3.9+
|
||||
def removesuffix(input_string, suffix):
|
||||
if suffix and input_string.endswith(suffix):
|
||||
return input_string[:-len(suffix)]
|
||||
return input_string
|
||||
|
||||
def removeprefix(input_string, prefix):
|
||||
if prefix and input_string.startswith(prefix):
|
||||
return input_string[len(prefix):]
|
||||
return input_string
|
||||
@@ -0,0 +1,20 @@
|
||||
import logging
|
||||
|
||||
"""
|
||||
Enables logging at the specified level for all loggers
|
||||
inside the tconnectsync package, and sets up a basicConfig
|
||||
to print those log messages to stderr.
|
||||
"""
|
||||
def enable_logging(level=logging.DEBUG):
|
||||
logging.basicConfig()
|
||||
for logger in logging.root.manager.loggerDict:
|
||||
if logger.startswith('tconnectsync'):
|
||||
logging.getLogger(logger).setLevel(level)
|
||||
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
# http://www.soc-bdr.org/rds/authors/unit_tables_conversions_and_genetic_dictionaries/conversion_glucose_mg_dl_to_mmol_l/index_en.html
|
||||
MMOLL_TO_MGDL = 18.0182
|
||||
MGDL_TO_MMOLL = 0.0555
|
||||
@@ -44,6 +44,12 @@ class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
|
||||
def __init__(self, controliq):
|
||||
self.controliq = controliq
|
||||
|
||||
def my_devices(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def device_settings(self, pump_guid):
|
||||
raise NotImplementedError
|
||||
|
||||
class TConnectApi(tconnectsync.api.TConnectApi):
|
||||
def __init__(self, email=None, password=None):
|
||||
if email is not None and password is not None:
|
||||
@@ -54,3 +60,4 @@ class TConnectApi(tconnectsync.api.TConnectApi):
|
||||
_ciq = ControlIQApi()
|
||||
_ws2 = WS2Api()
|
||||
_android = AndroidApi()
|
||||
_webui = WebUIScraper(_ciq)
|
||||
|
||||
@@ -221,12 +221,10 @@ class TestControlIQApi(unittest.TestCase):
|
||||
ciq = ControlIQApi()
|
||||
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
|
||||
def fake_get(endpoint, query):
|
||||
def fake_get(raw_endpoint, ignored_query):
|
||||
endpoint, query = raw_endpoint.split("?")
|
||||
self.assertTrue(endpoint.endswith("summary/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
|
||||
self.assertEqual(query, {
|
||||
"startDate": "04-01-2021",
|
||||
"endDate": "04-02-2021"
|
||||
})
|
||||
self.assertEqual(query, "startDate=04-01-2021&endDate=04-02-2021")
|
||||
|
||||
return {"faked_json": True}
|
||||
|
||||
|
||||
+38
-36
@@ -11,6 +11,7 @@ import requests_mock
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from tconnectsync.api.webui import WebUIScraper
|
||||
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment
|
||||
|
||||
from .fake import ControlIQApi
|
||||
|
||||
@@ -293,30 +294,30 @@ class TestWebUIScraper(unittest.TestCase):
|
||||
|
||||
devices = webui.my_devices()
|
||||
self.assertDictEqual(devices, {
|
||||
'100001': {
|
||||
'100001': Device(**{
|
||||
'name': 't:slim X2™ Insulin Pump',
|
||||
'model_number': '001002717',
|
||||
'status': 'Activated — Dec 30 2021',
|
||||
'guid': '00000000-0000-0000-0000-000000000001'
|
||||
},
|
||||
'10000002': {
|
||||
}),
|
||||
'10000002': Device(**{
|
||||
'name': 't:slim X2™ Insulin Pump',
|
||||
'model_number': '001000354',
|
||||
'status': 'Activated — Oct 26 2021',
|
||||
'guid': '00000000-0000-0000-0000-000000000002'
|
||||
},
|
||||
'100003': {
|
||||
}),
|
||||
'100003': Device(**{
|
||||
'name': 't:slim X2™ Insulin Pump',
|
||||
'model_number': '001000096',
|
||||
'status': 'Activated — Nov 20 2017',
|
||||
'guid': '00000000-0000-0000-0000-000000000003'
|
||||
},
|
||||
'ABCDEFGH': {
|
||||
}),
|
||||
'ABCDEFGH': Device(**{
|
||||
'name': 'OneTouch Verio IQ',
|
||||
'model_number': 'VERIO IQ',
|
||||
'status': 'Activated — Jan 17 2018',
|
||||
'guid': None
|
||||
}})
|
||||
})})
|
||||
|
||||
PUMP_SETTINGS_HTML = """
|
||||
<!DOCTYPE html
|
||||
@@ -952,48 +953,47 @@ class TestWebUIScraper(unittest.TestCase):
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/myaccount/DeviceSettings.aspx?guid=00000000-0000-0000-0000-000000000001',
|
||||
request_headers=base_headers(),
|
||||
|
||||
text=self.PUMP_SETTINGS_HTML)
|
||||
|
||||
profiles, settings = webui.device_settings_from_guid('00000000-0000-0000-0000-000000000001')
|
||||
self.assertListEqual(profiles, [{
|
||||
self.assertListEqual(profiles, [Profile(**{
|
||||
'title': 'A',
|
||||
'active': True,
|
||||
'segments': [{
|
||||
'segments': [ProfileSegment(**{
|
||||
'display_time': 'Midnight',
|
||||
'time': '12:00 AM',
|
||||
'basal_rate': '0.800 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}, {
|
||||
'basal_rate': 0.800,
|
||||
'correction_factor': 30.0,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
}), ProfileSegment(**{
|
||||
'display_time': '6:00 AM',
|
||||
'time': '6:00 AM',
|
||||
'basal_rate': '1.250 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}, {
|
||||
'basal_rate': 1.250,
|
||||
'correction_factor': 30,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
}), ProfileSegment(**{
|
||||
'display_time': '11:00 AM',
|
||||
'time': '11:00 AM',
|
||||
'basal_rate': '1.000 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}, {
|
||||
'basal_rate': 1.000,
|
||||
'correction_factor': 30,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
}), ProfileSegment(**{
|
||||
'display_time': 'Noon',
|
||||
'time': '12:00 PM',
|
||||
'basal_rate': '0.800 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}],
|
||||
'calculated_total_daily_basal': '21.65 units',
|
||||
'insulin_duration': '5:00 hours',
|
||||
'carbohydrates': 'On'
|
||||
}])
|
||||
'basal_rate': 0.800,
|
||||
'correction_factor': 30,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
})],
|
||||
'calculated_total_daily_basal': 21.65,
|
||||
'insulin_duration_min': 5*60,
|
||||
'carbs_enabled': True
|
||||
})])
|
||||
|
||||
self.assertDictEqual(settings, {
|
||||
self.assertDictEqual(settings.raw_settings, {
|
||||
'Alerts': {
|
||||
'Alert: Auto-Off': {'text': '18 hrs', 'value': True},
|
||||
'Alert: Low Insulin': {'text': '35 u'}
|
||||
@@ -1042,6 +1042,8 @@ class TestWebUIScraper(unittest.TestCase):
|
||||
},
|
||||
'upload_date': 'Jan 09, 2022'
|
||||
})
|
||||
self.assertEqual(settings.low_bg_threshold, 80)
|
||||
self.assertEqual(settings.high_bg_threshold, 200)
|
||||
|
||||
|
||||
|
||||
|
||||
+59
-9
@@ -2,6 +2,7 @@
|
||||
|
||||
import unittest
|
||||
import itertools
|
||||
import copy
|
||||
|
||||
from .fake import WS2Api
|
||||
|
||||
@@ -10,7 +11,7 @@ from tconnectsync.api.common import ApiException
|
||||
class TestWS2Api(unittest.TestCase):
|
||||
def fake_get_with_http_500(self, num_times):
|
||||
tries = 0
|
||||
def fake_get(endpoint):
|
||||
def fake_get(endpoint, **kwargs):
|
||||
nonlocal tries, num_times
|
||||
if "therapytimeline2csv" in endpoint:
|
||||
if tries < num_times:
|
||||
@@ -28,7 +29,7 @@ class TestWS2Api(unittest.TestCase):
|
||||
ws2.get = self.fake_get_with_http_500(2)
|
||||
|
||||
self.assertEqual(
|
||||
ws2.therapy_timeline_csv('2021-04-01', '2021-04-02'),
|
||||
ws2.therapy_timeline_csv('04-01-2021', '04-02-2021'),
|
||||
{
|
||||
"readingData": [],
|
||||
"iobData": [],
|
||||
@@ -41,7 +42,7 @@ class TestWS2Api(unittest.TestCase):
|
||||
|
||||
ws2.get = self.fake_get_with_http_500(3)
|
||||
|
||||
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '2021-04-01', '2021-04-02')
|
||||
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '04-01-2021', '04-02-2021')
|
||||
|
||||
RAW_DATA_HEADER = """Tandem Diabetes Care Inc.
|
||||
t:connect Therapy Timeline Data Export
|
||||
@@ -94,14 +95,14 @@ Report Generated On, 4/24/2021 7:50:04 PM
|
||||
|
||||
rawData = self.RAW_DATA_FULL
|
||||
|
||||
def fake_get(endpoint):
|
||||
def fake_get(endpoint, **kwargs):
|
||||
nonlocal rawData
|
||||
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
|
||||
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
|
||||
return rawData
|
||||
|
||||
ws2.get = fake_get
|
||||
|
||||
tt = ws2.therapy_timeline_csv('2021-04-01', '2021-04-02')
|
||||
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
|
||||
|
||||
self.assertDictEqual(tt, self.PARSED_DATA)
|
||||
|
||||
@@ -111,9 +112,9 @@ Report Generated On, 4/24/2021 7:50:04 PM
|
||||
|
||||
rawData = ""
|
||||
|
||||
def fake_get(endpoint):
|
||||
def fake_get(endpoint, **kwargs):
|
||||
nonlocal rawData
|
||||
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
|
||||
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
|
||||
return rawData
|
||||
|
||||
ws2.get = fake_get
|
||||
@@ -122,8 +123,57 @@ Report Generated On, 4/24/2021 7:50:04 PM
|
||||
for i in itertools.permutations([self.RAW_DATA_HEADER, self.RAW_DATA_CGM, self.RAW_DATA_IOB, self.RAW_DATA_BOLUS], 4):
|
||||
rawData = "\n".join(i)
|
||||
|
||||
tt = ws2.therapy_timeline_csv('2021-04-01', '2021-04-02')
|
||||
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
|
||||
self.assertDictEqual(tt, self.PARSED_DATA)
|
||||
|
||||
def test_therapy_timeline_csv_split_past_max_days(self):
|
||||
ws2 = WS2Api()
|
||||
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
|
||||
|
||||
def replace_str(raw, one, two):
|
||||
return raw.replace('04-01-2021', one).replace('04-02-2021', two)
|
||||
rawData1 = self.RAW_DATA_FULL
|
||||
rawData2 = replace_str(self.RAW_DATA_FULL, '04-03-2021', '04-04-2021')
|
||||
rawData3 = replace_str(self.RAW_DATA_FULL, '04-05-2021', '04-06-2021')
|
||||
rawData4 = replace_str(self.RAW_DATA_FULL, '04-07-2021', '04-07-2021')
|
||||
|
||||
def replace_parsed(one, two):
|
||||
parsedData = copy.deepcopy(self.PARSED_DATA)
|
||||
for typ in parsedData.keys():
|
||||
for i in range(len(parsedData[typ])):
|
||||
for f in parsedData[typ][i].keys():
|
||||
if 'datetime' in f.lower():
|
||||
parsedData[typ][i][f] = replace_str(parsedData[typ][i][f], one, two)
|
||||
return parsedData
|
||||
|
||||
parsedData1 = self.PARSED_DATA
|
||||
parsedData2 = replace_parsed('04-03-2021', '04-04-2021')
|
||||
parsedData3 = replace_parsed('04-05-2021', '04-06-2021')
|
||||
parsedData4 = replace_parsed('04-07-2021', '04-07-2021')
|
||||
|
||||
fullParsedData = parsedData1
|
||||
for d in [parsedData2, parsedData3, parsedData4]:
|
||||
for typ in d.keys():
|
||||
fullParsedData[typ] += d[typ]
|
||||
|
||||
def fake_get(endpoint, **kwargs):
|
||||
nonlocal rawData1, rawData2, rawData3, rawData4
|
||||
print('fake_get call %s' % endpoint)
|
||||
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
|
||||
return rawData1
|
||||
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-03-2021/04-04-2021?format=csv':
|
||||
return rawData2
|
||||
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-05-2021/04-06-2021?format=csv':
|
||||
return rawData3
|
||||
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-07-2021/04-07-2021?format=csv':
|
||||
return rawData4
|
||||
|
||||
ws2.get = fake_get
|
||||
|
||||
tt = ws2.therapy_timeline_csv('04-01-2021', '04-07-2021')
|
||||
|
||||
self.assertDictEqual(tt, fullParsedData)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,367 @@
|
||||
import dataclasses
|
||||
import unittest
|
||||
from tconnectsync.domain.bolus import Bolus
|
||||
|
||||
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent
|
||||
|
||||
class TestCGMTherapyEvent(unittest.TestCase):
|
||||
maxDiff = None
|
||||
sampleJson = {
|
||||
"eventDateTime": "2022-07-21T00:00:08",
|
||||
"eventID": 256,
|
||||
"requestDateTime": "0001-01-01T00:00:00",
|
||||
"type": "CGM",
|
||||
"description": "EGV",
|
||||
"sourceRecId": 0,
|
||||
"eventTypeId": 0,
|
||||
"deviceType": "t:slim X2 Insulin Pump",
|
||||
"serialNumber": "xxx",
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0,
|
||||
"egv": {
|
||||
"estimatedGlucoseValue": 174,
|
||||
"hypo": 0,
|
||||
"belowTarget": 0,
|
||||
"withinTarget": 1,
|
||||
"aboveTarget": 0,
|
||||
"hyper": 0
|
||||
}
|
||||
}
|
||||
|
||||
def test_parse_cgm(self):
|
||||
e = CGMTherapyEvent.parse(self.sampleJson)
|
||||
self.assertEqual(e.type, "CGM")
|
||||
self.assertEqual(e.eventDateTime, "2022-07-21T00:00:08")
|
||||
self.assertEqual(e.sourceRecId, 0)
|
||||
self.assertEqual(e.eventID, 256)
|
||||
self.assertEqual(e.egv, 174)
|
||||
|
||||
class TestBolusTherapyEvent(unittest.TestCase):
|
||||
maxDiff = None
|
||||
standardJson = {
|
||||
"actualTotalBolusRequested": 4.17,
|
||||
"bolusRequestOptions": "Standard",
|
||||
"bolusType": "Carb",
|
||||
"carbSize": 25,
|
||||
"correctionBolusSize": 0,
|
||||
"correctionFactor": 30,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 0,
|
||||
"eventDateTime": "2022-07-21T12:27:36",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
|
||||
"eventHistoryReportEventDesc": "Food Bolus",
|
||||
"foodBolusSize": 4.17,
|
||||
"iob": 2.62,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "573042",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": False
|
||||
},
|
||||
"requestDateTime": "2022-07-21T12:27:36",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-07-21T12:29:21",
|
||||
"value": 4.17
|
||||
},
|
||||
"foodDelivered": 4.17,
|
||||
"correctionDelivered": 0,
|
||||
"insulinRequested": 4.17,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3362,
|
||||
"bolusCompletionId": 3362
|
||||
},
|
||||
"standardPercent": 100,
|
||||
"targetBG": 110,
|
||||
"userOverride": 0,
|
||||
"type": "Bolus",
|
||||
"description": "Standard",
|
||||
"sourceRecId": 1171853319,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
}
|
||||
|
||||
def test_standard_to_bolus(self):
|
||||
e = BolusTherapyEvent.parse(self.standardJson)
|
||||
self.assertIsNotNone(e)
|
||||
b = e.to_bolus()
|
||||
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
|
||||
description="Standard",
|
||||
complete="1",
|
||||
completion="Completed",
|
||||
request_time="2022-07-21 12:27:36-04:00",
|
||||
completion_time="2022-07-21 12:29:21-04:00",
|
||||
insulin="4.17",
|
||||
requested_insulin="4.17",
|
||||
carbs="25",
|
||||
bg="",
|
||||
user_override="0",
|
||||
extended_bolus="0",
|
||||
bolex_completion_time="",
|
||||
bolex_start_time=""
|
||||
)))
|
||||
|
||||
correctionJson = {
|
||||
"actualTotalBolusRequested": 2.9,
|
||||
"bg": 254,
|
||||
"bolusRequestOptions": "Automatic Bolus/Correction",
|
||||
"bolusType": "Automatic Correction",
|
||||
"carbSize": 0,
|
||||
"correctionBolusSize": 2.9,
|
||||
"correctionFactor": 30,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 0,
|
||||
"eventDateTime": "2022-07-21T11:53:08",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
|
||||
"eventHistoryReportEventDesc": "Correction Bolus",
|
||||
"foodBolusSize": 0,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "572946",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": False
|
||||
},
|
||||
"requestDateTime": "2022-07-21T11:53:08",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-07-21T11:55:24",
|
||||
"value": 2.9
|
||||
},
|
||||
"foodDelivered": 0,
|
||||
"correctionDelivered": 2.9,
|
||||
"insulinRequested": 2.9,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3361,
|
||||
"bolusCompletionId": 3361
|
||||
},
|
||||
"standardPercent": 100,
|
||||
"targetBG": 110,
|
||||
"userOverride": 0,
|
||||
"type": "Bolus",
|
||||
"description": "Automatic Bolus/Correction",
|
||||
"sourceRecId": 1171791787,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
}
|
||||
|
||||
def test_correction_to_bolus(self):
|
||||
e = BolusTherapyEvent.parse(self.correctionJson)
|
||||
self.assertIsNotNone(e)
|
||||
b = e.to_bolus()
|
||||
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
|
||||
description="Automatic Bolus/Correction",
|
||||
complete="1",
|
||||
completion="Completed",
|
||||
request_time="2022-07-21 11:53:08-04:00",
|
||||
completion_time="2022-07-21 11:55:24-04:00",
|
||||
insulin="2.9",
|
||||
requested_insulin="2.9",
|
||||
carbs="0",
|
||||
bg="254",
|
||||
user_override="0",
|
||||
extended_bolus="0",
|
||||
bolex_completion_time="",
|
||||
bolex_start_time=""
|
||||
)))
|
||||
|
||||
extendedBolusIncompleteJson = {
|
||||
"actualTotalBolusRequested": 0.4,
|
||||
"bg": 131,
|
||||
"bolex": {
|
||||
"size": 0.2,
|
||||
"bolexStartDateTime": "2022-08-09T23:20:04",
|
||||
"iob": 0,
|
||||
"completionStatusId": 0,
|
||||
"extendedBolusIsComplete": 0,
|
||||
"insulinRequested": 0,
|
||||
"bolexCompletionId": 0
|
||||
},
|
||||
"bolusRequestOptions": "Extended",
|
||||
"bolusType": "Carb",
|
||||
"carbSize": 0,
|
||||
"correctionBolusSize": 0.0,
|
||||
"correctionFactor": 30.0,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 15,
|
||||
"eventDateTime": "2022-08-09T23:19:15",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
|
||||
"eventHistoryReportEventDesc": "Food Bolus: 50% Extended 15 mins",
|
||||
"foodBolusSize": 0.0,
|
||||
"iob": 5.87,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "631597",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": False
|
||||
},
|
||||
"requestDateTime": "2022-08-09T23:19:15",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-08-09T23:20:04",
|
||||
"value": 0.2
|
||||
},
|
||||
"foodDelivered": 0.0,
|
||||
"correctionDelivered": 0.0,
|
||||
"insulinRequested": 0.2,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3636.0,
|
||||
"bolusCompletionId": 3636.0
|
||||
},
|
||||
"standardPercent": 50.0,
|
||||
"targetBG": 110,
|
||||
"userOverride": 1,
|
||||
"type": "Bolus",
|
||||
"description": "Extended 50.00%/0.00",
|
||||
"sourceRecId": 1209631944,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
}
|
||||
|
||||
def test_extended_bolus_incomplete_to_bolus(self):
|
||||
e = BolusTherapyEvent.parse(self.extendedBolusIncompleteJson)
|
||||
self.assertIsNotNone(e)
|
||||
b = e.to_bolus()
|
||||
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
|
||||
description="Extended 50.00%/0.00",
|
||||
complete="0",
|
||||
completion="",
|
||||
request_time="2022-08-09 23:19:15-04:00",
|
||||
completion_time="2022-08-09 23:20:04-04:00",
|
||||
insulin="0.2",
|
||||
requested_insulin="0.2",
|
||||
carbs="0",
|
||||
bg="131",
|
||||
user_override="1",
|
||||
extended_bolus="1",
|
||||
bolex_completion_time="",
|
||||
bolex_start_time="2022-08-09 23:20:04-04:00"
|
||||
)))
|
||||
|
||||
extendedBolusJson = {
|
||||
"actualTotalBolusRequested": 0.4,
|
||||
"bg": 131,
|
||||
"bolex": {
|
||||
"size": 0.2,
|
||||
"bolexStartDateTime": "2022-08-09T23:20:04",
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-08-09T23:35:03",
|
||||
"value": 0.2
|
||||
},
|
||||
"iob": 5.7,
|
||||
"completionStatusId": 3.0,
|
||||
"completionStatusDesc": "Completed",
|
||||
"extendedBolusIsComplete": 1,
|
||||
"insulinRequested": 0.2,
|
||||
"bolexCompletionId": 16757133
|
||||
},
|
||||
"bolusRequestOptions": "Extended",
|
||||
"bolusType": "Carb",
|
||||
"carbSize": 0,
|
||||
"correctionBolusSize": 0.0,
|
||||
"correctionFactor": 30.0,
|
||||
"declinedCorrection": 0,
|
||||
"duration": 15,
|
||||
"eventDateTime": "2022-08-09T23:19:15",
|
||||
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
|
||||
"eventHistoryReportEventDesc": "Food Bolus: 50% Extended 15 mins",
|
||||
"foodBolusSize": 0.0,
|
||||
"iob": 5.87,
|
||||
"isQuickBolus": 0,
|
||||
"note": {
|
||||
"id": 0,
|
||||
"indexId": "631597",
|
||||
"eventTypeId": 64,
|
||||
"sourceRecordId": 0,
|
||||
"eventId": 0,
|
||||
"active": False
|
||||
},
|
||||
"requestDateTime": "2022-08-09T23:19:15",
|
||||
"standard": {
|
||||
"insulinDelivered": {
|
||||
"completionDateTime": "2022-08-09T23:20:04",
|
||||
"value": 0.2
|
||||
},
|
||||
"foodDelivered": 0.0,
|
||||
"correctionDelivered": 0.0,
|
||||
"insulinRequested": 0.2,
|
||||
"completionStatusId": 3,
|
||||
"completionStatusDesc": "Completed",
|
||||
"bolusIsComplete": 1,
|
||||
"bolusRequestId": 3636.0,
|
||||
"bolusCompletionId": 3636.0
|
||||
},
|
||||
"standardPercent": 50.0,
|
||||
"targetBG": 110,
|
||||
"userOverride": 1,
|
||||
"type": "Bolus",
|
||||
"description": "Extended 50.00%/0.00",
|
||||
"sourceRecId": 1209631944,
|
||||
"eventTypeId": 0,
|
||||
"indexId": 0,
|
||||
"uploadId": 0,
|
||||
"interactive": 0,
|
||||
"tempRateId": 0,
|
||||
"tempRateCompleted": 0,
|
||||
"tempRateActivated": 0
|
||||
}
|
||||
|
||||
def test_extended_bolus_complete_to_bolus(self):
|
||||
e = BolusTherapyEvent.parse(self.extendedBolusJson)
|
||||
self.assertIsNotNone(e)
|
||||
b = e.to_bolus()
|
||||
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
|
||||
description="Extended 50.00%/0.00",
|
||||
complete="1",
|
||||
completion="Completed",
|
||||
request_time="2022-08-09 23:19:15-04:00",
|
||||
completion_time="2022-08-09 23:20:04-04:00",
|
||||
insulin="0.2",
|
||||
requested_insulin="0.2",
|
||||
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"
|
||||
)))
|
||||
|
||||
|
||||
BOLUS_FULL_EXAMPLES = [
|
||||
TestBolusTherapyEvent.standardJson,
|
||||
TestBolusTherapyEvent.correctionJson,
|
||||
TestBolusTherapyEvent.extendedBolusJson
|
||||
]
|
||||
@@ -20,7 +20,7 @@ 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):
|
||||
def last_uploaded_entry(self, eventType, start_date=None, end_date=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def last_uploaded_activity(self, activityType):
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException
|
||||
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 ..sync.test_profile import DEVICE_PROFILE_A, DEVICE_SETTINGS, NS_PROFILE_A
|
||||
class TestNightscoutEntry(unittest.TestCase):
|
||||
maxDiff = None
|
||||
def test_basal(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.basal(
|
||||
@@ -191,6 +194,39 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_profile_store(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.profile_store(
|
||||
profile=DEVICE_PROFILE_A,
|
||||
device_settings=DEVICE_SETTINGS
|
||||
),
|
||||
NS_PROFILE_A
|
||||
)
|
||||
|
||||
|
||||
class TestTandemNightscoutTime(unittest.TestCase):
|
||||
def test_tandem_to_ns_time(self):
|
||||
self.assertEqual(tandem_to_ns_time('12:00 AM'), '00:00')
|
||||
self.assertEqual(tandem_to_ns_time('12:30 AM'), '00:30')
|
||||
self.assertEqual(tandem_to_ns_time('6:00 AM'), '06:00')
|
||||
self.assertEqual(tandem_to_ns_time('6:30 AM'), '06:30')
|
||||
self.assertEqual(tandem_to_ns_time('11:30 AM'), '11:30')
|
||||
self.assertEqual(tandem_to_ns_time('12:00 PM'), '12:00')
|
||||
self.assertEqual(tandem_to_ns_time('12:30 PM'), '12:30')
|
||||
self.assertEqual(tandem_to_ns_time('06:30 PM'), '18:30')
|
||||
self.assertEqual(tandem_to_ns_time('11:30 PM'), '23:30')
|
||||
|
||||
def test_tandem_to_ns_time_seconds(self):
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:00 AM'), 0)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:30 AM'), 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('6:00 AM'), 6*60*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('6:30 AM'), 6*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('11:30 AM'), 11*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:00 PM'), 12*60*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:30 PM'), 12*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('06:30 PM'), 12*60*60 + 6*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('11:30 PM'), 12*60*60 + 11*60*60 + 30*60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from tconnectsync.domain.bolus import Bolus
|
||||
from tconnectsync.parser.tconnect import TConnectEntry, UnknownBasalSuspensionEventException, UnknownCIQActivityEventException
|
||||
|
||||
class TestTConnectEntryBasal(unittest.TestCase):
|
||||
@@ -177,7 +178,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
def test_parse_bolus_entry_std_correction(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_bolus_entry(self.entryStdCorrection),
|
||||
{
|
||||
Bolus(**{
|
||||
"description": "Standard/Correction",
|
||||
"complete": "1",
|
||||
"completion": "Completed",
|
||||
@@ -191,7 +192,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
"bolex_start_time": None
|
||||
})
|
||||
}))
|
||||
|
||||
entryStd = {
|
||||
"Type": "Bolus",
|
||||
@@ -239,7 +240,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
def test_parse_bolus_entry_std(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_bolus_entry(self.entryStd),
|
||||
{
|
||||
Bolus(**{
|
||||
"description": "Standard",
|
||||
"complete": "1",
|
||||
"completion": "Completed",
|
||||
@@ -253,7 +254,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
"bolex_start_time": None
|
||||
})
|
||||
}))
|
||||
|
||||
entryStdAutomatic = {
|
||||
"Type": "Bolus",
|
||||
@@ -301,7 +302,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
def test_parse_bolus_entry_std_automatic(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_bolus_entry(self.entryStdAutomatic),
|
||||
{
|
||||
Bolus(**{
|
||||
"description": "Automatic Bolus/Correction",
|
||||
"complete": "1",
|
||||
"completion": "Completed",
|
||||
@@ -315,7 +316,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
"bolex_start_time": None
|
||||
})
|
||||
}))
|
||||
|
||||
entryStdIncompleteZero = {
|
||||
"Type": "Bolus",
|
||||
@@ -363,7 +364,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
def test_parse_bolus_entry_std_incomplete_zero(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_bolus_entry(self.entryStdIncompleteZero),
|
||||
{
|
||||
Bolus(**{
|
||||
"description": "Standard",
|
||||
"complete": "",
|
||||
"completion": "User Aborted",
|
||||
@@ -377,7 +378,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
"bolex_start_time": None
|
||||
})
|
||||
}))
|
||||
|
||||
entryStdIncompletePartial = {
|
||||
"Type": "Bolus",
|
||||
@@ -425,7 +426,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
def test_parse_bolus_entry_std_incomplete_partial(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_bolus_entry(self.entryStdIncompletePartial),
|
||||
{
|
||||
Bolus(**{
|
||||
"description": "Standard/Correction",
|
||||
"complete": "",
|
||||
"completion": "Terminated by Alarm",
|
||||
@@ -439,7 +440,72 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
"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 = {
|
||||
|
||||
@@ -52,7 +52,7 @@ class TestBolusSync(unittest.TestCase):
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry["bg_type"] = 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
|
||||
@@ -111,7 +111,7 @@ class TestBolusSync(unittest.TestCase):
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry["bg_type"] = type
|
||||
entry.bg_type = type
|
||||
return entry
|
||||
|
||||
expected = [
|
||||
@@ -145,7 +145,7 @@ class TestBolusSync(unittest.TestCase):
|
||||
]
|
||||
|
||||
for e in partialEntries:
|
||||
e["description"] += " (%s: requested %s units)" % (e["completion"], e["requested_insulin"])
|
||||
e.description += " (%s: requested %s units)" % (e.completion, e.requested_insulin)
|
||||
|
||||
self.assertListEqual(bolusEvents, [
|
||||
TConnectEntry.parse_bolus_entry(d) for d in stdData
|
||||
@@ -172,5 +172,23 @@ class TestBolusSync(unittest.TestCase):
|
||||
for d in zeroData:
|
||||
self.assertNotIn(TConnectEntry.parse_bolus_entry(d), bolusEvents)
|
||||
|
||||
def test_process_bolus_events_ciq_extended_bolus(self):
|
||||
stdData = [
|
||||
TestTConnectEntryBolus.entryExtendedComplete,
|
||||
]
|
||||
zeroData = [
|
||||
]
|
||||
bolusData = stdData + zeroData
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData)
|
||||
self.assertEqual(len(bolusEvents), len(stdData))
|
||||
|
||||
self.assertListEqual(bolusEvents, [
|
||||
TConnectEntry.parse_bolus_entry(d) for d in stdData
|
||||
])
|
||||
|
||||
for d in zeroData:
|
||||
self.assertNotIn(TConnectEntry.parse_bolus_entry(d), bolusEvents)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,524 @@
|
||||
#!/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()
|
||||
@@ -24,9 +24,9 @@ def build_mock_logger():
|
||||
logger.error(*args, **kwargs)
|
||||
|
||||
def fake_warn(*args, **kwargs):
|
||||
logger.warn(*args, **kwargs)
|
||||
logger.warning(*args, **kwargs)
|
||||
|
||||
with patch("tconnectsync.autoupdate.logger.error") as mock_error, patch("tconnectsync.autoupdate.logger.warn") as mock_warn:
|
||||
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)
|
||||
|
||||
+304
-27
@@ -4,22 +4,33 @@ 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, PUMP_EVENTS
|
||||
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 {
|
||||
@@ -32,10 +43,10 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
def stub_ws2_basalsuspension(self, time_start, time_end):
|
||||
return {"BasalSuspension": []}
|
||||
|
||||
def stub_last_uploaded_entry(self, event_type):
|
||||
def stub_last_uploaded_entry(self, event_type, **kwargs):
|
||||
return None
|
||||
|
||||
def stub_last_uploaded_activity(self, activity_type):
|
||||
def stub_last_uploaded_activity(self, activity_type, **kwargs):
|
||||
return None
|
||||
|
||||
"""No data in Nightscout. Uploads all basal data from tconnect."""
|
||||
@@ -53,6 +64,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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()
|
||||
@@ -60,7 +72,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
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), {
|
||||
@@ -72,6 +84,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
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):
|
||||
@@ -88,6 +101,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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()
|
||||
@@ -95,11 +109,12 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, IOB])
|
||||
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):
|
||||
@@ -116,11 +131,12 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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):
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
if event_type == "Temp Basal":
|
||||
return {
|
||||
"created_at": "2021-03-16 00:20:21-04:00",
|
||||
@@ -130,7 +146,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
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), {
|
||||
@@ -140,6 +156,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
|
||||
"""
|
||||
@@ -159,11 +176,12 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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):
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
if event_type == "Temp Basal":
|
||||
return {
|
||||
"created_at": "2021-03-16 00:20:21-04:00",
|
||||
@@ -174,7 +192,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
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, {
|
||||
@@ -192,9 +210,10 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]
|
||||
})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
"""No data in Nightscout. Uploads all bolus data from tconnect."""
|
||||
def test_new_ciq_bolus_data(self):
|
||||
"""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
|
||||
@@ -202,6 +221,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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):
|
||||
@@ -217,7 +237,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
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))
|
||||
@@ -230,6 +250,48 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
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):
|
||||
@@ -255,12 +317,13 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, IOB])
|
||||
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):
|
||||
@@ -271,6 +334,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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):
|
||||
@@ -286,7 +350,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
|
||||
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)
|
||||
@@ -297,6 +361,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
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):
|
||||
@@ -307,6 +372,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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):
|
||||
@@ -322,11 +388,12 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, BOLUS])
|
||||
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):
|
||||
@@ -354,14 +421,14 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
|
||||
def fake_last_uploaded_activity(activityType):
|
||||
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
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[IOB])
|
||||
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)
|
||||
@@ -374,6 +441,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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):
|
||||
@@ -413,7 +481,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
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), {
|
||||
@@ -423,6 +491,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
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):
|
||||
@@ -454,6 +523,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -462,11 +532,12 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
|
||||
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)
|
||||
|
||||
|
||||
"""
|
||||
@@ -506,7 +577,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type):
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
if event_type == "Sleep":
|
||||
return {
|
||||
"created_at": "2021-05-02 14:46:40-04:00",
|
||||
@@ -519,12 +590,12 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
"duration": 20,
|
||||
"_id": "exercise"
|
||||
}
|
||||
return self.stub_last_uploaded_entry()
|
||||
return self.stub_last_uploaded_entry(**kwargs)
|
||||
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
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), {
|
||||
@@ -539,6 +610,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
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):
|
||||
@@ -577,7 +649,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
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), {
|
||||
@@ -588,6 +660,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
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):
|
||||
@@ -628,7 +701,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type):
|
||||
def fake_last_uploaded_entry(event_type, **kwargs):
|
||||
if event_type == "Site Change":
|
||||
return {
|
||||
"created_at": "2021-12-04 16:18:10-05:00"
|
||||
@@ -637,12 +710,12 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
return {
|
||||
"created_at": "2021-12-04 16:07:32-05:00"
|
||||
}
|
||||
return self.stub_last_uploaded_entry()
|
||||
return self.stub_last_uploaded_entry(**kwargs)
|
||||
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
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), {
|
||||
@@ -651,6 +724,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
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):
|
||||
@@ -690,11 +764,214 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
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__':
|
||||
|
||||
Reference in New Issue
Block a user