mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-25 10:13:40 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90bede8b75 | ||
|
|
7a8d00da88 | ||
|
|
52cb287ce8 | ||
|
|
27ba5d9a64 | ||
|
|
2140bd356e | ||
|
|
a74b63ab05 | ||
|
|
b0ffe9bcfc | ||
|
|
ca4ca72476 | ||
|
|
8cef3233c6 | ||
|
|
118ee236ba | ||
|
|
1657b1c696 | ||
|
|
0a9dd8732f | ||
|
|
dbb1389254 | ||
|
|
163db9807b | ||
|
|
b1ca7eee1e | ||
|
|
1fa543e3d6 |
@@ -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
|
||||
```
|
||||
@@ -151,6 +193,17 @@ $ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.5
|
||||
version = 0.6.2
|
||||
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
|
||||
|
||||
@@ -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 = [[]]
|
||||
|
||||
@@ -6,6 +6,7 @@ BOLUS = "BOLUS"
|
||||
IOB = "IOB"
|
||||
BOLUS_BG = "BOLUS_BG"
|
||||
CGM = "CGM"
|
||||
PUMP_EVENTS = "PUMP_EVENTS"
|
||||
|
||||
DEFAULT_FEATURES = [
|
||||
BASAL,
|
||||
@@ -16,7 +17,8 @@ DEFAULT_FEATURES = [
|
||||
ALL_FEATURES = [
|
||||
BASAL,
|
||||
BOLUS,
|
||||
IOB
|
||||
IOB,
|
||||
PUMP_EVENTS
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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"
|
||||
|
||||
|
||||
@@ -69,5 +76,36 @@ class NightscoutEntry:
|
||||
# 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=""):
|
||||
@@ -113,8 +117,9 @@ class TConnectEntry:
|
||||
"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
|
||||
@@ -123,4 +128,67 @@ class TConnectEntry:
|
||||
"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)
|
||||
+19
-1
@@ -22,8 +22,13 @@ 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
|
||||
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,6 +87,19 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
|
||||
logger.debug("No CSV basal data found")
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
+16
-5
@@ -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')
|
||||
|
||||
def get(*args):
|
||||
return os.environ.get(*args)
|
||||
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(val, default=None):
|
||||
return os.environ.get(val, values.get(val, default))
|
||||
|
||||
def get_number(name, default):
|
||||
val = get(name, default)
|
||||
@@ -37,6 +47,7 @@ AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '180') # 3
|
||||
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
|
||||
|
||||
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__)
|
||||
|
||||
@@ -111,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:
|
||||
|
||||
@@ -8,6 +8,7 @@ from ..parser.nightscout import (
|
||||
NightscoutEntry
|
||||
)
|
||||
from ..parser.tconnect import TConnectEntry
|
||||
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,6 +65,11 @@ def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=Fal
|
||||
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"]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -162,6 +162,34 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
"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__':
|
||||
|
||||
@@ -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):
|
||||
@@ -511,5 +511,116 @@ class TestTConnectEntryReading(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import copy
|
||||
|
||||
from tconnectsync.sync.basal import process_ciq_basal_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
|
||||
@@ -22,7 +24,7 @@ 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,
|
||||
@@ -83,7 +85,7 @@ class TestBasalSync(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def get_example_ciq_basal_events_with_manual_suspension():
|
||||
data = TestBasalSync.base.copy()
|
||||
data = copy.deepcopy(TestBasalSync.base)
|
||||
data["basal"]["tempDeliveryEvents"] = []
|
||||
data["basal"]["algorithmDeliveryEvents"] = [
|
||||
{
|
||||
|
||||
+332
-9
@@ -3,10 +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.features import BASAL, BOLUS, IOB
|
||||
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
|
||||
@@ -18,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 {
|
||||
@@ -27,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
|
||||
@@ -56,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), {
|
||||
@@ -126,7 +130,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False)
|
||||
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), {
|
||||
@@ -170,7 +174,7 @@ class TestProcessTimeRange(unittest.TestCase):
|
||||
nightscout.last_uploaded_entry = fake_last_uploaded_entry
|
||||
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
|
||||
|
||||
process_time_range(tconnect, nightscout, start, end, pretend=False)
|
||||
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, {
|
||||
@@ -213,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))
|
||||
@@ -282,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)
|
||||
@@ -357,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)
|
||||
@@ -370,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,99 @@
|
||||
#!/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"])
|
||||
|
||||
def test_environment_merges_with_dotenv_file(self):
|
||||
with tempfile.TemporaryDirectory(prefix='env_plus_environ') as dir, chdir(dir):
|
||||
self.write_test_dotenv_file(dir, "dotenv_cwd_defaults")
|
||||
|
||||
environ = {
|
||||
"TCONNECT_EMAIL": "test_environ_override_email@email.com"
|
||||
}
|
||||
|
||||
with unittest.mock.patch.dict(os.environ, environ):
|
||||
secret = self.import_secret()
|
||||
self.assertEqual(secret.TCONNECT_EMAIL, "test_environ_override_email@email.com")
|
||||
self.assertEqual(secret.NS_URL, "http://test_dotenv_cwd_defaults_url")
|
||||
|
||||
def test_dotenv_in_current_working_directory_overrides_homedir_config(self):
|
||||
with tempfile.TemporaryDirectory(prefix='dotenv_cwd') as dir, chdir(dir):
|
||||
self.write_test_dotenv_file(dir, "dotenv_cwd")
|
||||
|
||||
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_cwd_email@email.com")
|
||||
self.assertEqual(secret.NS_URL, "http://test_dotenv_cwd_url")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user