Compare commits

..
6 Commits
Author SHA1 Message Date
James Woglom 037f53e8c4 version: update to 0.5, due to suspension event changes 2021-10-28 01:43:11 -04:00
James Woglom 9635b0bd0f sync/basal: process manual suspension events as manual 0u/hr basal, fixes #17 2021-10-28 01:38:11 -04:00
James Woglom e9f569d2a0 workflows: only publish to pypi on dispatch 2021-10-24 00:41:04 -04:00
James Woglom f5ef34174f in-progress: support BOLUS_BG and CGM features.
BOLUS_BG will add BG readings which are associated with boluses
on the pump into the Nightscout treatment object. It will determine
whether the BG reading was automatically filled via the Dexcom
connection on the pump or was manually entered by seeing if the
BG reading matches the current CGM reading as known to the pump
at that time. Support for this is nearly complete.

CGM will add Dexcom CGM readings from the pump to Nightscout
as SGV (sensor glucose value) entries. This should only be
used in a situation where xDrip is not used and the pump
connection to the CGM will be the only source of CGM
data to Nightscout. This requires additional testing before
it should be considered ready.

Both options are hidden behind the ENABLE_TESTING_MODES=true
environment variable. Run tconnectsync with this environment
variable and BOLUS_BG and CGM will be usable with the
--features argument.
2021-10-24 00:36:48 -04:00
James Woglom 9f488b2c5e support specifying specific features to synchronize (basal, bolus, iob) 2021-10-23 16:31:59 -04:00
James WoglomandGitHub 99341c5443 README: use GitHub Registry instead of Docker Hub 2021-10-20 01:40:20 -04:00
20 changed files with 780 additions and 61 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
name: Publish to PyPI
on:
push:
branches: [ master ]
on: workflow_dispatch
jobs:
build-binary:
+13 -6
View File
@@ -144,11 +144,11 @@ If you receive no errors, then you can move on to the **Running Tconnectsync Con
First, [ensure that you have Docker running and installed](https://docs.docker.com/get-started/#download-and-install-docker).
To download and run the `jwoglom/tconnectsync` prebuilt Docker image from [Docker Hub](https://hub.docker.com/r/jwoglom/tconnectsync):
To download and run the prebuilt Docker image from GitHub Packages:
```bash
$ docker pull jwoglom/tconnectsync:latest
$ docker run jwoglom/tconnectsync --help
$ docker pull ghcr.io/jwoglom/tconnectsync/tconnectsync:latest
$ docker run ghcr.io/jwoglom/tconnectsync/tconnectsync --help
```
To instead build the image locally and launch the project:
@@ -266,12 +266,19 @@ exec python3 -u main.py --auto-update
In the `tconnectsync.conf`, you should set `/path/to/tconnectsync` to the folder
where you checked-out the GitHub repository.
An example `run.sh` if you installed tconnectsync via Docker:
An example `run.sh` if you installed tconnectsync via the GitHub Docker Registry:
```bash
#!/bin/bash
docker run ghcr.io/jwoglom/tconnectsync/tconnectsync --auto-update
```
An example `run.sh` if you built tconnectsync locally:
```bash
#!/bin/bash
docker build -t tconnectsync
docker run tconnectsync --auto-update
```
@@ -290,7 +297,7 @@ An example configuration in `/etc/crontab` which runs every 15 minutes:
0,15,30,45 * * * * root /path/to/tconnectsync/run.sh
```
You can use one of the same `run.sh` files mentioned above in the Supervisord example, but remove the `--auto-update` flag since you are handling the functionality for running the script periodically yourself.
You can use one of the same `run.sh` files referenced above, but remove the `--auto-update` flag since you are handling the functionality for running the script periodically yourself.
## Tandem APIs
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = tconnectsync
version = 0.4.1
version = 0.5
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
+6 -2
View File
@@ -10,6 +10,7 @@ from .process import process_time_range
from .autoupdate import process_auto_update
from .check import check_login
from .nightscout import NightscoutApi
from .features import DEFAULT_FEATURES, ALL_FEATURES
try:
from .secret import (
@@ -38,6 +39,7 @@ def parse_args(*args, **kwargs):
parser.add_argument('--days', dest='days', type=int, default=1, help='The number of days of t:connect data to read in. Cannot be used with --from-date and --until-date.')
parser.add_argument('--auto-update', dest='auto_update', action='store_const', const=True, default=False, help='If set, continuously checks for updates from t:connect and syncs with Nightscout.')
parser.add_argument('--check-login', dest='check_login', action='store_const', const=True, default=False, help='If set, checks that the provided t:connect credentials can be used to log in.')
parser.add_argument('--features', dest='features', nargs='+', default=DEFAULT_FEATURES, choices=ALL_FEATURES, help='Specifies what data should be synchronized between tconnect and Nightscout.')
return parser.parse_args(*args, **kwargs)
@@ -76,11 +78,13 @@ def main(*args, **kwargs):
if args.check_login:
return check_login(tconnect, time_start, time_end)
logging.info("Enabled features: " + ", ".join(args.features))
if args.auto_update:
print("Starting auto-update between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
process_auto_update(tconnect, nightscout, time_start, time_end, args.pretend)
process_auto_update(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features)
else:
print("Processing data between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend)
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features)
print("Added", added, "items")
+3 -2
View File
@@ -3,6 +3,7 @@ import logging
import sys
from .process import process_time_range
from .features import DEFAULT_FEATURES
from .secret import (
PUMP_SERIAL_NUMBER,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS,
@@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
Performs the auto-update functionality. Runs indefinitely in a loop
until stopped (ctrl+c).
"""
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
# Read from android api, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
@@ -35,7 +36,7 @@ def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
if pretend:
logger.info('Would update now if not in pretend mode')
else:
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend)
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
logger.info('Added %d items from process_time_range' % added)
if added == 0:
if last_event_index:
+28
View File
@@ -0,0 +1,28 @@
from .secret import ENABLE_TESTING_MODES
"""Supported synchronization features."""
BASAL = "BASAL"
BOLUS = "BOLUS"
IOB = "IOB"
BOLUS_BG = "BOLUS_BG"
CGM = "CGM"
DEFAULT_FEATURES = [
BASAL,
BOLUS,
IOB
]
ALL_FEATURES = [
BASAL,
BOLUS,
IOB
]
# These modes are not yet ready for wide use.
if ENABLE_TESTING_MODES:
ALL_FEATURES += [
BOLUS_BG,
CGM
]
+13 -1
View File
@@ -53,7 +53,19 @@ class NightscoutApi:
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout treatments response: %s" % latest.text)
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry response: %s" % latest.text)
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
def last_uploaded_bg_entry(self):
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry response: %s" % latest.text)
j = latest.json()
if j and len(j) > 0:
+34 -3
View File
@@ -1,8 +1,11 @@
import arrow
ENTERED_BY = "Pump (tconnectsync)"
BASAL_EVENTTYPE = "Temp Basal"
BOLUS_EVENTTYPE = "Combo Bolus"
IOB_ACTIVITYTYPE = "tconnect_iob"
"""
Conversion methods for parsing data into Nightscout objects.
"""
@@ -21,9 +24,14 @@ class NightscoutEntry:
"enteredBy": ENTERED_BY
}
# Note that Nightscout is not consistent and uses "Sensor"/"Finger"
# for treatment objects, unlike "sgv"/"mbg" for entries
SENSOR = "Sensor"
FINGER = "Finger"
@staticmethod
def bolus(bolus, carbs, created_at, notes=""):
return {
def bolus(bolus, carbs, created_at, notes="", bg="", bg_type=""):
data = {
"eventType": BOLUS_EVENTTYPE,
"created_at": created_at,
"carbs": int(carbs),
@@ -31,6 +39,15 @@ class NightscoutEntry:
"notes": notes,
"enteredBy": ENTERED_BY,
}
if bg:
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
raise InvalidBolusTypeException
data.update({
"glucose": str(bg),
"glucoseType": bg_type
})
return data
@staticmethod
def iob(iob, created_at):
@@ -39,4 +56,18 @@ class NightscoutEntry:
"iob": float(iob),
"created_at": created_at,
"enteredBy": ENTERED_BY
}
}
@staticmethod
def entry(sgv, created_at):
return {
"type": "sgv",
"sgv": int(sgv),
"date": int(1000 * arrow.get(created_at).timestamp()),
"dateString": arrow.get(created_at).strftime('%Y-%m-%dT%H:%M:%S%z'),
"device": ENTERED_BY,
# delta, direction are undefined
}
class InvalidBolusTypeException(RuntimeError):
pass
+19
View File
@@ -40,6 +40,16 @@ class TConnectEntry:
"duration_mins": duration_mins,
"basal_rate": basal_rate,
}
@staticmethod
def manual_suspension_to_basal_entry(parsedSuspension, seconds):
duration_mins = seconds / 60
return {
"time": parsedSuspension["time"],
"delivery_type": "%s suspension" % parsedSuspension["suspendReason"],
"duration_mins": duration_mins,
"basal_rate": 0.0
}
@staticmethod
def parse_suspension_entry(data):
@@ -100,8 +110,17 @@ class TConnectEntry:
"insulin": data["InsulinDelivered"],
"requested_insulin": data["ActualTotalBolusRequested"],
"carbs": data["CarbSize"],
"bg": data["BG"], # Note: can be empty string for automatic Control-IQ boluses
"user_override": data["UserOverride"],
"extended_bolus": "1" if extended_bolus else "",
"bolex_completion_time": TConnectEntry._datetime_parse(data["BolexCompletionDateTime"]).format() if complete and extended_bolus else None,
"bolex_start_time": TConnectEntry._datetime_parse(data["BolexStartDateTime"]).format() if complete and extended_bolus else None,
}
@staticmethod
def parse_reading_entry(data):
return {
"time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(),
"bg": data["Readings (CGM / BGM)"],
"type": data["Description"]
}
+29 -12
View File
@@ -18,7 +18,12 @@ from .sync.iob import (
process_iob_events,
ns_write_iob_events
)
from .sync.cgm import (
process_cgm_events,
ns_write_cgm_events
)
from .parser.tconnect import TConnectEntry
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES
logger = logging.getLogger(__name__)
@@ -27,7 +32,7 @@ Given a TConnectApi object and start/end range, performs a single
cycle of synchronizing data within the time range.
If pretend is true, then doesn't actually write data to Nightscout.
"""
def process_time_range(tconnect, nightscout, time_start, time_end, pretend):
def process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
@@ -59,20 +64,32 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend):
added = 0
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
if csvBasalData:
logger.debug("CSV basal data found: processing it")
add_csv_basal_events(basalEvents, csvBasalData)
else:
logger.debug("No CSV basal data found")
cgmData = None
if CGM in features or BOLUS_BG in features:
logger.debug("Processing CGM events")
cgmData = process_cgm_events(readingData)
if CGM in features:
logger.debug("Writing CGM events")
added += ns_write_cgm_events(nightscout, cgmData, pretend)
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend)
if BASAL in features:
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
if csvBasalData:
logger.debug("CSV basal data found: processing it")
add_csv_basal_events(basalEvents, csvBasalData)
else:
logger.debug("No CSV basal data found")
bolusEvents = process_bolus_events(bolusData)
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend)
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend)
iobEvents = process_iob_events(iobData)
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
if BOLUS in features:
bolusEvents = process_bolus_events(bolusData)
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features))
if IOB in features:
iobEvents = process_iob_events(iobData)
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
logger.info("Wrote %d events to Nightscout this process cycle" % added)
return added
+1 -5
View File
@@ -36,11 +36,7 @@ AUTOUPDATE_USE_FIXED_SLEEP = get_bool('AUTOUPDATE_USE_FIXED_SLEEP', 'false')
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '180') # 3 hours
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
_config = ['TCONNECT_EMAIL', 'TCONNECT_PASSWORD', 'PUMP_SERIAL_NUMBER',
'NS_URL', 'NS_SECRET', 'TIMEZONE_NAME',
'AUTOUPDATE_DEFAULT_SLEEP_SECONDS', 'AUTOUPDATE_MAX_SLEEP_SECONDS',
'AUTOUPDATE_USE_FIXED_SLEEP', 'AUTOUPDATE_FAILURE_MINUTES',
'AUTOUPDATE_RESTART_ON_FAILURE']
ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false')
if __name__ == '__main__':
for k in locals():
+44 -2
View File
@@ -32,11 +32,53 @@ def process_ciq_basal_events(data):
for b in data["basal"]["profileDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="profileDelivery"))
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
# Suspensions with suspendReason 'control-iq' will match a basal event found above.
for i in basalEvents:
if i["time"] in suspensionEvents:
i["suspendReason"] = suspensionEvents[i["time"]]["suspendReason"]
i["delivery_type"] += " (" + suspensionEvents[i["time"]]["suspendReason"] + " suspension)"
del suspensionEvents[i["time"]]
# Suspensions with suspendReason 'manual' do not have an associated basal event,
# and require extra processing.
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
unprocessedSuspensions = list(suspensionEvents.values())
unprocessedSuspensions.sort(key=lambda x: arrow.get(x["time"]))
# For the remaining suspensions which did not match with an existing basal event,
# add a new event manually. This means we need to calculate the duration of the
# suspension.
newEvents = []
for i in range(len(basalEvents)):
if len(unprocessedSuspensions) == 0:
break
existingTime = arrow.get(basalEvents[i]["time"])
unprocessedTime = arrow.get(unprocessedSuspensions[0]["time"])
# If we've found an event which occurs after the suspension, then the
# difference in their timestamps is the duration of the suspension.
if i > 0 and existingTime > unprocessedTime:
suspension = unprocessedSuspensions.pop(0)
# TConnect's internal duration object tracks the duration in seconds
seconds = (existingTime - unprocessedTime).seconds
newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds)
logger.debug("Creating basal event for unprocessed suspension: %s" % newEvent)
newEvents.append(newEvent)
# Any remaining suspensions which have not been processed have not ended,
# which means we do not know their duration; so we will skip them (for now)
# Add any new events and re-sort
if newEvents:
basalEvents += newEvents
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
return basalEvents
+42 -9
View File
@@ -1,6 +1,8 @@
import arrow
import logging
from tconnectsync.sync.cgm import find_event_at
from ..parser.nightscout import (
BOLUS_EVENTTYPE,
NightscoutEntry
@@ -12,7 +14,7 @@ logger = logging.getLogger(__name__)
"""
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_bolus_events(bolusdata):
def process_bolus_events(bolusdata, cgmEvents=None):
bolusEvents = []
for b in bolusdata:
@@ -24,16 +26,37 @@ def process_bolus_events(bolusdata):
else:
logger.warning("Skipping non-completed bolus data (was a bolus in progress?): %s parsed: %s" % (b, parsed))
continue
if parsed["bg"] and cgmEvents:
requested_at = parsed["request_time"] if not parsed["extended_bolus"] else parsed["bolex_start_time"]
parsed["bg_type"] = guess_bolus_bg_type(parsed["bg"], requested_at, cgmEvents)
bolusEvents.append(parsed)
bolusEvents.sort(key=lambda event: arrow.get(event["completion_time"] if not event["extended_bolus"] else event["bolex_start_time"]))
bolusEvents.sort(key=lambda event: arrow.get(event["request_time"] if not event["extended_bolus"] else event["bolex_start_time"]))
return bolusEvents
"""
Determine whether the given BG specified in the bolus is identical to the
most recent CGM reading at that time. If it is, return SENSOR.
Otherwise, return FINGER.
"""
def guess_bolus_bg_type(bg, created_at, cgmEvents):
if not cgmEvents:
return NightscoutEntry.FINGER
event = find_event_at(cgmEvents, created_at)
if event and str(event["bg"]) == str(bg):
return NightscoutEntry.SENSOR
return NightscoutEntry.FINGER
"""
Given processed bolus data, adds bolus events to Nightscout.
"""
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False):
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None):
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE)
last_upload_time = None
@@ -49,12 +72,22 @@ def ns_write_bolus_events(nightscout, bolusEvents, pretend=False):
logger.info("Skipping basal event before last upload time: %s" % event)
continue
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
created_at=created_at,
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
)
if include_bg and event["bg"]:
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
created_at=created_at,
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else ""),
bg=event["bg"],
bg_type=event["bg_type"]
)
else:
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
created_at=created_at,
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
)
add_count += 1
+69
View File
@@ -0,0 +1,69 @@
import json
import arrow
import logging
from ..parser.tconnect import TConnectEntry
from ..parser.nightscout import NightscoutEntry
logger = logging.getLogger(__name__)
def process_cgm_events(readingData):
data = []
for r in readingData:
data.append(TConnectEntry.parse_reading_entry(r))
return data
"""
Given reading data and a time, finds the BG reading event which would have
been the current one at that time. e.g., it looks before the given time,
not after.
This is a heuristic for checking whether the BG component of a bolus was
manually entered or inferred based on the pump's CGM.
"""
def find_event_at(cgmEvents, find_time):
find_t = arrow.get(find_time)
events = list(map(lambda x: (arrow.get(x["time"]), x), cgmEvents))
events.sort()
closestReading = None
for t, r in events:
if t > find_t:
break
closestReading = r
return closestReading
"""
Given processed CGM data, adds reading entries to Nightscout.
"""
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False):
logger.debug("ns_write_cgm_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_bg_entry()
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["dateString"])
logger.info("Last Nightscout CGM upload: %s" % last_upload_time)
add_count = 0
for event in cgmEvents:
created_at = event["time"]
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
logger.info("Skipping CGM event before last upload time: %s" % event)
continue
entry = NightscoutEntry.entry(
sgv=event["bg"],
created_at=created_at
)
add_count += 1
logger.info(" Processing cgm reading: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry, entity='entries')
return add_count
+78 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.parser.nightscout import NightscoutEntry
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException
class TestNightscoutEntry(unittest.TestCase):
def test_basal(self):
@@ -73,6 +73,69 @@ class TestNightscoutEntry(unittest.TestCase):
}
)
def test_bolus_with_bg(self):
self.assertEqual(
NightscoutEntry.bolus(
bolus=7.5,
carbs=45,
created_at="2021-03-16 00:25:21-04:00",
bg="123",
bg_type=NightscoutEntry.SENSOR),
{
"eventType": "Combo Bolus",
"created_at": "2021-03-16 00:25:21-04:00",
"carbs": 45,
"insulin": 7.5,
"notes": "",
"enteredBy": "Pump (tconnectsync)",
"glucose": "123",
"glucoseType": "Sensor"
}
)
self.assertEqual(
NightscoutEntry.bolus(
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00",
bg="150",
bg_type=NightscoutEntry.FINGER),
{
"eventType": "Combo Bolus",
"created_at": "2021-03-16 12:25:21-04:00",
"carbs": 5,
"insulin": 0.5,
"notes": "",
"enteredBy": "Pump (tconnectsync)",
"glucose": "150",
"glucoseType": "Finger"
}
)
def test_bolus_with_bg_invalid_type(self):
self.assertRaises(InvalidBolusTypeException,
NightscoutEntry.bolus,
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00",
bg="150")
self.assertRaises(InvalidBolusTypeException,
NightscoutEntry.bolus,
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00",
bg="150",
bg_type="")
self.assertRaises(InvalidBolusTypeException,
NightscoutEntry.bolus,
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00",
bg="150",
bg_type="unknown")
def test_iob(self):
self.assertEqual(
NightscoutEntry.iob(
@@ -86,6 +149,20 @@ class TestNightscoutEntry(unittest.TestCase):
}
)
def test_entry(self):
self.assertEqual(
NightscoutEntry.entry(
sgv=152,
created_at="2021-10-23 22:17:14-04:00"),
{
"type": "sgv",
"sgv": 152,
"date": 1635041834000,
"dateString": "2021-10-23T22:17:14-0400",
"device": "Pump (tconnectsync)",
}
)
if __name__ == '__main__':
unittest.main()
+95
View File
@@ -60,6 +60,26 @@ class TestTConnectEntrySuspension(unittest.TestCase):
}
)
class TestTConnectEntrySuspensionToBasal(unittest.TestCase):
def test_manual_suspension_to_basal_entry(self):
suspension = {
"time": "2021-03-16 00:30:21-04:00",
"continuation": None,
"suspendReason": "manual"
}
self.assertEqual(
TConnectEntry.manual_suspension_to_basal_entry(
suspension,
seconds=300
), {
"time": "2021-03-16 00:30:21-04:00",
"delivery_type": "manual suspension",
"duration_mins": 5.0,
"basal_rate": 0.0
}
)
class TestTConnectEntryCGM(unittest.TestCase):
def test_parse_cgm_entry(self):
self.assertEqual(
@@ -166,6 +186,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"insulin": "13.53",
"requested_insulin": "13.53",
"carbs": "75",
"bg": "141",
"user_override": "0",
"extended_bolus": "",
"bolex_completion_time": None,
@@ -227,6 +248,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"insulin": "1.25",
"requested_insulin": "1.25",
"carbs": "0",
"bg": "159",
"user_override": "1",
"extended_bolus": "",
"bolex_completion_time": None,
@@ -288,6 +310,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"insulin": "1.70",
"requested_insulin": "1.70",
"carbs": "0",
"bg": "",
"user_override": "0",
"extended_bolus": "",
"bolex_completion_time": None,
@@ -349,6 +372,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"insulin": "0.00",
"requested_insulin": "0.50",
"carbs": "0",
"bg": "144",
"user_override": "1",
"extended_bolus": "",
"bolex_completion_time": None,
@@ -410,11 +434,82 @@ class TestTConnectEntryBolus(unittest.TestCase):
"insulin": "1.82",
"requested_insulin": "2.63",
"carbs": "0",
"bg": "189",
"user_override": "0",
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
class TestTConnectEntryReading(unittest.TestCase):
entry1 = {
"DeviceType": "t:slim X2 Insulin Pump",
"SerialNumber": "90556643",
"Description": "EGV",
"EventDateTime": "2021-10-23T12:55:53",
"Readings (CGM / BGM)": "135"
}
def test_parse_reading_entry1(self):
self.assertEqual(
TConnectEntry.parse_reading_entry(self.entry1),
{
"time": "2021-10-23 12:55:53-04:00",
"bg": "135",
"type": "EGV"
}
)
entry2 = {
"DeviceType": "t:slim X2 Insulin Pump",
"SerialNumber": "90556643",
"Description": "EGV",
"EventDateTime": "2021-10-23T16:15:52",
"Readings (CGM / BGM)": "93"
}
def test_parse_reading_entry2(self):
self.assertEqual(
TConnectEntry.parse_reading_entry(self.entry2),
{
"time": "2021-10-23 16:15:52-04:00",
"bg": "93",
"type": "EGV"
}
)
entry3 = {
"DeviceType": "t:slim X2 Insulin Pump",
"SerialNumber": "90556643",
"Description": "EGV",
"EventDateTime": "2021-10-23T16:20:52",
"Readings (CGM / BGM)": "100"
}
def test_parse_reading_entry3(self):
self.assertEqual(
TConnectEntry.parse_reading_entry(self.entry3),
{
"time": "2021-10-23 16:20:52-04:00",
"bg": "100",
"type": "EGV"
}
)
entry4 = {
"DeviceType": "t:slim X2 Insulin Pump",
"SerialNumber": "90556643",
"Description": "EGV",
"EventDateTime": "2021-10-23T16:25:52",
"Readings (CGM / BGM)": "107"
}
def test_parse_reading_entry4(self):
self.assertEqual(
TConnectEntry.parse_reading_entry(self.entry4),
{
"time": "2021-10-23 16:25:52-04:00",
"bg": "107",
"type": "EGV"
}
)
if __name__ == '__main__':
unittest.main()
+68 -10
View File
@@ -5,6 +5,8 @@ from tconnectsync.sync.basal import process_ciq_basal_events
from tconnectsync.parser.tconnect import TConnectEntry
class TestBasalSync(unittest.TestCase):
maxDiff = None
base = {
"basal": {
"profileRates": [],
@@ -25,26 +27,26 @@ class TestBasalSync(unittest.TestCase):
{
"y": 0.8,
"duration": 1221,
"x": 1615878000
"x": 1615878000 # 12:00:00
}
]
data["basal"]["algorithmDeliveryEvents"] = [
{
"y": 0.797,
"duration": 300,
"x": 1615879521
"x": 1615879521 # 12:25:21
},
{
"y": 0,
"duration": 2693,
"x": 1615879821
"x": 1615879821 # 12:30:21
},
]
data["basal"]["profileDeliveryEvents"] = [
{
"y": 0.799,
"duration": 300,
"x": 1615879221
"x": 1615879221 # 12:20:21
}
]
@@ -52,7 +54,7 @@ class TestBasalSync(unittest.TestCase):
{
"suspendReason": "control-iq",
"continuation": None,
"x": 1615879821
"x": 1615879821 # 12:30:21
},
]
@@ -73,12 +75,68 @@ class TestBasalSync(unittest.TestCase):
self.assertEqual(basalEvents[2], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[3], {
"suspendReason": "control-iq",
**TConnectEntry.parse_ciq_basal_entry(
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][1],
delivery_type="algorithmDelivery")
})
delivery_type="algorithmDelivery (control-iq suspension)")
)
@staticmethod
def get_example_ciq_basal_events_with_manual_suspension():
data = TestBasalSync.base.copy()
data["basal"]["tempDeliveryEvents"] = []
data["basal"]["algorithmDeliveryEvents"] = [
{
"y": 1.14,
"duration": 300,
"x": 1635187357 # 11:42:37
},
{
"y": 0.8,
"duration": 1198,
"x": 1635187657 # 11:47:37
},
{
"y": 0.8,
"duration": 599,
"x": 1635190967 # 12:42:47
}
]
data["basal"]["profileDeliveryEvents"] = []
data["suspensionDeliveryEvents"] = [
{
"suspendReason": "manual",
"continuation": None,
"x": 1635188855 # 12:07:35
},
{
"suspendReason": "manual",
"continuation": None,
"x": 1635191566 # 12:52:46
},
]
return data
def test_process_ciq_basal_events_with_manual_suspension(self):
data = TestBasalSync.get_example_ciq_basal_events_with_manual_suspension()
basalEvents = process_ciq_basal_events(data)
self.assertEqual(len(basalEvents), 4)
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][1], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[2], TConnectEntry.manual_suspension_to_basal_entry(
TConnectEntry.parse_suspension_entry(data["suspensionDeliveryEvents"][0]),
seconds=2112, # 2112 seconds between 12:07:35 and 12:42:47
))
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][2], delivery_type="algorithmDelivery"))
if __name__ == '__main__':
+93 -1
View File
@@ -3,10 +3,12 @@
import unittest
import random
from tconnectsync.sync.bolus import process_bolus_events
from tconnectsync.parser.tconnect import TConnectEntry
from tconnectsync.parser.nightscout import NightscoutEntry
from ..parser.test_tconnect import TestTConnectEntryBolus
from ..parser.test_tconnect import TestTConnectEntryBolus, TestTConnectEntryCGM, TestTConnectEntryReading
class TestBolusSync(unittest.TestCase):
@@ -33,6 +35,96 @@ class TestBolusSync(unittest.TestCase):
TConnectEntry.parse_bolus_entry(d) for d in bolusData
])
def test_process_bolus_events_cgmevents_not_matching(self):
bolusData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
cgmEvents = [
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3)
]
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
self.assertEqual(len(bolusEvents), len(bolusData))
def set_bg_type(entry, type):
entry["bg_type"] = type
return entry
# Expect FINGER for bolus entries with a BG because there's no matching event with the same BG
expected = [
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.FINGER),
# No BG specified for the automatic bolus
TConnectEntry.parse_bolus_entry(bolusData[2])
]
self.assertListEqual(bolusEvents, expected)
def test_process_bolus_events_cgmevents_matches(self):
bolusData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
cgmEvents = [
{
"time": "2021-04-01 12:45:30-04:00",
"bg": "100",
"type": "EGV"
},
# Matches entryStdCorrection time but with wrong BG
{
"time": "2021-04-01 12:50:30-04:00",
"bg": "105",
"type": "EGV"
},
{
"time": "2021-04-01 13:00:30-04:00",
"bg": "110",
"type": "EGV"
},
{
"time": "2021-04-01 23:15:30-04:00",
"bg": "150",
"type": "EGV"
},
# Matches entryStd time with correct BG
{
"time": "2021-04-01 23:20:30-04:00",
"bg": "159",
"type": "EGV"
},
{
"time": "2021-04-01 23:25:30-04:00",
"bg": "160",
"type": "EGV"
},
]
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
self.assertEqual(len(bolusEvents), len(bolusData))
def set_bg_type(entry, type):
entry["bg_type"] = type
return entry
expected = [
# Time found but BG doesn't match
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
# Time found and BG matches
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.SENSOR),
# No BG specified for the automatic bolus
TConnectEntry.parse_bolus_entry(bolusData[2])
]
self.assertListEqual(bolusEvents, expected)
def test_process_bolus_events_update_partial_description(self):
stdData = [
TestTConnectEntryBolus.entryStdCorrection,
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.cgm import find_event_at, process_cgm_events
from tconnectsync.parser.tconnect import TConnectEntry
from ..parser.test_tconnect import TestTConnectEntryReading
class TestProcessCGMEvents(unittest.TestCase):
def test_process_cgm_events(self):
rawReadings = [
TestTConnectEntryReading.entry1,
TestTConnectEntryReading.entry2,
TestTConnectEntryReading.entry3,
TestTConnectEntryReading.entry4
]
self.assertListEqual(
process_cgm_events(rawReadings),
[TConnectEntry.parse_reading_entry(r) for r in rawReadings]
)
class TestFindEventAt(unittest.TestCase):
readingData = [
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry4)
]
def test_find_event_at_exact(self):
for r in self.readingData:
self.assertEqual(find_event_at(self.readingData, r["time"]), r)
def test_find_event_at_before_not_found(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-22 10:30:00-04:00"), None)
def test_find_event_at_large_gap(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 13:30:00-04:00"), self.readingData[0])
def test_find_event_at_between_close(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:17:52-04:00"), self.readingData[1])
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:21:52-04:00"), self.readingData[2])
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:25:59-04:00"), self.readingData[3])
def test_find_event_at_most_recent(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 18:00:00-04:00"), self.readingData[3])
if __name__ == '__main__':
unittest.main()
+92 -3
View File
@@ -6,6 +6,7 @@ import pprint
from tconnectsync.process import process_time_range
from tconnectsync.parser.nightscout import IOB_ACTIVITYTYPE, NightscoutEntry
from tconnectsync.features import BASAL, BOLUS, IOB
from .api.fake import TConnectApi
from .nightscout_fake import NightscoutApi
@@ -63,11 +64,38 @@ class TestProcessTimeRange(unittest.TestCase):
NightscoutEntry.basal(0.8, 20.35, "2021-03-16 00:00:00-04:00", reason="tempDelivery"),
NightscoutEntry.basal(0.799, 5.0, "2021-03-16 00:20:21-04:00", reason="profileDelivery"),
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Nothing should be updated in Nightscout without the BASAL feature."""
def test_basal_data_not_updated_without_feature(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, IOB])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""Two basal entries in Nightscout. Two new basal entries in tconnect."""
def test_partial_ciq_basal_data(self):
@@ -104,7 +132,7 @@ class TestProcessTimeRange(unittest.TestCase):
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
@@ -148,7 +176,7 @@ class TestProcessTimeRange(unittest.TestCase):
self.assertDictEqual(nightscout.uploaded_entries, {
"treatments": [
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery")
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
]})
self.assertEqual(len(nightscout.put_entries["treatments"]), 1)
self.assertDictEqual(dict(nightscout.put_entries), {
@@ -199,6 +227,37 @@ class TestProcessTimeRange(unittest.TestCase):
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Nothing should be updated in Nightscout without the BOLUS feature."""
def test_bolus_data_not_updated_without_feature(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
bolusData = TestBolusSync.get_example_csv_bolus_events()
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"bolusData": bolusData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, IOB])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Uploads new iob reading from tconnect."""
def test_new_ciq_iob_data(self):
tconnect = TConnectApi()
@@ -234,6 +293,36 @@ class TestProcessTimeRange(unittest.TestCase):
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""No data in Nightscout. Nothing should be updated in Nightscout without the IOB feature."""
def test_iob_data_not_updated_without_feature(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
iobData = TestIOBSync.get_example_csv_iob_events()
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"iobData": iobData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, BOLUS])
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
"""Existing IOB in Nightscout. Uploads new iob reading and deletes old IOB."""
def test_updates_ciq_iob_data(self):