mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-28 05:34:10 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
037f53e8c4 | ||
|
|
9635b0bd0f | ||
|
|
e9f569d2a0 | ||
|
|
f5ef34174f | ||
|
|
9f488b2c5e | ||
|
|
99341c5443 |
@@ -1,7 +1,5 @@
|
||||
name: Publish to PyPI
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build-binary:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,6 +3,7 @@ import logging
|
||||
import sys
|
||||
|
||||
from .process import process_time_range
|
||||
from .features import DEFAULT_FEATURES
|
||||
from .secret import (
|
||||
PUMP_SERIAL_NUMBER,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS,
|
||||
@@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
Performs the auto-update functionality. Runs indefinitely in a loop
|
||||
until stopped (ctrl+c).
|
||||
"""
|
||||
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
|
||||
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
|
||||
# Read from android api, find exact interval to cut down on API calls
|
||||
# Refresh API token. If failure, die, have wrapper script re-run.
|
||||
|
||||
@@ -35,7 +36,7 @@ def process_auto_update(tconnect, nightscout, time_start, time_end, pretend):
|
||||
if pretend:
|
||||
logger.info('Would update now if not in pretend mode')
|
||||
else:
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend)
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
|
||||
logger.info('Added %d items from process_time_range' % added)
|
||||
if added == 0:
|
||||
if last_event_index:
|
||||
|
||||
@@ -0,0 +1,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
|
||||
]
|
||||
@@ -53,7 +53,19 @@ class NightscoutApi:
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
if latest.status_code != 200:
|
||||
raise ApiException(latest.status_code, "Nightscout treatments response: %s" % latest.text)
|
||||
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry response: %s" % latest.text)
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
def last_uploaded_bg_entry(self):
|
||||
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + '&ts=' + str(time.time())), headers={
|
||||
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
|
||||
})
|
||||
if latest.status_code != 200:
|
||||
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry response: %s" % latest.text)
|
||||
|
||||
j = latest.json()
|
||||
if j and len(j) > 0:
|
||||
|
||||
@@ -1,8 +1,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
|
||||
@@ -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
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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__':
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import unittest
|
||||
import random
|
||||
|
||||
|
||||
from tconnectsync.sync.bolus import process_bolus_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry
|
||||
|
||||
from ..parser.test_tconnect import TestTConnectEntryBolus
|
||||
from ..parser.test_tconnect import TestTConnectEntryBolus, TestTConnectEntryCGM, TestTConnectEntryReading
|
||||
|
||||
class TestBolusSync(unittest.TestCase):
|
||||
|
||||
@@ -33,6 +35,96 @@ class TestBolusSync(unittest.TestCase):
|
||||
TConnectEntry.parse_bolus_entry(d) for d in bolusData
|
||||
])
|
||||
|
||||
def test_process_bolus_events_cgmevents_not_matching(self):
|
||||
bolusData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
TestTConnectEntryBolus.entryStd,
|
||||
TestTConnectEntryBolus.entryStdAutomatic
|
||||
]
|
||||
|
||||
cgmEvents = [
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3)
|
||||
]
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry["bg_type"] = type
|
||||
return entry
|
||||
|
||||
# Expect FINGER for bolus entries with a BG because there's no matching event with the same BG
|
||||
expected = [
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.FINGER),
|
||||
# No BG specified for the automatic bolus
|
||||
TConnectEntry.parse_bolus_entry(bolusData[2])
|
||||
]
|
||||
|
||||
self.assertListEqual(bolusEvents, expected)
|
||||
|
||||
def test_process_bolus_events_cgmevents_matches(self):
|
||||
bolusData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
TestTConnectEntryBolus.entryStd,
|
||||
TestTConnectEntryBolus.entryStdAutomatic
|
||||
]
|
||||
|
||||
cgmEvents = [
|
||||
{
|
||||
"time": "2021-04-01 12:45:30-04:00",
|
||||
"bg": "100",
|
||||
"type": "EGV"
|
||||
},
|
||||
# Matches entryStdCorrection time but with wrong BG
|
||||
{
|
||||
"time": "2021-04-01 12:50:30-04:00",
|
||||
"bg": "105",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 13:00:30-04:00",
|
||||
"bg": "110",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 23:15:30-04:00",
|
||||
"bg": "150",
|
||||
"type": "EGV"
|
||||
},
|
||||
# Matches entryStd time with correct BG
|
||||
{
|
||||
"time": "2021-04-01 23:20:30-04:00",
|
||||
"bg": "159",
|
||||
"type": "EGV"
|
||||
},
|
||||
{
|
||||
"time": "2021-04-01 23:25:30-04:00",
|
||||
"bg": "160",
|
||||
"type": "EGV"
|
||||
},
|
||||
]
|
||||
|
||||
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
|
||||
self.assertEqual(len(bolusEvents), len(bolusData))
|
||||
|
||||
def set_bg_type(entry, type):
|
||||
entry["bg_type"] = type
|
||||
return entry
|
||||
|
||||
expected = [
|
||||
# Time found but BG doesn't match
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
|
||||
# Time found and BG matches
|
||||
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.SENSOR),
|
||||
# No BG specified for the automatic bolus
|
||||
TConnectEntry.parse_bolus_entry(bolusData[2])
|
||||
]
|
||||
|
||||
self.assertListEqual(bolusEvents, expected)
|
||||
|
||||
def test_process_bolus_events_update_partial_description(self):
|
||||
stdData = [
|
||||
TestTConnectEntryBolus.entryStdCorrection,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
|
||||
from tconnectsync.sync.cgm import find_event_at, process_cgm_events
|
||||
from tconnectsync.parser.tconnect import TConnectEntry
|
||||
|
||||
from ..parser.test_tconnect import TestTConnectEntryReading
|
||||
|
||||
class TestProcessCGMEvents(unittest.TestCase):
|
||||
def test_process_cgm_events(self):
|
||||
rawReadings = [
|
||||
TestTConnectEntryReading.entry1,
|
||||
TestTConnectEntryReading.entry2,
|
||||
TestTConnectEntryReading.entry3,
|
||||
TestTConnectEntryReading.entry4
|
||||
]
|
||||
self.assertListEqual(
|
||||
process_cgm_events(rawReadings),
|
||||
[TConnectEntry.parse_reading_entry(r) for r in rawReadings]
|
||||
)
|
||||
|
||||
class TestFindEventAt(unittest.TestCase):
|
||||
readingData = [
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3),
|
||||
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry4)
|
||||
]
|
||||
|
||||
def test_find_event_at_exact(self):
|
||||
for r in self.readingData:
|
||||
self.assertEqual(find_event_at(self.readingData, r["time"]), r)
|
||||
|
||||
def test_find_event_at_before_not_found(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-22 10:30:00-04:00"), None)
|
||||
|
||||
def test_find_event_at_large_gap(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 13:30:00-04:00"), self.readingData[0])
|
||||
|
||||
def test_find_event_at_between_close(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:17:52-04:00"), self.readingData[1])
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:21:52-04:00"), self.readingData[2])
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:25:59-04:00"), self.readingData[3])
|
||||
|
||||
def test_find_event_at_most_recent(self):
|
||||
self.assertEqual(find_event_at(self.readingData, "2021-10-23 18:00:00-04:00"), self.readingData[3])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+92
-3
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user