mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-27 10:13:39 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52cb287ce8 | ||
|
|
27ba5d9a64 | ||
|
|
2140bd356e | ||
|
|
a74b63ab05 | ||
|
|
b0ffe9bcfc | ||
|
|
ca4ca72476 | ||
|
|
8cef3233c6 | ||
|
|
118ee236ba | ||
|
|
1657b1c696 | ||
|
|
0a9dd8732f | ||
|
|
dbb1389254 | ||
|
|
163db9807b | ||
|
|
b1ca7eee1e | ||
|
|
1fa543e3d6 | ||
|
|
037f53e8c4 | ||
|
|
9635b0bd0f | ||
|
|
e9f569d2a0 | ||
|
|
f5ef34174f | ||
|
|
9f488b2c5e | ||
|
|
99341c5443 |
@@ -1,7 +1,5 @@
|
||||
name: Publish to PyPI
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build-binary:
|
||||
|
||||
@@ -23,6 +23,21 @@ If run with the `--auto-update` flag, then the application performs the followin
|
||||
* Queries an API endpoint used only by the t:connect mobile app which returns an internal event ID, corresponding to the most recent event published by the mobile app.
|
||||
* Whenever the internal event ID changes (denoting that the mobile app uploaded new data to synchronize), perform all of the above mentioned steps to synchronize data.
|
||||
|
||||
The following synchronization features are enabled by default:
|
||||
|
||||
* `BASAL`: Basal data
|
||||
* `BOLUS`: Bolus data
|
||||
* `IOB`: Insulin-on-board data. Only the most recent IOB entry is saved to Nightscout, as an "activity"
|
||||
|
||||
The following synchronization feature is disabled by default, but can be enabled via the `--features` flag:
|
||||
|
||||
* `PUMP_EVENTS`: Events reported by the pump. Includes support for the following:
|
||||
* Site/Cartridge Change (occurs for both a site change and a cartridge change)
|
||||
* Empty Cartridge/Pump Shutdown (from my investigation, occurs either when the cartridge runs out of insulin OR you hard-shut off the pump)
|
||||
* User Suspended (occurs when you manually disable insulin delivery)
|
||||
* Exercise Mode (in Nightscout, appears with a start and end time)
|
||||
* Sleep Mode (in Nightscout, appears with a start and end time)
|
||||
|
||||
## Setup
|
||||
|
||||
**To get started,** you need to choose whether to install the application via
|
||||
@@ -31,9 +46,14 @@ If run with the `--auto-update` flag, then the application performs the followin
|
||||
After that, you can choose to run the program continuously via **Supervisord**
|
||||
or on a regular interval with **Cron**.
|
||||
|
||||
**NOTE:** If you fork the tconnectsync repository on GitHub, **do not commit your .env file**.
|
||||
If pushed to GitHub, this will make your tconnect and Nightscout passwords publicly visible and put your data at risk.
|
||||
|
||||
## Installation
|
||||
|
||||
First, create a file named `.env` containing configuration values.
|
||||
First, you need to create a file containing configuration values.
|
||||
The name of this file will be `.env`, and its location will be dependent on which
|
||||
method of installation you choose.
|
||||
You should specify the following parameters:
|
||||
|
||||
```bash
|
||||
@@ -52,25 +72,36 @@ NS_SECRET='apisecret'
|
||||
TIMEZONE_NAME='America/New_York'
|
||||
```
|
||||
|
||||
These values can alternatively be specified via environment variables.
|
||||
This file contains your t:connect username and password, Tandem pump serial number (which is utilized in API calls to t:connect), your Nightscout URL and secret token (for uploading data to Nightscout), and local timezone (the timezone used in t:connect).
|
||||
|
||||
The .env file contains your t:connect username and password, Tandem pump serial number (which is utilized in API calls to t:connect), your Nightscout URL and secret token (for uploading data to Nightscout), and local timezone (the timezone used in t:connect).
|
||||
(Alternatively, these values can be specified via environment variables.)
|
||||
|
||||
### Installation via Pip
|
||||
|
||||
This is the easiest method to install.
|
||||
|
||||
First, ensure that you have **Python 3** with **Pip** installed on your
|
||||
Linux machine. Then, install tconnectsync from pip:
|
||||
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`
|
||||
* **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.
|
||||
|
||||
Now install the `tconnectsync` package with pip:
|
||||
|
||||
```
|
||||
$ pip3 install tconnectsync
|
||||
```
|
||||
|
||||
If the pip3 command is not found, run `python3 -m pip install tconnectsync` instead.
|
||||
|
||||
After this, you should be able to view tconnectsync's help with:
|
||||
```
|
||||
$ tconnectsync --help
|
||||
usage: tconnectsync [-h] [--version] [--pretend] [-v] [--start-date START_DATE] [--end-date END_DATE] [--days DAYS] [--auto-update] [--check-login]
|
||||
[--features {BASAL,BOLUS,IOB,PUMP_EVENTS} [{BASAL,BOLUS,IOB,PUMP_EVENTS} ...]]
|
||||
|
||||
Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.
|
||||
|
||||
@@ -85,9 +116,16 @@ optional arguments:
|
||||
--days DAYS The number of days of t:connect data to read in. Cannot be used with --from-date and --until-date.
|
||||
--auto-update If set, continuously checks for updates from t:connect and syncs with Nightscout.
|
||||
--check-login If set, checks that the provided t:connect credentials can be used to log in.
|
||||
--features {BASAL,BOLUS,IOB,PUMP_EVENTS} [{BASAL,BOLUS,IOB,PUMP_EVENTS} ...]
|
||||
Specifies what data should be synchronized between tconnect and Nightscout.
|
||||
```
|
||||
|
||||
Go to the folder where you created the `.env` file, and run:
|
||||
Move the `.env` file you created to the following folder:
|
||||
|
||||
* **MacOS:** `/Users/<username>/.config/tconnectsync/.env`
|
||||
* **Linux:** `$HOME/.config/tconnectsync/.env`
|
||||
* **Windows:** `$HOME/.config/tconnectsync/.env` (inside WSL)
|
||||
|
||||
```
|
||||
$ tconnectsync --check-login
|
||||
```
|
||||
@@ -116,6 +154,7 @@ $ pip3 install pipenv
|
||||
$ pipenv install
|
||||
$ pipenv run tconnectsync --help
|
||||
usage: main.py [-h] [--version] [--pretend] [-v] [--start-date START_DATE] [--end-date END_DATE] [--days DAYS] [--auto-update] [--check-login]
|
||||
[--features {BASAL,BOLUS,IOB,PUMP_EVENTS} [{BASAL,BOLUS,IOB,PUMP_EVENTS} ...]]
|
||||
|
||||
Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.
|
||||
|
||||
@@ -130,10 +169,13 @@ optional arguments:
|
||||
--days DAYS The number of days of t:connect data to read in. Cannot be used with --from-date and --until-date.
|
||||
--auto-update If set, continuously checks for updates from t:connect and syncs with Nightscout.
|
||||
--check-login If set, checks that the provided t:connect credentials can be used to log in.
|
||||
--features {BASAL,BOLUS,IOB,PUMP_EVENTS} [{BASAL,BOLUS,IOB,PUMP_EVENTS} ...]
|
||||
Specifies what data should be synchronized between tconnect and Nightscout.
|
||||
```
|
||||
|
||||
|
||||
Move the `.env` file you created earlier into this folder, and run:
|
||||
Move the `.env` file you created earlier into the `tconnectsync` folder, and run:
|
||||
|
||||
```
|
||||
$ pipenv run tconnectsync --check-login
|
||||
```
|
||||
@@ -144,13 +186,24 @@ If you receive no errors, then you can move on to the **Running Tconnectsync Con
|
||||
|
||||
First, [ensure that you have Docker running and installed](https://docs.docker.com/get-started/#download-and-install-docker).
|
||||
|
||||
To download and run the `jwoglom/tconnectsync` prebuilt Docker image from [Docker Hub](https://hub.docker.com/r/jwoglom/tconnectsync):
|
||||
To download and run the prebuilt Docker image from GitHub Packages:
|
||||
|
||||
```bash
|
||||
$ docker pull jwoglom/tconnectsync:latest
|
||||
$ docker run jwoglom/tconnectsync --help
|
||||
$ docker pull ghcr.io/jwoglom/tconnectsync/tconnectsync:latest
|
||||
$ docker run ghcr.io/jwoglom/tconnectsync/tconnectsync --help
|
||||
```
|
||||
|
||||
Move the `.env` file you created earlier into the current folder, and run:
|
||||
|
||||
```
|
||||
$ docker run tconnectsync --check-login
|
||||
```
|
||||
|
||||
If you receive no errors, then you can move on to the **Running Tconnectsync Continuously** section.
|
||||
|
||||
|
||||
#### Building Locally
|
||||
|
||||
To instead build the image locally and launch the project:
|
||||
|
||||
```bash
|
||||
@@ -266,12 +319,19 @@ exec python3 -u main.py --auto-update
|
||||
In the `tconnectsync.conf`, you should set `/path/to/tconnectsync` to the folder
|
||||
where you checked-out the GitHub repository.
|
||||
|
||||
An example `run.sh` if you installed tconnectsync via Docker:
|
||||
An example `run.sh` if you installed tconnectsync via the GitHub Docker Registry:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
docker run ghcr.io/jwoglom/tconnectsync/tconnectsync --auto-update
|
||||
```
|
||||
|
||||
An example `run.sh` if you built tconnectsync locally:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
docker build -t tconnectsync
|
||||
docker run tconnectsync --auto-update
|
||||
```
|
||||
|
||||
@@ -290,7 +350,7 @@ An example configuration in `/etc/crontab` which runs every 15 minutes:
|
||||
0,15,30,45 * * * * root /path/to/tconnectsync/run.sh
|
||||
```
|
||||
|
||||
You can use one of the same `run.sh` files mentioned above in the Supervisord example, but remove the `--auto-update` flag since you are handling the functionality for running the script periodically yourself.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.4.1
|
||||
version = 0.6.1
|
||||
author = James Woglom
|
||||
author_email = j@wogloms.net
|
||||
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
|
||||
@@ -34,4 +34,4 @@ exclude =
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
tconnectsync = tconnectsync:main
|
||||
tconnectsync = tconnectsync:main
|
||||
|
||||
@@ -10,6 +10,7 @@ from .process import process_time_range
|
||||
from .autoupdate import process_auto_update
|
||||
from .check import check_login
|
||||
from .nightscout import NightscoutApi
|
||||
from .features import DEFAULT_FEATURES, ALL_FEATURES
|
||||
|
||||
try:
|
||||
from .secret import (
|
||||
@@ -38,6 +39,7 @@ def parse_args(*args, **kwargs):
|
||||
parser.add_argument('--days', dest='days', type=int, default=1, help='The number of days of t:connect data to read in. Cannot be used with --from-date and --until-date.')
|
||||
parser.add_argument('--auto-update', dest='auto_update', action='store_const', const=True, default=False, help='If set, continuously checks for updates from t:connect and syncs with Nightscout.')
|
||||
parser.add_argument('--check-login', dest='check_login', action='store_const', const=True, default=False, help='If set, checks that the provided t:connect credentials can be used to log in.')
|
||||
parser.add_argument('--features', dest='features', nargs='+', default=DEFAULT_FEATURES, choices=ALL_FEATURES, help='Specifies what data should be synchronized between tconnect and Nightscout.')
|
||||
|
||||
return parser.parse_args(*args, **kwargs)
|
||||
|
||||
@@ -76,11 +78,13 @@ def main(*args, **kwargs):
|
||||
if args.check_login:
|
||||
return check_login(tconnect, time_start, time_end)
|
||||
|
||||
logging.info("Enabled features: " + ", ".join(args.features))
|
||||
|
||||
if args.auto_update:
|
||||
print("Starting auto-update between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
|
||||
process_auto_update(tconnect, nightscout, time_start, time_end, args.pretend)
|
||||
process_auto_update(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features)
|
||||
else:
|
||||
print("Processing data between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend)
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features)
|
||||
print("Added", added, "items")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from ..util import timeago
|
||||
from .common import ApiException, ApiLoginException
|
||||
from .common import ApiException, ApiLoginException, parse_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,3 +154,16 @@ class AndroidApi:
|
||||
"""
|
||||
def user_profile(self):
|
||||
return self.get('cloud/usersettings/api/UserProfile?userId=%s' % self.userId)
|
||||
|
||||
"""
|
||||
Returns therapy events, used by the webui Therapy Timeline.
|
||||
{'event': [
|
||||
{'type': 'Basal', 'basalRate': ...},
|
||||
{'type': 'Bolus', 'standard': ...},
|
||||
{'type': 'CGM', 'egv': ...}
|
||||
]}
|
||||
"""
|
||||
def therapy_events(self, start_date=None, end_date=None):
|
||||
startDate = parse_date(start_date)
|
||||
endDate = parse_date(end_date)
|
||||
return self.get('tconnect/therapyevents/api/TherapyEvents/%s/%s/false?userId=%s' % (startDate, endDate, self.userId))
|
||||
|
||||
@@ -3,6 +3,7 @@ import datetime
|
||||
import csv
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
|
||||
from .common import parse_date, base_headers, ApiException
|
||||
|
||||
@@ -36,7 +37,7 @@ class WS2Api:
|
||||
if t.endswith(')'):
|
||||
t = t[:-1]
|
||||
|
||||
return t
|
||||
return json.loads(t)
|
||||
|
||||
def _split_empty_sections(self, text):
|
||||
sections = [[]]
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import sys
|
||||
|
||||
from .process import process_time_range
|
||||
from .features import DEFAULT_FEATURES
|
||||
from .secret import (
|
||||
PUMP_SERIAL_NUMBER,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS,
|
||||
@@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
Performs the auto-update functionality. Runs indefinitely in a loop
|
||||
until stopped (ctrl+c).
|
||||
"""
|
||||
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
|
||||
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
|
||||
# Read from android api, find exact interval to cut down on API calls
|
||||
# Refresh API token. If failure, die, have wrapper script re-run.
|
||||
|
||||
@@ -35,7 +36,7 @@ def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
|
||||
if pretend:
|
||||
logger.info('Would update now if not in pretend mode')
|
||||
else:
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend)
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
|
||||
logger.info('Added %d items from process_time_range' % added)
|
||||
if added == 0:
|
||||
if last_event_index:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from .secret import ENABLE_TESTING_MODES
|
||||
|
||||
"""Supported synchronization features."""
|
||||
BASAL = "BASAL"
|
||||
BOLUS = "BOLUS"
|
||||
IOB = "IOB"
|
||||
BOLUS_BG = "BOLUS_BG"
|
||||
CGM = "CGM"
|
||||
PUMP_EVENTS = "PUMP_EVENTS"
|
||||
|
||||
DEFAULT_FEATURES = [
|
||||
BASAL,
|
||||
BOLUS,
|
||||
IOB
|
||||
]
|
||||
|
||||
ALL_FEATURES = [
|
||||
BASAL,
|
||||
BOLUS,
|
||||
IOB,
|
||||
PUMP_EVENTS
|
||||
]
|
||||
|
||||
|
||||
# These modes are not yet ready for wide use.
|
||||
if ENABLE_TESTING_MODES:
|
||||
ALL_FEATURES += [
|
||||
BOLUS_BG,
|
||||
CGM
|
||||
]
|
||||
@@ -53,7 +53,19 @@ class NightscoutApi:
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
if latest.status_code != 200:
|
||||
raise ApiException(latest.status_code, "Nightscout treatments response: %s" % latest.text)
|
||||
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry response: %s" % latest.text)
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
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)
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import arrow
|
||||
|
||||
ENTERED_BY = "Pump (tconnectsync)"
|
||||
|
||||
BASAL_EVENTTYPE = "Temp Basal"
|
||||
BOLUS_EVENTTYPE = "Combo Bolus"
|
||||
SITECHANGE_EVENTTYPE = "Site Change"
|
||||
BASALSUSPENSION_EVENTTYPE = "Basal Suspension"
|
||||
ACTIVITY_EVENTTYPE = "Activity"
|
||||
EXERCISE_EVENTTYPE = "Exercise"
|
||||
SLEEP_EVENTTYPE = "Sleep"
|
||||
|
||||
IOB_ACTIVITYTYPE = "tconnect_iob"
|
||||
|
||||
|
||||
"""
|
||||
Conversion methods for parsing data into Nightscout objects.
|
||||
"""
|
||||
@@ -21,9 +31,14 @@ class NightscoutEntry:
|
||||
"enteredBy": ENTERED_BY
|
||||
}
|
||||
|
||||
# Note that Nightscout is not consistent and uses "Sensor"/"Finger"
|
||||
# for treatment objects, unlike "sgv"/"mbg" for entries
|
||||
SENSOR = "Sensor"
|
||||
FINGER = "Finger"
|
||||
|
||||
@staticmethod
|
||||
def bolus(bolus, carbs, created_at, notes=""):
|
||||
return {
|
||||
def bolus(bolus, carbs, created_at, notes="", bg="", bg_type=""):
|
||||
data = {
|
||||
"eventType": BOLUS_EVENTTYPE,
|
||||
"created_at": created_at,
|
||||
"carbs": int(carbs),
|
||||
@@ -31,6 +46,15 @@ class NightscoutEntry:
|
||||
"notes": notes,
|
||||
"enteredBy": ENTERED_BY,
|
||||
}
|
||||
if bg:
|
||||
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
|
||||
raise InvalidBolusTypeException
|
||||
|
||||
data.update({
|
||||
"glucose": str(bg),
|
||||
"glucoseType": bg_type
|
||||
})
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def iob(iob, created_at):
|
||||
@@ -39,4 +63,49 @@ class NightscoutEntry:
|
||||
"iob": float(iob),
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def entry(sgv, created_at):
|
||||
return {
|
||||
"type": "sgv",
|
||||
"sgv": int(sgv),
|
||||
"date": int(1000 * arrow.get(created_at).timestamp()),
|
||||
"dateString": arrow.get(created_at).strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
"device": ENTERED_BY,
|
||||
# delta, direction are undefined
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def sitechange(created_at, reason=""):
|
||||
return {
|
||||
"eventType": SITECHANGE_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def basalsuspension(created_at, reason=""):
|
||||
return {
|
||||
"eventType": BASALSUSPENSION_EVENTTYPE,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def activity(created_at, duration, reason="", event_type=ACTIVITY_EVENTTYPE):
|
||||
return {
|
||||
"eventType": event_type,
|
||||
"reason": reason,
|
||||
"notes": reason,
|
||||
"duration": float(duration),
|
||||
"created_at": created_at,
|
||||
"enteredBy": ENTERED_BY
|
||||
}
|
||||
|
||||
class InvalidBolusTypeException(RuntimeError):
|
||||
pass
|
||||
@@ -1,3 +1,4 @@
|
||||
from os import stat
|
||||
import sys
|
||||
import arrow
|
||||
|
||||
@@ -13,7 +14,6 @@ a more digestable format, which is used internally.
|
||||
"""
|
||||
class TConnectEntry:
|
||||
BASAL_EVENTS = { 0: "Suspension", 1: "Profile", 2: "TempRate", 3: "Algorithm" }
|
||||
ACTIVITY_EVENTS = { 1: "Sleep", 2: "Exercise", 3: "AutoBolus", 4: "CarbOnly" }
|
||||
|
||||
@staticmethod
|
||||
def _epoch_parse(x):
|
||||
@@ -27,6 +27,10 @@ class TConnectEntry:
|
||||
# is overwritten with America/New_York, resulting in 2020-09-01T06:00:00-04:00, the
|
||||
# correct timestamp.
|
||||
return arrow.get(x, tzinfo="America/Los_Angeles").replace(tzinfo=TIMEZONE_NAME)
|
||||
|
||||
@staticmethod
|
||||
def _jsonp_epoch_parse(x):
|
||||
return TConnectEntry._epoch_parse(int(x.replace('/Date(', '').replace('-0000)/', '')))
|
||||
|
||||
@staticmethod
|
||||
def parse_ciq_basal_entry(data, delivery_type=""):
|
||||
@@ -40,6 +44,16 @@ class TConnectEntry:
|
||||
"duration_mins": duration_mins,
|
||||
"basal_rate": basal_rate,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def manual_suspension_to_basal_entry(parsedSuspension, seconds):
|
||||
duration_mins = seconds / 60
|
||||
return {
|
||||
"time": parsedSuspension["time"],
|
||||
"delivery_type": "%s suspension" % parsedSuspension["suspendReason"],
|
||||
"duration_mins": duration_mins,
|
||||
"basal_rate": 0.0
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_suspension_entry(data):
|
||||
@@ -100,8 +114,81 @@ class TConnectEntry:
|
||||
"insulin": data["InsulinDelivered"],
|
||||
"requested_insulin": data["ActualTotalBolusRequested"],
|
||||
"carbs": data["CarbSize"],
|
||||
"bg": data["BG"], # Note: can be empty string for automatic Control-IQ boluses
|
||||
"user_override": data["UserOverride"],
|
||||
"extended_bolus": "1" if extended_bolus else "",
|
||||
"bolex_completion_time": TConnectEntry._datetime_parse(data["BolexCompletionDateTime"]).format() if complete and extended_bolus else None,
|
||||
"bolex_start_time": TConnectEntry._datetime_parse(data["BolexStartDateTime"]).format() if complete and extended_bolus else None,
|
||||
}
|
||||
# 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):
|
||||
return {
|
||||
"time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(),
|
||||
"bg": data["Readings (CGM / BGM)"],
|
||||
"type": data["Description"]
|
||||
}
|
||||
|
||||
ACTIVITY_EVENTS = { 1: "Sleep", 2: "Exercise", 3: "AutoBolus", 4: "CarbOnly" }
|
||||
|
||||
@staticmethod
|
||||
def parse_ciq_activity_event(data):
|
||||
if data["eventType"] not in TConnectEntry.ACTIVITY_EVENTS.keys():
|
||||
raise UnknownCIQActivityEventException(data)
|
||||
|
||||
time = TConnectEntry._epoch_parse(data["x"])
|
||||
return {
|
||||
"time": time.format(),
|
||||
"duration_mins": data["duration"] / 60,
|
||||
"event_type": TConnectEntry.ACTIVITY_EVENTS[data["eventType"]]
|
||||
}
|
||||
|
||||
BASALSUSPENSION_EVENTS = {
|
||||
# site-cart corresponds to a Site or Cartridge change,
|
||||
# specifically a Tubing Filled: Norm AND a Cannula Filled: Norm alert.
|
||||
# (This means that a typical changing of a cartridge and then a site
|
||||
# will result in two consecutive events of this type.)
|
||||
"site-cart": "Site/Cartridge Change",
|
||||
|
||||
# alarm corresponds to one of the following:
|
||||
# - an Empty Cartridge alarm
|
||||
# - a Pump shutdown
|
||||
"alarm": "Empty Cartridge/Pump Shutdown",
|
||||
|
||||
# manual corresponds to a Pumping Suspended by User event
|
||||
"manual": "User Suspended"
|
||||
}
|
||||
|
||||
BASALSUSPENSION_SKIPPED_EVENTS = {
|
||||
# basal-profile events are not very useful; with ControlIQ enabled,
|
||||
# Tandem does not show them in the tconnect UI.
|
||||
"basal-profile",
|
||||
|
||||
# If an event continues to occur after the date switches over to the next
|
||||
# day, then the pump generates a "previous" event. This isn't useful to
|
||||
# us, so we skip them.
|
||||
"previous",
|
||||
}
|
||||
@staticmethod
|
||||
def parse_basalsuspension_event(data):
|
||||
if data["SuspendReason"] in TConnectEntry.BASALSUSPENSION_SKIPPED_EVENTS:
|
||||
return None
|
||||
|
||||
if data["SuspendReason"] not in TConnectEntry.BASALSUSPENSION_EVENTS.keys():
|
||||
raise UnknownBasalSuspensionEventException(data)
|
||||
|
||||
time = TConnectEntry._jsonp_epoch_parse(data["EventDateTime"])
|
||||
return {
|
||||
"time": time.format(),
|
||||
"event_type": TConnectEntry.BASALSUSPENSION_EVENTS[data["SuspendReason"]]
|
||||
}
|
||||
|
||||
|
||||
class UnknownCIQActivityEventException(Exception):
|
||||
def __init__(self, data):
|
||||
super().__init__("Unknown CIQ activity event type: %s" % data)
|
||||
|
||||
class UnknownBasalSuspensionEventException(Exception):
|
||||
def __init__(self, data):
|
||||
super().__init__("Unknown basal suspension event type: %s" % data)
|
||||
+47
-12
@@ -18,7 +18,17 @@ from .sync.iob import (
|
||||
process_iob_events,
|
||||
ns_write_iob_events
|
||||
)
|
||||
from .sync.cgm import (
|
||||
process_cgm_events,
|
||||
ns_write_cgm_events
|
||||
)
|
||||
from .sync.pump_events import (
|
||||
process_ciq_activity_events,
|
||||
process_basalsuspension_events,
|
||||
ns_write_pump_events
|
||||
)
|
||||
from .parser.tconnect import TConnectEntry
|
||||
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,7 +37,7 @@ Given a TConnectApi object and start/end range, performs a single
|
||||
cycle of synchronizing data within the time range.
|
||||
If pretend is true, then doesn't actually write data to Nightscout.
|
||||
"""
|
||||
def process_time_range(tconnect, nightscout, time_start, time_end, pretend):
|
||||
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)
|
||||
@@ -59,20 +69,45 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend):
|
||||
|
||||
added = 0
|
||||
|
||||
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
|
||||
if csvBasalData:
|
||||
logger.debug("CSV basal data found: processing it")
|
||||
add_csv_basal_events(basalEvents, csvBasalData)
|
||||
else:
|
||||
logger.debug("No CSV basal data found")
|
||||
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)
|
||||
|
||||
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend)
|
||||
if BASAL in features:
|
||||
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
|
||||
if csvBasalData:
|
||||
logger.debug("CSV basal data found: processing it")
|
||||
add_csv_basal_events(basalEvents, csvBasalData)
|
||||
else:
|
||||
logger.debug("No CSV basal data found")
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData)
|
||||
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend)
|
||||
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)
|
||||
|
||||
iobEvents = process_iob_events(iobData)
|
||||
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
|
||||
ws2BasalSuspension = tconnect.ws2.basalsuspension(time_start, time_end)
|
||||
|
||||
bsPumpEvents = process_basalsuspension_events(ws2BasalSuspension)
|
||||
logger.debug("basalsuspension events: %s" % bsPumpEvents)
|
||||
|
||||
pumpEvents += bsPumpEvents
|
||||
|
||||
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend)
|
||||
|
||||
if BOLUS in features:
|
||||
bolusEvents = process_bolus_events(bolusData)
|
||||
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features))
|
||||
|
||||
if IOB in features:
|
||||
iobEvents = process_iob_events(iobData)
|
||||
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
|
||||
|
||||
logger.info("Wrote %d events to Nightscout this process cycle" % added)
|
||||
return added
|
||||
+16
-9
@@ -1,10 +1,20 @@
|
||||
import os, sys
|
||||
from dotenv import load_dotenv
|
||||
import os, sys, pathlib
|
||||
from dotenv import dotenv_values
|
||||
|
||||
load_dotenv()
|
||||
cwd_path = os.path.join(os.getcwd(), '.env')
|
||||
global_path = os.path.join(pathlib.Path.home(), '.config/tconnectsync/.env')
|
||||
|
||||
values = {}
|
||||
|
||||
if os.path.exists(cwd_path):
|
||||
values = dotenv_values(cwd_path)
|
||||
elif os.path.exists(global_path):
|
||||
values = dotenv_values(global_path)
|
||||
else:
|
||||
values = dotenv_values()
|
||||
|
||||
def get(*args):
|
||||
return os.environ.get(*args)
|
||||
return values.get(args[0], os.environ.get(*args))
|
||||
|
||||
def get_number(name, default):
|
||||
val = get(name, default)
|
||||
@@ -36,11 +46,8 @@ AUTOUPDATE_USE_FIXED_SLEEP = get_bool('AUTOUPDATE_USE_FIXED_SLEEP', 'false')
|
||||
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '180') # 3 hours
|
||||
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
|
||||
|
||||
_config = ['TCONNECT_EMAIL', 'TCONNECT_PASSWORD', 'PUMP_SERIAL_NUMBER',
|
||||
'NS_URL', 'NS_SECRET', 'TIMEZONE_NAME',
|
||||
'AUTOUPDATE_DEFAULT_SLEEP_SECONDS', 'AUTOUPDATE_MAX_SLEEP_SECONDS',
|
||||
'AUTOUPDATE_USE_FIXED_SLEEP', 'AUTOUPDATE_FAILURE_MINUTES',
|
||||
'AUTOUPDATE_RESTART_ON_FAILURE']
|
||||
ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false')
|
||||
SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false')
|
||||
|
||||
if __name__ == '__main__':
|
||||
for k in locals():
|
||||
|
||||
@@ -6,6 +6,7 @@ from ..parser.nightscout import (
|
||||
NightscoutEntry
|
||||
)
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,11 +33,53 @@ def process_ciq_basal_events(data):
|
||||
for b in data["basal"]["profileDeliveryEvents"]:
|
||||
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="profileDelivery"))
|
||||
|
||||
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
|
||||
|
||||
# Suspensions with suspendReason 'control-iq' will match a basal event found above.
|
||||
for i in basalEvents:
|
||||
if i["time"] in suspensionEvents:
|
||||
i["suspendReason"] = suspensionEvents[i["time"]]["suspendReason"]
|
||||
i["delivery_type"] += " (" + suspensionEvents[i["time"]]["suspendReason"] + " suspension)"
|
||||
|
||||
del suspensionEvents[i["time"]]
|
||||
|
||||
# Suspensions with suspendReason 'manual' do not have an associated basal event,
|
||||
# and require extra processing.
|
||||
|
||||
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
|
||||
|
||||
unprocessedSuspensions = list(suspensionEvents.values())
|
||||
unprocessedSuspensions.sort(key=lambda x: arrow.get(x["time"]))
|
||||
|
||||
# For the remaining suspensions which did not match with an existing basal event,
|
||||
# add a new event manually. This means we need to calculate the duration of the
|
||||
# suspension.
|
||||
newEvents = []
|
||||
for i in range(len(basalEvents)):
|
||||
if len(unprocessedSuspensions) == 0:
|
||||
break
|
||||
|
||||
existingTime = arrow.get(basalEvents[i]["time"])
|
||||
unprocessedTime = arrow.get(unprocessedSuspensions[0]["time"])
|
||||
|
||||
# If we've found an event which occurs after the suspension, then the
|
||||
# difference in their timestamps is the duration of the suspension.
|
||||
if i > 0 and existingTime > unprocessedTime:
|
||||
suspension = unprocessedSuspensions.pop(0)
|
||||
|
||||
# TConnect's internal duration object tracks the duration in seconds
|
||||
seconds = (existingTime - unprocessedTime).seconds
|
||||
|
||||
newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds)
|
||||
logger.debug("Creating basal event for unprocessed suspension: %s" % newEvent)
|
||||
newEvents.append(newEvent)
|
||||
|
||||
# Any remaining suspensions which have not been processed have not ended,
|
||||
# which means we do not know their duration; so we will skip them (for now)
|
||||
|
||||
# Add any new events and re-sort
|
||||
if newEvents:
|
||||
basalEvents += newEvents
|
||||
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
|
||||
|
||||
|
||||
return basalEvents
|
||||
|
||||
@@ -69,6 +112,11 @@ def ns_write_basal_events(nightscout, basalEvents, pretend=False):
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout basal upload: %s" % last_upload_time)
|
||||
|
||||
if SKIP_NS_LAST_UPLOADED_CHECK:
|
||||
logger.warning("Overriding last upload check")
|
||||
last_upload = None
|
||||
last_upload_time = None
|
||||
|
||||
add_count = 0
|
||||
for event in basalEvents:
|
||||
if last_upload_time and arrow.get(event["time"]) < last_upload_time:
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
from tconnectsync.sync.cgm import find_event_at
|
||||
|
||||
from ..parser.nightscout import (
|
||||
BOLUS_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
|
||||
"""
|
||||
def process_bolus_events(bolusdata):
|
||||
def process_bolus_events(bolusdata, cgmEvents=None):
|
||||
bolusEvents = []
|
||||
|
||||
for b in bolusdata:
|
||||
@@ -24,16 +27,37 @@ def process_bolus_events(bolusdata):
|
||||
else:
|
||||
logger.warning("Skipping non-completed bolus data (was a bolus in progress?): %s parsed: %s" % (b, parsed))
|
||||
continue
|
||||
|
||||
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["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]))
|
||||
bolusEvents.sort(key=lambda event: arrow.get(event["request_time"] if not event["extended_bolus"] else event["bolex_start_time"]))
|
||||
|
||||
return bolusEvents
|
||||
|
||||
"""
|
||||
Determine whether the given BG specified in the bolus is identical to the
|
||||
most recent CGM reading at that time. If it is, return SENSOR.
|
||||
Otherwise, return FINGER.
|
||||
"""
|
||||
def guess_bolus_bg_type(bg, created_at, cgmEvents):
|
||||
if not cgmEvents:
|
||||
return NightscoutEntry.FINGER
|
||||
|
||||
event = find_event_at(cgmEvents, created_at)
|
||||
if event and str(event["bg"]) == str(bg):
|
||||
return NightscoutEntry.SENSOR
|
||||
|
||||
return NightscoutEntry.FINGER
|
||||
|
||||
|
||||
"""
|
||||
Given processed bolus data, adds bolus events to Nightscout.
|
||||
"""
|
||||
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False):
|
||||
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None):
|
||||
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
|
||||
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE)
|
||||
last_upload_time = None
|
||||
@@ -41,6 +65,11 @@ def ns_write_bolus_events(nightscout, bolusEvents, pretend=False):
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout bolus upload: %s" % last_upload_time)
|
||||
|
||||
if SKIP_NS_LAST_UPLOADED_CHECK:
|
||||
logger.warning("Overriding last upload check")
|
||||
last_upload = None
|
||||
last_upload_time = None
|
||||
|
||||
add_count = 0
|
||||
for event in bolusEvents:
|
||||
created_at = event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]
|
||||
@@ -49,12 +78,22 @@ def ns_write_bolus_events(nightscout, bolusEvents, pretend=False):
|
||||
logger.info("Skipping basal event before last upload time: %s" % event)
|
||||
continue
|
||||
|
||||
entry = NightscoutEntry.bolus(
|
||||
bolus=event["insulin"],
|
||||
carbs=event["carbs"],
|
||||
created_at=created_at,
|
||||
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
|
||||
)
|
||||
if include_bg and event["bg"]:
|
||||
entry = NightscoutEntry.bolus(
|
||||
bolus=event["insulin"],
|
||||
carbs=event["carbs"],
|
||||
created_at=created_at,
|
||||
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else ""),
|
||||
bg=event["bg"],
|
||||
bg_type=event["bg_type"]
|
||||
)
|
||||
else:
|
||||
entry = NightscoutEntry.bolus(
|
||||
bolus=event["insulin"],
|
||||
carbs=event["carbs"],
|
||||
created_at=created_at,
|
||||
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
|
||||
)
|
||||
|
||||
add_count += 1
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..parser.nightscout import NightscoutEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process_cgm_events(readingData):
|
||||
data = []
|
||||
for r in readingData:
|
||||
data.append(TConnectEntry.parse_reading_entry(r))
|
||||
|
||||
return data
|
||||
|
||||
"""
|
||||
Given reading data and a time, finds the BG reading event which would have
|
||||
been the current one at that time. e.g., it looks before the given time,
|
||||
not after.
|
||||
This is a heuristic for checking whether the BG component of a bolus was
|
||||
manually entered or inferred based on the pump's CGM.
|
||||
"""
|
||||
def find_event_at(cgmEvents, find_time):
|
||||
find_t = arrow.get(find_time)
|
||||
events = list(map(lambda x: (arrow.get(x["time"]), x), cgmEvents))
|
||||
events.sort()
|
||||
|
||||
closestReading = None
|
||||
for t, r in events:
|
||||
if t > find_t:
|
||||
break
|
||||
closestReading = r
|
||||
|
||||
|
||||
return closestReading
|
||||
|
||||
|
||||
"""
|
||||
Given processed CGM data, adds reading entries to Nightscout.
|
||||
"""
|
||||
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False):
|
||||
logger.debug("ns_write_cgm_events: querying for last uploaded entry")
|
||||
last_upload = nightscout.last_uploaded_bg_entry()
|
||||
last_upload_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["dateString"])
|
||||
logger.info("Last Nightscout CGM upload: %s" % last_upload_time)
|
||||
|
||||
add_count = 0
|
||||
for event in cgmEvents:
|
||||
created_at = event["time"]
|
||||
if last_upload_time and arrow.get(created_at) <= last_upload_time:
|
||||
if pretend:
|
||||
logger.info("Skipping CGM event before last upload time: %s" % event)
|
||||
continue
|
||||
|
||||
entry = NightscoutEntry.entry(
|
||||
sgv=event["bg"],
|
||||
created_at=created_at
|
||||
)
|
||||
|
||||
add_count += 1
|
||||
|
||||
logger.info(" Processing cgm reading: %s entry: %s" % (event, entry))
|
||||
if not pretend:
|
||||
nightscout.upload_entry(entry, entity='entries')
|
||||
|
||||
return add_count
|
||||
@@ -0,0 +1,206 @@
|
||||
import arrow
|
||||
import logging
|
||||
|
||||
from ..parser.nightscout import (
|
||||
SITECHANGE_EVENTTYPE,
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
EXERCISE_EVENTTYPE,
|
||||
SLEEP_EVENTTYPE,
|
||||
ACTIVITY_EVENTTYPE,
|
||||
NightscoutEntry
|
||||
)
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
Given a list of "activity events" from the CIQ therapy timeline endpoint,
|
||||
process it into our internal events format.
|
||||
|
||||
These events contain a duration.
|
||||
"""
|
||||
def process_ciq_activity_events(data):
|
||||
events = []
|
||||
|
||||
for event in data["events"]:
|
||||
events.append(TConnectEntry.parse_ciq_activity_event(event))
|
||||
|
||||
return events
|
||||
|
||||
"""
|
||||
Given a list of "basal suspension events" from the basalsuspension WS2 endpoint,
|
||||
process it into our internal events format.
|
||||
|
||||
These events do NOT contain a duration.
|
||||
"""
|
||||
def process_basalsuspension_events(data):
|
||||
events = []
|
||||
|
||||
for event in data['BasalSuspension']:
|
||||
parsed = TConnectEntry.parse_basalsuspension_event(event)
|
||||
|
||||
if parsed:
|
||||
events.append(parsed)
|
||||
|
||||
|
||||
return events
|
||||
|
||||
"""
|
||||
Given processed pump event data (of various types), write them to Nightscout
|
||||
"""
|
||||
def ns_write_pump_events(nightscout, pumpEvents, pretend=False):
|
||||
count = 0
|
||||
|
||||
siteChangeEvents = []
|
||||
emptyCartEvents = []
|
||||
userSuspendedEvents = []
|
||||
exerciseEvents = []
|
||||
sleepEvents = []
|
||||
activityEvents = []
|
||||
|
||||
for event in pumpEvents:
|
||||
if event["event_type"] == TConnectEntry.BASALSUSPENSION_EVENTS["site-cart"]:
|
||||
siteChangeEvents.append(event)
|
||||
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["alarm"]:
|
||||
emptyCartEvents.append(event)
|
||||
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["manual"]:
|
||||
userSuspendedEvents.append(event)
|
||||
elif event["event_type"] == "Exercise":
|
||||
exerciseEvents.append(event)
|
||||
elif event["event_type"] == "Sleep":
|
||||
sleepEvents.append(event)
|
||||
elif event["event_type"] in TConnectEntry.ACTIVITY_EVENTS.values():
|
||||
activityEvents.append(event)
|
||||
|
||||
logger.debug("siteChangeEvents: %s" % siteChangeEvents)
|
||||
logger.debug("emptyCartEvents: %s" % emptyCartEvents)
|
||||
logger.debug("userSuspendedEvents: %s" % userSuspendedEvents)
|
||||
logger.debug("exerciseEvents: %s" % exerciseEvents)
|
||||
logger.debug("sleepEvents: %s" % sleepEvents)
|
||||
logger.debug("activityEvents: %s" % activityEvents)
|
||||
|
||||
count += ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=pretend)
|
||||
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend)
|
||||
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend)
|
||||
|
||||
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)
|
||||
|
||||
return count
|
||||
|
||||
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
siteChangeEvents,
|
||||
lambda event: NightscoutEntry.sitechange(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"]
|
||||
),
|
||||
SITECHANGE_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
|
||||
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
emptyCartEvents,
|
||||
lambda event: NightscoutEntry.basalsuspension(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"]
|
||||
),
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
|
||||
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
userSuspendedEvents,
|
||||
lambda event: NightscoutEntry.basalsuspension(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"]
|
||||
),
|
||||
BASALSUSPENSION_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
|
||||
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
exerciseEvents,
|
||||
lambda event: NightscoutEntry.activity(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"],
|
||||
duration=event["duration_mins"],
|
||||
event_type=EXERCISE_EVENTTYPE
|
||||
),
|
||||
EXERCISE_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
|
||||
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
sleepEvents,
|
||||
lambda event: NightscoutEntry.activity(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"],
|
||||
duration=event["duration_mins"],
|
||||
event_type=SLEEP_EVENTTYPE
|
||||
),
|
||||
SLEEP_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
|
||||
def ns_write_activity_events(nightscout, activityEvents, pretend=False):
|
||||
return _ns_write_pump_events(
|
||||
nightscout,
|
||||
activityEvents,
|
||||
lambda event: NightscoutEntry.activity(
|
||||
created_at=event["time"],
|
||||
reason=event["event_type"],
|
||||
duration=event["duration_mins"]
|
||||
),
|
||||
ACTIVITY_EVENTTYPE,
|
||||
pretend=pretend)
|
||||
|
||||
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False):
|
||||
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_time = None
|
||||
if last_upload:
|
||||
last_upload_time = arrow.get(last_upload["created_at"])
|
||||
logger.info("Last Nightscout %s: %s" % (eventType, last_upload_time))
|
||||
|
||||
if SKIP_NS_LAST_UPLOADED_CHECK:
|
||||
logger.warning("Overriding last upload check")
|
||||
last_upload = None
|
||||
last_upload_time = None
|
||||
|
||||
add_count = 0
|
||||
for event in events:
|
||||
created_at = event["time"]
|
||||
if last_upload_time and arrow.get(created_at) <= last_upload_time:
|
||||
skip = True
|
||||
if "duration_mins" in event.keys() and "duration" in last_upload.keys():
|
||||
if created_at == last_upload["created_at"] and float(event["duration_mins"]) > float(last_upload["duration"]):
|
||||
logger.info("Latest %s event needs updating: duration has increased from %s to %s: %s" % (eventType, last_upload["duration"], event["duration_mins"], event))
|
||||
logger.info("Deleting previous %s: %s" % (eventType, last_upload))
|
||||
nightscout.delete_entry('treatments/%s' % last_upload["_id"])
|
||||
skip = False
|
||||
|
||||
if skip:
|
||||
if pretend:
|
||||
logger.info("Skipping %s event before last upload time: %s" % (eventType, event))
|
||||
continue
|
||||
|
||||
entry = buildNsEventFunc(event)
|
||||
|
||||
add_count += 1
|
||||
|
||||
logger.info(" Processing %s: %s entry: %s" % (eventType, event, entry))
|
||||
if not pretend:
|
||||
nightscout.upload_entry(entry)
|
||||
|
||||
return add_count
|
||||
@@ -0,0 +1,10 @@
|
||||
# Hack to enable debug-level logging when running tests
|
||||
# with `python3 -m unittest discover`
|
||||
|
||||
import sys
|
||||
if 'unittest' in sys.modules:
|
||||
import logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s %(levelname)-8s %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException
|
||||
|
||||
class TestNightscoutEntry(unittest.TestCase):
|
||||
def test_basal(self):
|
||||
@@ -73,6 +73,69 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
def test_bolus_with_bg(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.bolus(
|
||||
bolus=7.5,
|
||||
carbs=45,
|
||||
created_at="2021-03-16 00:25:21-04:00",
|
||||
bg="123",
|
||||
bg_type=NightscoutEntry.SENSOR),
|
||||
{
|
||||
"eventType": "Combo Bolus",
|
||||
"created_at": "2021-03-16 00:25:21-04:00",
|
||||
"carbs": 45,
|
||||
"insulin": 7.5,
|
||||
"notes": "",
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"glucose": "123",
|
||||
"glucoseType": "Sensor"
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
NightscoutEntry.bolus(
|
||||
bolus=0.5,
|
||||
carbs=5,
|
||||
created_at="2021-03-16 12:25:21-04:00",
|
||||
bg="150",
|
||||
bg_type=NightscoutEntry.FINGER),
|
||||
{
|
||||
"eventType": "Combo Bolus",
|
||||
"created_at": "2021-03-16 12:25:21-04:00",
|
||||
"carbs": 5,
|
||||
"insulin": 0.5,
|
||||
"notes": "",
|
||||
"enteredBy": "Pump (tconnectsync)",
|
||||
"glucose": "150",
|
||||
"glucoseType": "Finger"
|
||||
}
|
||||
)
|
||||
|
||||
def test_bolus_with_bg_invalid_type(self):
|
||||
self.assertRaises(InvalidBolusTypeException,
|
||||
NightscoutEntry.bolus,
|
||||
bolus=0.5,
|
||||
carbs=5,
|
||||
created_at="2021-03-16 12:25:21-04:00",
|
||||
bg="150")
|
||||
|
||||
self.assertRaises(InvalidBolusTypeException,
|
||||
NightscoutEntry.bolus,
|
||||
bolus=0.5,
|
||||
carbs=5,
|
||||
created_at="2021-03-16 12:25:21-04:00",
|
||||
bg="150",
|
||||
bg_type="")
|
||||
|
||||
self.assertRaises(InvalidBolusTypeException,
|
||||
NightscoutEntry.bolus,
|
||||
bolus=0.5,
|
||||
carbs=5,
|
||||
created_at="2021-03-16 12:25:21-04:00",
|
||||
bg="150",
|
||||
bg_type="unknown")
|
||||
|
||||
def test_iob(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.iob(
|
||||
@@ -86,6 +149,48 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
def test_entry(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.entry(
|
||||
sgv=152,
|
||||
created_at="2021-10-23 22:17:14-04:00"),
|
||||
{
|
||||
"type": "sgv",
|
||||
"sgv": 152,
|
||||
"date": 1635041834000,
|
||||
"dateString": "2021-10-23T22:17:14-0400",
|
||||
"device": "Pump (tconnectsync)",
|
||||
}
|
||||
)
|
||||
|
||||
def test_sitechange(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.sitechange(
|
||||
created_at="2021-12-05T00:16:35.058Z",
|
||||
reason="reason"),
|
||||
{
|
||||
"eventType": "Site Change",
|
||||
"reason": "reason",
|
||||
"notes": "reason",
|
||||
"created_at": "2021-12-05T00:16:35.058Z",
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
}
|
||||
)
|
||||
|
||||
def test_basalsuspension(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.basalsuspension(
|
||||
created_at="2021-12-05T00:16:35.058Z",
|
||||
reason="reason"),
|
||||
{
|
||||
"eventType": "Basal Suspension",
|
||||
"reason": "reason",
|
||||
"notes": "reason",
|
||||
"created_at": "2021-12-05T00:16:35.058Z",
|
||||
"enteredBy": "Pump (tconnectsync)"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
from tconnectsync.parser.tconnect import TConnectEntry, UnknownBasalSuspensionEventException, UnknownCIQActivityEventException
|
||||
|
||||
class TestTConnectEntryBasal(unittest.TestCase):
|
||||
def test_parse_ciq_basal_entry(self):
|
||||
@@ -60,6 +60,26 @@ class TestTConnectEntrySuspension(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
class TestTConnectEntrySuspensionToBasal(unittest.TestCase):
|
||||
def test_manual_suspension_to_basal_entry(self):
|
||||
suspension = {
|
||||
"time": "2021-03-16 00:30:21-04:00",
|
||||
"continuation": None,
|
||||
"suspendReason": "manual"
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
TConnectEntry.manual_suspension_to_basal_entry(
|
||||
suspension,
|
||||
seconds=300
|
||||
), {
|
||||
"time": "2021-03-16 00:30:21-04:00",
|
||||
"delivery_type": "manual suspension",
|
||||
"duration_mins": 5.0,
|
||||
"basal_rate": 0.0
|
||||
}
|
||||
)
|
||||
|
||||
class TestTConnectEntryCGM(unittest.TestCase):
|
||||
def test_parse_cgm_entry(self):
|
||||
self.assertEqual(
|
||||
@@ -166,6 +186,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"insulin": "13.53",
|
||||
"requested_insulin": "13.53",
|
||||
"carbs": "75",
|
||||
"bg": "141",
|
||||
"user_override": "0",
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
@@ -227,6 +248,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"insulin": "1.25",
|
||||
"requested_insulin": "1.25",
|
||||
"carbs": "0",
|
||||
"bg": "159",
|
||||
"user_override": "1",
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
@@ -288,6 +310,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"insulin": "1.70",
|
||||
"requested_insulin": "1.70",
|
||||
"carbs": "0",
|
||||
"bg": "",
|
||||
"user_override": "0",
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
@@ -349,6 +372,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"insulin": "0.00",
|
||||
"requested_insulin": "0.50",
|
||||
"carbs": "0",
|
||||
"bg": "144",
|
||||
"user_override": "1",
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
@@ -410,11 +434,193 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"insulin": "1.82",
|
||||
"requested_insulin": "2.63",
|
||||
"carbs": "0",
|
||||
"bg": "189",
|
||||
"user_override": "0",
|
||||
"extended_bolus": "",
|
||||
"bolex_completion_time": None,
|
||||
"bolex_start_time": None
|
||||
})
|
||||
|
||||
class TestTConnectEntryReading(unittest.TestCase):
|
||||
entry1 = {
|
||||
"DeviceType": "t:slim X2 Insulin Pump",
|
||||
"SerialNumber": "90556643",
|
||||
"Description": "EGV",
|
||||
"EventDateTime": "2021-10-23T12:55:53",
|
||||
"Readings (CGM / BGM)": "135"
|
||||
}
|
||||
def test_parse_reading_entry1(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_reading_entry(self.entry1),
|
||||
{
|
||||
"time": "2021-10-23 12:55:53-04:00",
|
||||
"bg": "135",
|
||||
"type": "EGV"
|
||||
}
|
||||
)
|
||||
|
||||
entry2 = {
|
||||
"DeviceType": "t:slim X2 Insulin Pump",
|
||||
"SerialNumber": "90556643",
|
||||
"Description": "EGV",
|
||||
"EventDateTime": "2021-10-23T16:15:52",
|
||||
"Readings (CGM / BGM)": "93"
|
||||
}
|
||||
def test_parse_reading_entry2(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_reading_entry(self.entry2),
|
||||
{
|
||||
"time": "2021-10-23 16:15:52-04:00",
|
||||
"bg": "93",
|
||||
"type": "EGV"
|
||||
}
|
||||
)
|
||||
|
||||
entry3 = {
|
||||
"DeviceType": "t:slim X2 Insulin Pump",
|
||||
"SerialNumber": "90556643",
|
||||
"Description": "EGV",
|
||||
"EventDateTime": "2021-10-23T16:20:52",
|
||||
"Readings (CGM / BGM)": "100"
|
||||
}
|
||||
def test_parse_reading_entry3(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_reading_entry(self.entry3),
|
||||
{
|
||||
"time": "2021-10-23 16:20:52-04:00",
|
||||
"bg": "100",
|
||||
"type": "EGV"
|
||||
}
|
||||
)
|
||||
|
||||
entry4 = {
|
||||
"DeviceType": "t:slim X2 Insulin Pump",
|
||||
"SerialNumber": "90556643",
|
||||
"Description": "EGV",
|
||||
"EventDateTime": "2021-10-23T16:25:52",
|
||||
"Readings (CGM / BGM)": "107"
|
||||
}
|
||||
def test_parse_reading_entry4(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_reading_entry(self.entry4),
|
||||
{
|
||||
"time": "2021-10-23 16:25:52-04:00",
|
||||
"bg": "107",
|
||||
"type": "EGV"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestTConnectEntryCIQEvent(unittest.TestCase):
|
||||
def test_parse_ciq_activity_event_sleep(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_ciq_activity_event({
|
||||
"continuation": None,
|
||||
"duration": 30661,
|
||||
"eventType": 1,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1638091836
|
||||
}),
|
||||
{
|
||||
"time": "2021-11-28 01:30:36-05:00",
|
||||
"duration_mins": (30661 / 60),
|
||||
"event_type": "Sleep"
|
||||
}
|
||||
)
|
||||
|
||||
def test_parse_ciq_activity_event_exercise(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_ciq_activity_event({
|
||||
"duration": 1200,
|
||||
"eventType": 2,
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912
|
||||
}),
|
||||
{
|
||||
"time": "2021-05-01 13:45:12-04:00",
|
||||
"duration_mins": 20,
|
||||
"event_type": "Exercise"
|
||||
}
|
||||
)
|
||||
|
||||
def test_parse_ciq_activity_event_unknown_id(self):
|
||||
self.assertRaises(
|
||||
UnknownCIQActivityEventException,
|
||||
TConnectEntry.parse_ciq_activity_event,
|
||||
{
|
||||
"duration": 1200,
|
||||
"eventType": 5,
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912
|
||||
}
|
||||
)
|
||||
|
||||
class TestTConnectEntryBasalSuspensionEvent(unittest.TestCase):
|
||||
def test_parse_basalsuspension_event_sitecart(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_basalsuspension_event({
|
||||
'EventDateTime': '/Date(1638663490000-0000)/',
|
||||
'SuspendReason': 'site-cart'
|
||||
}),
|
||||
{
|
||||
"time": "2021-12-04 16:18:10-05:00",
|
||||
"event_type": "Site/Cartridge Change"
|
||||
}
|
||||
)
|
||||
|
||||
def test_parse_basalsuspension_event_alarm(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_basalsuspension_event({
|
||||
'EventDateTime': '/Date(1637863616000-0000)/',
|
||||
'SuspendReason': 'alarm'
|
||||
}),
|
||||
{
|
||||
"time": "2021-11-25 10:06:56-05:00",
|
||||
"event_type": "Empty Cartridge/Pump Shutdown"
|
||||
}
|
||||
)
|
||||
|
||||
def test_parse_basalsuspension_event_manual(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_basalsuspension_event({
|
||||
'EventDateTime': '/Date(1638662852000-0000)/',
|
||||
'SuspendReason': 'manual'
|
||||
}),
|
||||
{
|
||||
"time": "2021-12-04 16:07:32-05:00",
|
||||
"event_type": "User Suspended"
|
||||
}
|
||||
)
|
||||
|
||||
def test_parse_basalsuspension_event_basalprofile_skipped(self):
|
||||
self.assertIsNone(
|
||||
TConnectEntry.parse_basalsuspension_event({
|
||||
'EventDateTime': '/Date(1638659343000-0000)/',
|
||||
'SuspendReason': 'basal-profile',
|
||||
})
|
||||
)
|
||||
|
||||
def test_parse_basalsuspension_event_previous_skipped(self):
|
||||
self.assertIsNone(
|
||||
TConnectEntry.parse_basalsuspension_event({
|
||||
'Continuation': 'continuation',
|
||||
'EventDateTime': '/Date(1638604800000-0000)/',
|
||||
'SuspendReason': 'previous',
|
||||
})
|
||||
)
|
||||
|
||||
def test_parse_basalsuspension_event_unknown_suspendreason(self):
|
||||
self.assertRaises(
|
||||
UnknownBasalSuspensionEventException,
|
||||
TConnectEntry.parse_basalsuspension_event,
|
||||
{
|
||||
'EventDateTime': '/Date(1638604800000-0000)/',
|
||||
'SuspendReason': 'unknown',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+71
-11
@@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import copy
|
||||
|
||||
from tconnectsync.sync.basal import process_ciq_basal_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
|
||||
class TestBasalSync(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
base = {
|
||||
"basal": {
|
||||
"profileRates": [],
|
||||
@@ -20,31 +24,31 @@ class TestBasalSync(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def get_example_ciq_basal_events():
|
||||
data = TestBasalSync.base.copy()
|
||||
data = copy.deepcopy(TestBasalSync.base)
|
||||
data["basal"]["tempDeliveryEvents"] = [
|
||||
{
|
||||
"y": 0.8,
|
||||
"duration": 1221,
|
||||
"x": 1615878000
|
||||
"x": 1615878000 # 12:00:00
|
||||
}
|
||||
]
|
||||
data["basal"]["algorithmDeliveryEvents"] = [
|
||||
{
|
||||
"y": 0.797,
|
||||
"duration": 300,
|
||||
"x": 1615879521
|
||||
"x": 1615879521 # 12:25:21
|
||||
},
|
||||
{
|
||||
"y": 0,
|
||||
"duration": 2693,
|
||||
"x": 1615879821
|
||||
"x": 1615879821 # 12:30:21
|
||||
},
|
||||
]
|
||||
data["basal"]["profileDeliveryEvents"] = [
|
||||
{
|
||||
"y": 0.799,
|
||||
"duration": 300,
|
||||
"x": 1615879221
|
||||
"x": 1615879221 # 12:20:21
|
||||
}
|
||||
]
|
||||
|
||||
@@ -52,7 +56,7 @@ class TestBasalSync(unittest.TestCase):
|
||||
{
|
||||
"suspendReason": "control-iq",
|
||||
"continuation": None,
|
||||
"x": 1615879821
|
||||
"x": 1615879821 # 12:30:21
|
||||
},
|
||||
]
|
||||
|
||||
@@ -73,12 +77,68 @@ class TestBasalSync(unittest.TestCase):
|
||||
self.assertEqual(basalEvents[2], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
|
||||
|
||||
self.assertEqual(basalEvents[3], {
|
||||
"suspendReason": "control-iq",
|
||||
**TConnectEntry.parse_ciq_basal_entry(
|
||||
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][1],
|
||||
delivery_type="algorithmDelivery")
|
||||
})
|
||||
delivery_type="algorithmDelivery (control-iq suspension)")
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_example_ciq_basal_events_with_manual_suspension():
|
||||
data = copy.deepcopy(TestBasalSync.base)
|
||||
data["basal"]["tempDeliveryEvents"] = []
|
||||
data["basal"]["algorithmDeliveryEvents"] = [
|
||||
{
|
||||
"y": 1.14,
|
||||
"duration": 300,
|
||||
"x": 1635187357 # 11:42:37
|
||||
},
|
||||
{
|
||||
"y": 0.8,
|
||||
"duration": 1198,
|
||||
"x": 1635187657 # 11:47:37
|
||||
},
|
||||
{
|
||||
"y": 0.8,
|
||||
"duration": 599,
|
||||
"x": 1635190967 # 12:42:47
|
||||
}
|
||||
]
|
||||
data["basal"]["profileDeliveryEvents"] = []
|
||||
data["suspensionDeliveryEvents"] = [
|
||||
{
|
||||
"suspendReason": "manual",
|
||||
"continuation": None,
|
||||
"x": 1635188855 # 12:07:35
|
||||
},
|
||||
{
|
||||
"suspendReason": "manual",
|
||||
"continuation": None,
|
||||
"x": 1635191566 # 12:52:46
|
||||
},
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
def test_process_ciq_basal_events_with_manual_suspension(self):
|
||||
data = TestBasalSync.get_example_ciq_basal_events_with_manual_suspension()
|
||||
|
||||
basalEvents = process_ciq_basal_events(data)
|
||||
self.assertEqual(len(basalEvents), 4)
|
||||
|
||||
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
|
||||
|
||||
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][1], delivery_type="algorithmDelivery"))
|
||||
|
||||
self.assertEqual(basalEvents[2], TConnectEntry.manual_suspension_to_basal_entry(
|
||||
TConnectEntry.parse_suspension_entry(data["suspensionDeliveryEvents"][0]),
|
||||
seconds=2112, # 2112 seconds between 12:07:35 and 12:42:47
|
||||
))
|
||||
|
||||
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
|
||||
data["basal"]["algorithmDeliveryEvents"][2], delivery_type="algorithmDelivery"))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import unittest
|
||||
import random
|
||||
|
||||
|
||||
from tconnectsync.sync.bolus import process_bolus_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry
|
||||
|
||||
from ..parser.test_tconnect import TestTConnectEntryBolus
|
||||
from ..parser.test_tconnect import TestTConnectEntryBolus, TestTConnectEntryCGM, TestTConnectEntryReading
|
||||
|
||||
class TestBolusSync(unittest.TestCase):
|
||||
|
||||
@@ -33,6 +35,96 @@ class TestBolusSync(unittest.TestCase):
|
||||
TConnectEntry.parse_bolus_entry(d) for d in bolusData
|
||||
])
|
||||
|
||||
def test_process_bolus_events_cgmevents_not_matching(self):
|
||||
bolusData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
TestTConnectEntryBolus.entryStd,
|
||||
TestTConnectEntryBolus.entryStdAutomatic
|
||||
]
|
||||
|
||||
cgmEvents = [
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3)
|
||||
]
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry["bg_type"] = type
|
||||
return entry
|
||||
|
||||
# Expect FINGER for bolus entries with a BG because there's no matching event with the same BG
|
||||
expected = [
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.FINGER),
|
||||
# No BG specified for the automatic bolus
|
||||
TConnectEntry.parse_bolus_entry(bolusData[2])
|
||||
]
|
||||
|
||||
self.assertListEqual(bolusEvents, expected)
|
||||
|
||||
def test_process_bolus_events_cgmevents_matches(self):
|
||||
bolusData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
TestTConnectEntryBolus.entryStd,
|
||||
TestTConnectEntryBolus.entryStdAutomatic
|
||||
]
|
||||
|
||||
cgmEvents = [
|
||||
{
|
||||
"time": "2021-04-01 12:45:30-04:00",
|
||||
"bg": "100",
|
||||
"type": "EGV"
|
||||
},
|
||||
# Matches entryStdCorrection time but with wrong BG
|
||||
{
|
||||
"time": "2021-04-01 12:50:30-04:00",
|
||||
"bg": "105",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 13:00:30-04:00",
|
||||
"bg": "110",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 23:15:30-04:00",
|
||||
"bg": "150",
|
||||
"type": "EGV"
|
||||
},
|
||||
# Matches entryStd time with correct BG
|
||||
{
|
||||
"time": "2021-04-01 23:20:30-04:00",
|
||||
"bg": "159",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 23:25:30-04:00",
|
||||
"bg": "160",
|
||||
"type": "EGV"
|
||||
},
|
||||
]
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry["bg_type"] = type
|
||||
return entry
|
||||
|
||||
expected = [
|
||||
# Time found but BG doesn't match
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
|
||||
# Time found and BG matches
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.SENSOR),
|
||||
# No BG specified for the automatic bolus
|
||||
TConnectEntry.parse_bolus_entry(bolusData[2])
|
||||
]
|
||||
|
||||
self.assertListEqual(bolusEvents, expected)
|
||||
|
||||
def test_process_bolus_events_update_partial_description(self):
|
||||
stdData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
|
||||
from tconnectsync.sync.cgm import find_event_at, process_cgm_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
|
||||
from ..parser.test_tconnect import TestTConnectEntryReading
|
||||
|
||||
class TestProcessCGMEvents(unittest.TestCase):
|
||||
def test_process_cgm_events(self):
|
||||
rawReadings = [
|
||||
TestTConnectEntryReading.entry1,
|
||||
TestTConnectEntryReading.entry2,
|
||||
TestTConnectEntryReading.entry3,
|
||||
TestTConnectEntryReading.entry4
|
||||
]
|
||||
self.assertListEqual(
|
||||
process_cgm_events(rawReadings),
|
||||
[TConnectEntry.parse_reading_entry(r) for r in rawReadings]
|
||||
)
|
||||
|
||||
class TestFindEventAt(unittest.TestCase):
|
||||
readingData = [
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry4)
|
||||
]
|
||||
|
||||
def test_find_event_at_exact(self):
|
||||
for r in self.readingData:
|
||||
self.assertEqual(find_event_at(self.readingData, r["time"]), r)
|
||||
|
||||
def test_find_event_at_before_not_found(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-22 10:30:00-04:00"), None)
|
||||
|
||||
def test_find_event_at_large_gap(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 13:30:00-04:00"), self.readingData[0])
|
||||
|
||||
def test_find_event_at_between_close(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:17:52-04:00"), self.readingData[1])
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:21:52-04:00"), self.readingData[2])
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:25:59-04:00"), self.readingData[3])
|
||||
|
||||
def test_find_event_at_most_recent(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 18:00:00-04:00"), self.readingData[3])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+423
-11
@@ -3,9 +3,11 @@
|
||||
import unittest
|
||||
import datetime
|
||||
import pprint
|
||||
import copy
|
||||
|
||||
from tconnectsync.process import process_time_range
|
||||
from tconnectsync.parser.nightscout import IOB_ACTIVITYTYPE, NightscoutEntry
|
||||
from tconnectsync.parser.nightscout import EXERCISE_EVENTTYPE, IOB_ACTIVITYTYPE, SLEEP_EVENTTYPE, NightscoutEntry
|
||||
from tconnectsync.features import BASAL, BOLUS, IOB, PUMP_EVENTS
|
||||
|
||||
from .api.fake import TConnectApi
|
||||
from .nightscout_fake import NightscoutApi
|
||||
@@ -17,7 +19,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def stub_therapy_timeline(self, time_start, time_end):
|
||||
pass
|
||||
return copy.deepcopy(TestBasalSync.base)
|
||||
|
||||
def stub_therapy_timeline_csv(self, time_start, time_end):
|
||||
return {
|
||||
@@ -26,6 +28,9 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
"basalData": [],
|
||||
"bolusData": []
|
||||
}
|
||||
|
||||
def stub_ws2_basalsuspension(self, time_start, time_end):
|
||||
return {"BasalSuspension": []}
|
||||
|
||||
def stub_last_uploaded_entry(self, event_type):
|
||||
return None
|
||||
@@ -55,7 +60,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)
|
||||
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), {
|
||||
@@ -63,11 +68,38 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
NightscoutEntry.basal(0.8, 20.35, "2021-03-16 00:00:00-04:00", reason="tempDelivery"),
|
||||
NightscoutEntry.basal(0.799, 5.0, "2021-03-16 00:20:21-04:00", reason="profileDelivery"),
|
||||
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
|
||||
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
|
||||
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""No data in Nightscout. Nothing should be updated in Nightscout without the BASAL feature."""
|
||||
def test_basal_data_not_updated_without_feature(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 4, 20, 12, 0)
|
||||
end = datetime.datetime(2021, 4, 21, 12, 0)
|
||||
|
||||
def fake_therapy_timeline(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return TestBasalSync.get_example_ciq_basal_events()
|
||||
|
||||
tconnect.controliq.therapy_timeline = fake_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, IOB])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""Two basal entries in Nightscout. Two new basal entries in tconnect."""
|
||||
def test_partial_ciq_basal_data(self):
|
||||
@@ -98,13 +130,13 @@ 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)
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
|
||||
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
|
||||
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
@@ -142,13 +174,13 @@ 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)
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
|
||||
self.assertDictEqual(nightscout.uploaded_entries, {
|
||||
"treatments": [
|
||||
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
|
||||
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
|
||||
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
|
||||
]})
|
||||
self.assertEqual(len(nightscout.put_entries["treatments"]), 1)
|
||||
self.assertDictEqual(dict(nightscout.put_entries), {
|
||||
@@ -185,7 +217,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)
|
||||
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))
|
||||
@@ -199,6 +231,37 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""No data in Nightscout. Nothing should be updated in Nightscout without the BOLUS feature."""
|
||||
def test_bolus_data_not_updated_without_feature(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 4, 20, 12, 0)
|
||||
end = datetime.datetime(2021, 4, 21, 12, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
|
||||
bolusData = TestBolusSync.get_example_csv_bolus_events()
|
||||
def fake_therapy_timeline_csv(time_start, time_end):
|
||||
return {
|
||||
**self.stub_therapy_timeline_csv(time_start, time_end),
|
||||
"bolusData": bolusData,
|
||||
}
|
||||
|
||||
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
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, [])
|
||||
|
||||
"""No data in Nightscout. Uploads new iob reading from tconnect."""
|
||||
def test_new_ciq_iob_data(self):
|
||||
tconnect = TConnectApi()
|
||||
@@ -223,7 +286,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)
|
||||
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)
|
||||
@@ -234,6 +297,36 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""No data in Nightscout. Nothing should be updated in Nightscout without the IOB feature."""
|
||||
def test_iob_data_not_updated_without_feature(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 4, 20, 12, 0)
|
||||
end = datetime.datetime(2021, 4, 21, 12, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
|
||||
iobData = TestIOBSync.get_example_csv_iob_events()
|
||||
def fake_therapy_timeline_csv(time_start, time_end):
|
||||
return {
|
||||
**self.stub_therapy_timeline_csv(time_start, time_end),
|
||||
"iobData": iobData,
|
||||
}
|
||||
|
||||
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, BOLUS])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 0)
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""Existing IOB in Nightscout. Uploads new iob reading and deletes old IOB."""
|
||||
def test_updates_ciq_iob_data(self):
|
||||
@@ -268,7 +361,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
|
||||
nightscout.last_uploaded_activity = fake_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False)
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[IOB])
|
||||
|
||||
pprint.pprint(nightscout.uploaded_entries)
|
||||
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 1)
|
||||
@@ -281,8 +374,327 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
self.assertListEqual(nightscout.deleted_entries, [
|
||||
"activity/sentinel_existing_iob_id"
|
||||
])
|
||||
|
||||
"""No pump activity events in Nightscout. New CIQ activity events."""
|
||||
def test_new_ciq_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
def fake_therapy_timeline(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {
|
||||
**TestBasalSync.base,
|
||||
"events": [{
|
||||
"duration": 1200,
|
||||
"eventType": 2, # Exercise
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912 # 2021-05-01 13:45:12-04:00
|
||||
}, {
|
||||
"duration": 30661,
|
||||
"eventType": 1, # Sleep
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619992000 # 2021-05-02 14:46:40-04:00
|
||||
}]
|
||||
}
|
||||
|
||||
tconnect.controliq.therapy_timeline = fake_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
NightscoutEntry.activity(created_at="2021-05-01 13:45:12-04:00", duration=20, reason="Exercise", event_type=EXERCISE_EVENTTYPE),
|
||||
NightscoutEntry.activity(created_at="2021-05-02 14:46:40-04:00", duration=30661/60, reason="Sleep", event_type=SLEEP_EVENTTYPE),
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""No pump activity events in Nightscout. New CIQ activity events, but feature is disabled."""
|
||||
def test_no_ciq_activity_events_without_feature(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
def fake_therapy_timeline(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {
|
||||
**TestBasalSync.base,
|
||||
"events": [{
|
||||
"duration": 1200,
|
||||
"eventType": 2, # Exercise
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912 # 2021-05-01 13:45:12-04:00
|
||||
}, {
|
||||
"duration": 30661,
|
||||
"eventType": 1, # Sleep
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619992000 # 2021-05-02 14:46:40-04:00
|
||||
}]
|
||||
}
|
||||
|
||||
tconnect.controliq.therapy_timeline = fake_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
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, [])
|
||||
|
||||
|
||||
"""
|
||||
Existing Sleep event in Nightscout with shorter duration than current, as well as a past Exercise event.
|
||||
Ensures that the old sleep event is deleted and a new one is created with the correct duration."""
|
||||
def test_existing_ciq_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
def fake_therapy_timeline(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {
|
||||
**TestBasalSync.base,
|
||||
"events": [{
|
||||
"duration": 1200,
|
||||
"eventType": 2, # Exercise
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619901912 # 2021-05-01 13:45:12-04:00
|
||||
}, {
|
||||
"duration": 4200, # Currently 60 mins (3600), changing to 70 mins (4200)
|
||||
"eventType": 1, # Sleep
|
||||
"continuation": None,
|
||||
"timeZoneId": "America/Los_Angeles",
|
||||
"x": 1619992000 # 2021-05-02 14:46:40-04:00
|
||||
}]
|
||||
}
|
||||
|
||||
tconnect.controliq.therapy_timeline = fake_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type):
|
||||
if event_type == "Sleep":
|
||||
return {
|
||||
"created_at": "2021-05-02 14:46:40-04:00",
|
||||
"duration": 60,
|
||||
"_id": "old_sleep"
|
||||
}
|
||||
elif event_type == "Exercise":
|
||||
return {
|
||||
"created_at": "2021-05-01 13:45:12-04:00",
|
||||
"duration": 20,
|
||||
"_id": "exercise"
|
||||
}
|
||||
return self.stub_last_uploaded_entry()
|
||||
|
||||
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])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
# Already exists:
|
||||
# NightscoutEntry.activity(created_at="2021-05-01 13:45:12-04:00", duration=20, reason="Exercise", event_type=EXERCISE_EVENTTYPE),
|
||||
# Updated event duration:
|
||||
NightscoutEntry.activity(created_at="2021-05-02 14:46:40-04:00", duration=70, reason="Sleep", event_type=SLEEP_EVENTTYPE),
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertEqual(len(nightscout.deleted_entries), 1)
|
||||
self.assertListEqual(nightscout.deleted_entries, [
|
||||
"treatments/old_sleep"
|
||||
])
|
||||
|
||||
"""No pump activity events in Nightscout. New WS2 activity events."""
|
||||
def test_new_ws2_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
def fake_basalsuspension(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {"BasalSuspension": [
|
||||
{
|
||||
'EventDateTime': '/Date(1638663490000-0000)/',
|
||||
'SuspendReason': 'site-cart'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1637863616000-0000)/',
|
||||
'SuspendReason': 'alarm'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1638662852000-0000)/',
|
||||
'SuspendReason': 'manual'
|
||||
}
|
||||
]}
|
||||
|
||||
tconnect.ws2.basalsuspension = fake_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 3)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
NightscoutEntry.sitechange(created_at="2021-12-04 16:18:10-05:00", reason="Site/Cartridge Change"),
|
||||
NightscoutEntry.basalsuspension(created_at="2021-11-25 10:06:56-05:00", reason="Empty Cartridge/Pump Shutdown"),
|
||||
NightscoutEntry.basalsuspension(created_at="2021-12-04 16:07:32-05:00", reason="User Suspended")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""Existing pump activity events in Nightscout. New WS2 activity events. Only adds new events."""
|
||||
def test_existing_ws2_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
def fake_basalsuspension(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {"BasalSuspension": [
|
||||
{
|
||||
'EventDateTime': '/Date(1638663490000-0000)/',
|
||||
'SuspendReason': 'site-cart'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1637863616000-0000)/',
|
||||
'SuspendReason': 'alarm'
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1638662852000-0000)/',
|
||||
'SuspendReason': 'manual'
|
||||
},
|
||||
# This event is new:
|
||||
{
|
||||
'EventDateTime': '/Date(1638672852000-0000)/',
|
||||
'SuspendReason': 'manual'
|
||||
}
|
||||
]}
|
||||
|
||||
tconnect.ws2.basalsuspension = fake_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
def fake_last_uploaded_entry(event_type):
|
||||
if event_type == "Site Change":
|
||||
return {
|
||||
"created_at": "2021-12-04 16:18:10-05:00"
|
||||
}
|
||||
elif event_type == "Basal Suspension":
|
||||
return {
|
||||
"created_at": "2021-12-04 16:07:32-05:00"
|
||||
}
|
||||
return self.stub_last_uploaded_entry()
|
||||
|
||||
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])
|
||||
|
||||
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
|
||||
self.assertDictEqual(dict(nightscout.uploaded_entries), {
|
||||
"treatments": [
|
||||
NightscoutEntry.basalsuspension(created_at="2021-12-04 18:54:12-05:00", reason="User Suspended")
|
||||
]})
|
||||
self.assertDictEqual(nightscout.put_entries, {})
|
||||
self.assertListEqual(nightscout.deleted_entries, [])
|
||||
|
||||
"""No pump activity events in nightscout. New WS2 activity events, but only of skipped types. None should be added."""
|
||||
def test_skipped_ws2_activity_events(self):
|
||||
tconnect = TConnectApi()
|
||||
|
||||
# datetimes are unused by the API fake
|
||||
start = datetime.datetime(2021, 5, 1, 0, 0)
|
||||
end = datetime.datetime(2021, 5, 3, 0, 0)
|
||||
|
||||
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
|
||||
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
|
||||
|
||||
def fake_basalsuspension(time_start, time_end):
|
||||
self.assertEqual(time_start, start)
|
||||
self.assertEqual(time_end, end)
|
||||
|
||||
return {"BasalSuspension": [
|
||||
{
|
||||
'EventDateTime': '/Date(1638659343000-0000)/',
|
||||
'SuspendReason': 'basal-profile',
|
||||
},
|
||||
{
|
||||
'Continuation': 'continuation',
|
||||
'EventDateTime': '/Date(1638604800000-0000)/',
|
||||
'SuspendReason': 'previous',
|
||||
},
|
||||
{
|
||||
'EventDateTime': '/Date(1638659343000-0000)/',
|
||||
'SuspendReason': 'basal-profile',
|
||||
}
|
||||
]}
|
||||
|
||||
tconnect.ws2.basalsuspension = fake_basalsuspension
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
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, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import unittest.mock
|
||||
import tempfile
|
||||
import importlib
|
||||
import contextlib
|
||||
import pathlib
|
||||
import os
|
||||
|
||||
@contextlib.contextmanager
|
||||
def chdir(dir):
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(dir)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.chdir(orig_cwd)
|
||||
|
||||
class TestSecretDotEnv(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def write_test_dotenv_file(self, path, type):
|
||||
with open(os.path.join(path, ".env"), "w") as f:
|
||||
f.write("""
|
||||
TCONNECT_EMAIL=test_%s_email@email.com
|
||||
NS_URL=http://test_%s_url
|
||||
""" % (type, type))
|
||||
f.close()
|
||||
|
||||
def import_secret(self):
|
||||
return importlib.reload(importlib.import_module("tconnectsync.secret"))
|
||||
|
||||
def test_dotenv_in_current_working_directory(self):
|
||||
with tempfile.TemporaryDirectory(prefix='dotenv_cwd') as dir, chdir(dir):
|
||||
self.write_test_dotenv_file(dir, "dotenv_cwd")
|
||||
|
||||
secret = self.import_secret()
|
||||
self.assertEqual(secret.TCONNECT_EMAIL, "test_dotenv_cwd_email@email.com")
|
||||
self.assertEqual(secret.NS_URL, "http://test_dotenv_cwd_url")
|
||||
|
||||
def test_dotenv_in_homedir_config_folder(self):
|
||||
with tempfile.TemporaryDirectory(prefix='dotenv_homedir_config') as dir, chdir(dir):
|
||||
config_dir = os.path.join(dir, '.config/tconnectsync')
|
||||
os.makedirs(config_dir)
|
||||
|
||||
self.write_test_dotenv_file(config_dir, "dotenv_homedir_config")
|
||||
|
||||
with unittest.mock.patch.object(pathlib.Path, "home") as mock_home:
|
||||
mock_home.return_value = dir
|
||||
|
||||
secret = self.import_secret()
|
||||
self.assertEqual(secret.TCONNECT_EMAIL, "test_dotenv_homedir_config_email@email.com")
|
||||
self.assertEqual(secret.NS_URL, "http://test_dotenv_homedir_config_url")
|
||||
|
||||
def test_no_dotenv_file_reads_from_environment(self):
|
||||
with tempfile.TemporaryDirectory(prefix='dotenv_environ') as dir, chdir(dir):
|
||||
environ = {
|
||||
"TCONNECT_EMAIL": "test_environ_email@email.com",
|
||||
"NS_URL": "http://test_environ_url"
|
||||
}
|
||||
|
||||
with unittest.mock.patch.dict(os.environ, environ):
|
||||
secret = self.import_secret()
|
||||
self.assertEqual(secret.TCONNECT_EMAIL, environ["TCONNECT_EMAIL"])
|
||||
self.assertEqual(secret.NS_URL, environ["NS_URL"])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user