Compare commits

...
16 Commits
Author SHA1 Message Date
James Woglom 90bede8b75 bump version to v0.6.2 2021-12-23 20:57:50 -05:00
James Woglom 7a8d00da88 secret: put environment variables at higher precedence than .env file 2021-12-23 20:46:58 -05:00
James Woglom 52cb287ce8 bump version to 0.6.1 2021-12-23 20:38:23 -05:00
James Woglom 27ba5d9a64 Update dotenv secret parsing logic, and read from .config/tconnectsync/.env path 2021-12-23 19:39:51 -05:00
James WoglomandGitHub 2140bd356e Update README.md with note about forking the repo 2021-12-15 15:31:33 -05:00
James Woglom a74b63ab05 bugfix: check for extended bolus start/completion time presence before parsing, since extended boluses can be in progress 2021-12-12 13:29:08 -05:00
James Woglom b0ffe9bcfc update to 1.6 2021-12-04 22:00:56 -05:00
James Woglom ca4ca72476 add SKIP_NS_LAST_UPLOADED_CHECK workaround when running tconnectsync for past data. see #23 2021-12-04 21:56:30 -05:00
James Woglom 8cef3233c6 features: enable PUMP_EVENTS as optional feature 2021-12-04 21:38:13 -05:00
James Woglom 118ee236ba test_process: add WS2 activity event process tests 2021-12-04 21:37:34 -05:00
James Woglom 1657b1c696 test_process: add test coverage for CIQ pump_events sync 2021-12-04 21:20:47 -05:00
James Woglom 0a9dd8732f bug: use deepcopy to avoid mutating the base dictionary 2021-12-04 21:20:13 -05:00
James Woglom dbb1389254 hack: enable debug logging in tests 2021-12-04 21:18:53 -05:00
James Woglom 163db9807b sync/pump_events: upload events to nightscout 2021-12-04 20:28:03 -05:00
James Woglom b1ca7eee1e add basalsuspension parsing + tests 2021-12-04 19:02:13 -05:00
James Woglom 1fa543e3d6 add PUMP_EVENTS feature, with initial code for parsing CIQ activities (sleep mode, exercise, etc) 2021-12-04 17:41:16 -05:00
18 changed files with 1029 additions and 34 deletions
+60 -7
View File
@@ -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
+2 -2
View File
@@ -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
+14 -1
View File
@@ -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))
+2 -1
View File
@@ -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 -1
View File
@@ -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
]
+38
View File
@@ -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
+72 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+6
View File
@@ -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"]
+206
View File
@@ -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
+10
View File
@@ -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')
+28
View File
@@ -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__':
+112 -1
View File
@@ -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()
+4 -2
View File
@@ -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
View File
@@ -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__':
+99
View File
@@ -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()