diff --git a/tconnectsync/api/webui.py b/tconnectsync/api/webui.py index 9e03179..04124df 100644 --- a/tconnectsync/api/webui.py +++ b/tconnectsync/api/webui.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Tuple import requests import urllib import datetime @@ -8,7 +8,7 @@ import logging from bs4 import BeautifulSoup -from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment +from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment, DeviceSettings from tconnectsync.util import removesuffix, removeprefix from tconnectsync.util.constants import MMOLL_TO_MGDL @@ -108,9 +108,9 @@ class WebUIScraper: """ Returns a parsed representation of a pump's settings. Note that pump_guid is NOT the serial number of the pump, and - should be obtained from my_devices()[str(serial_number)]['guid'] + should be obtained from my_devices()[str(serial_number)].guid """ - def device_settings_from_guid(self, pump_guid: str) -> List[Profile]: + def device_settings_from_guid(self, pump_guid: str) -> Tuple[List[Profile], DeviceSettings]: profiles = [] settings = {} r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid) @@ -126,7 +126,15 @@ class WebUIScraper: else: settings.update(self._parse_settings_tbl(tbl)) - return profiles, settings + low_bg_threshold, high_bg_threshold = self._extract_bg_thresholds(settings) + + dev_settings = DeviceSettings( + low_bg_threshold=low_bg_threshold, + high_bg_threshold=high_bg_threshold, + raw_settings=settings + ) + + return profiles, dev_settings def _parse_profile_tbl(self, tbl) -> Profile: profile = {} @@ -246,6 +254,22 @@ class WebUIScraper: return settings + def _extract_bg_thresholds(self, settings): + # Nightscout needs default values + low_bg_threshold = 70 + high_bg_threshold = 180 + if 'CGM Alerts' in settings: + if 'Low Alert' in settings['CGM Alerts']: + low = settings['CGM Alerts']['Low Alert'] + if low['value']: + low_bg_threshold = int(low['text'].split(' mg/dL')[0]) + if 'High Alert' in settings['CGM Alerts']: + high = settings['CGM Alerts']['High Alert'] + if high['value']: + high_bg_threshold = int(high['text'].split(' mg/dL')[0]) + + return low_bg_threshold, high_bg_threshold + """ Wraps a call to my_devices to identify the device GUID from the given pump serial, and then returns device_settings_from_guid. diff --git a/tconnectsync/domain/device_settings.py b/tconnectsync/domain/device_settings.py index a64b071..0d0ce67 100644 --- a/tconnectsync/domain/device_settings.py +++ b/tconnectsync/domain/device_settings.py @@ -25,3 +25,10 @@ class Profile: calculated_total_daily_basal: float # in units insulin_duration_min: int carbs_enabled: bool + +# Settings stored globally in the pump that are stored per-profile in Nightscout +@dataclass +class DeviceSettings: + low_bg_threshold: int + high_bg_threshold: int + raw_settings: dict diff --git a/tconnectsync/nightscout.py b/tconnectsync/nightscout.py index a748980..c890de7 100644 --- a/tconnectsync/nightscout.py +++ b/tconnectsync/nightscout.py @@ -144,4 +144,29 @@ class NightscoutApi: }, verify=self.verify) if status.status_code != 200: raise Exception('HTTP error status code (%d) from Nightscout: %s' % (status.status_code, status.text)) - return status.json() \ No newline at end of file + return status.json() + + """ + Returns information on configured Nightscout profiles, over the optional time range. + Will only return the most recent profile for the given range. + """ + def profiles(self, time_start=None, time_end=None): + def internal(t_to_space): + dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space) + latest = requests.get(urljoin(self.url, 'api/v1/profile.json?' + dateFilter + '&ts=' + str(time.time())), headers={ + 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() + }, verify=self.verify) + if latest.status_code != 200: + raise ApiException(latest.status_code, "Nightscout profiles response: %s" % latest.text) + + j = latest.json() + if j and len(j) > 0: + return j[0] + return None + + ret = internal(False) + if ret is None and (time_start or time_end): + ret = internal(True) + if ret is not None: + logger.warning("profiles with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end)) + return ret \ No newline at end of file diff --git a/tconnectsync/parser/nightscout.py b/tconnectsync/parser/nightscout.py index d4877dc..1427e1b 100644 --- a/tconnectsync/parser/nightscout.py +++ b/tconnectsync/parser/nightscout.py @@ -1,5 +1,8 @@ import arrow +from ..domain.device_settings import Profile, DeviceSettings +from ..secret import TIMEZONE_NAME, NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE + ENTERED_BY = "Pump (tconnectsync)" BASAL_EVENTTYPE = "Temp Basal" @@ -106,6 +109,76 @@ class NightscoutEntry: "created_at": created_at, "enteredBy": ENTERED_BY } + + # Tandem-scraped profile to Nightscout profile store entry + @staticmethod + def profile_store(profile: Profile, device_settings: DeviceSettings) -> dict: + return { + # insulin duration in hours + "dia": (profile.insulin_duration_min / 60), + "carbratio": [ + { + "time": tandem_to_ns_time(segment.time), + "timeAsSeconds": tandem_to_ns_time_seconds(segment.time), + "value": segment.carb_ratio + } for segment in profile.segments + ], + + "carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, + "delay": NIGHTSCOUT_PROFILE_DELAY_VALUE, + "sens": [ # Correction factor + { + "time": tandem_to_ns_time(segment.time), + "timeAsSeconds": tandem_to_ns_time_seconds(segment.time), + "value": segment.correction_factor + } for segment in profile.segments + ], + "basal": [ + { + "time": tandem_to_ns_time(segment.time), + "timeAsSeconds": tandem_to_ns_time_seconds(segment.time), + "value": segment.basal_rate + } for segment in profile.segments + ], + "target_low": [ + { + "time": "00:00", + "timeAsSeconds": 0, + "value": device_settings.low_bg_threshold + } + ], + "target_high": [ + { + "time": "00:00", + "timeAsSeconds": 0, + "value": device_settings.high_bg_threshold + } + ], + "timezone": TIMEZONE_NAME, # tconnectsync settings timezone + "startDate": "1970-01-01T00:00:00.000Z", + "units": "mg/dl" + } + +def tandem_to_ns_time(tandem_time: str) -> str: + numbers, ampm = tandem_time.split(' ') + hr, min = numbers.split(':') + if ampm.lower().strip() == 'am': + return "%02d:%02d" % (int(hr) % 12, int(min)) + elif ampm.lower().strip() == 'pm': + return "%02d:%02d" % (12 + (int(hr) % 12), int(min)) + raise InvalidTimeException(tandem_time) + +def tandem_to_ns_time_seconds(tandem_time: str) -> int: + numbers, ampm = tandem_time.split(' ') + hr, min = numbers.split(':') + if ampm.lower().strip() == 'am': + return 60 * (60 * (int(hr) % 12) + int(min)) + elif ampm.lower().strip() == 'pm': + return 60 * (60 * (12 + (int(hr) % 12)) + int(min)) + raise InvalidTimeException(tandem_time) class InvalidBolusTypeException(RuntimeError): + pass + +class InvalidTimeException(RuntimeError): pass \ No newline at end of file diff --git a/tconnectsync/secret.py b/tconnectsync/secret.py index be63423..4b4643b 100644 --- a/tconnectsync/secret.py +++ b/tconnectsync/secret.py @@ -59,6 +59,10 @@ AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15 AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true') AUTOUPDATE_MAX_LOOP_INVOCATIONS = get_number('AUTOUPDATE_MAX_LOOP_INVOCATIONS', '-1') +# Default Nightscout profile segment fields which aren't stored by Tandem +NIGHTSCOUT_PROFILE_CARBS_HR_VALUE = get('NIGHTSCOUT_PROFILE_CARBS_HR_VALUE', '20') +NIGHTSCOUT_PROFILE_DELAY_VALUE = get('NIGHTSCOUT_PROFILE_DELAY_VALUE', '20') + ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false') SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false') REQUESTS_PROXY = get('REQUESTS_PROXY', '') diff --git a/tconnectsync/sync/profile.py b/tconnectsync/sync/profile.py new file mode 100644 index 0000000..3bbe801 --- /dev/null +++ b/tconnectsync/sync/profile.py @@ -0,0 +1,45 @@ +from typing import List, Tuple +import logging + +from ..api import TConnectApi +from ..domain.device_settings import Profile +from ..secret import PUMP_SERIAL_NUMBER + +logger = logging.getLogger(__name__) + +def get_pump_profiles(tconnect: TConnectApi) -> List[Profile]: + all_devices = tconnect.webui.my_devices() + if str(PUMP_SERIAL_NUMBER) not in all_devices: + logger.warn("Could not find entry for provided pump serial number in t:connect device list: %s, received: %s", PUMP_SERIAL_NUMBER, all_devices) + return [] + + device = all_devices[str(PUMP_SERIAL_NUMBER)] + + logger.info("Getting profile settings for %s", device) + device_profiles, device_settings = tconnect.webui.device_settings_from_guid(device.guid) + logger.debug("device_profiles: %s", device_profiles) + logger.debug("device_settings: %s", device_settings) + + logger.info("Found pump profiles: %s", ["%s%s" % (profile.title, " (active)" if profile.active else "") for profile in device_profiles]) + + return device_profiles + + +""" +Compare pump device and Nightscout profiles, and return a final dictionary of +Nightscout profile objects, with the pump profile settings overriding what is +currently in Nightscout. + +ns_profile_obj is the output from NightscoutApi.profiles() and should be the most +recent profile object in mongo. +""" +def compare_profiles(device_profiles: List[Profile], ns_profile_obj: dict): + device = {profile.title: profile for profile in device_profiles} + ns = ns_profile_obj.get('store', {}) + + logger.info("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys()) + + missing_profiles_in_ns = set(device.keys()) - set(ns.keys()) + for profile_name in missing_profiles_in_ns: + logger.info("Missing profile in Nightscout: %s: %s", profile_name, device.get(profile_name)) + pump_configured_profile = device[profile_name] diff --git a/tests/api/test_webui.py b/tests/api/test_webui.py index c61d38a..345d919 100644 --- a/tests/api/test_webui.py +++ b/tests/api/test_webui.py @@ -953,7 +953,6 @@ class TestWebUIScraper(unittest.TestCase): with requests_mock.Mocker() as m: m.get('https://tconnect.tandemdiabetes.com/myaccount/DeviceSettings.aspx?guid=00000000-0000-0000-0000-000000000001', request_headers=base_headers(), - text=self.PUMP_SETTINGS_HTML) profiles, settings = webui.device_settings_from_guid('00000000-0000-0000-0000-000000000001') @@ -994,7 +993,7 @@ class TestWebUIScraper(unittest.TestCase): 'carbs_enabled': True })]) - self.assertDictEqual(settings, { + self.assertDictEqual(settings.raw_settings, { 'Alerts': { 'Alert: Auto-Off': {'text': '18 hrs', 'value': True}, 'Alert: Low Insulin': {'text': '35 u'} @@ -1043,6 +1042,8 @@ class TestWebUIScraper(unittest.TestCase): }, 'upload_date': 'Jan 09, 2022' }) + self.assertEqual(settings.low_bg_threshold, 80) + self.assertEqual(settings.high_bg_threshold, 200) diff --git a/tests/parser/test_nightscout.py b/tests/parser/test_nightscout.py index 2d66141..ae250be 100644 --- a/tests/parser/test_nightscout.py +++ b/tests/parser/test_nightscout.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 import unittest -from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException +from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException, tandem_to_ns_time, tandem_to_ns_time_seconds +from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings +from tconnectsync.secret import NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE, TIMEZONE_NAME class TestNightscoutEntry(unittest.TestCase): + maxDiff = None def test_basal(self): self.assertEqual( NightscoutEntry.basal( @@ -191,6 +194,167 @@ class TestNightscoutEntry(unittest.TestCase): } ) + + def test_profile_store(self): + self.assertEqual( + NightscoutEntry.profile_store( + profile=Profile( + title='A', + active=True, + segments=[ + ProfileSegment( + display_time='Midnight', + time='12:00 AM', + basal_rate=0.8, + correction_factor=30.0, + carb_ratio=6.0, + target_bg_mgdl=110.0), + ProfileSegment( + display_time='6:00 AM', + time='6:00 AM', + basal_rate=1.25, + correction_factor=30.0, + carb_ratio=6.0, + target_bg_mgdl=110.0), + ProfileSegment( + display_time='11:00 AM', + time='11:00 AM', + basal_rate=1.0, + correction_factor=30.0, + carb_ratio=6.0, + target_bg_mgdl=110.0), + ProfileSegment( + display_time='Noon', + time='12:00 PM', + basal_rate=0.8, + correction_factor=30.0, + carb_ratio=6.0, + target_bg_mgdl=110.0) + ], + calculated_total_daily_basal=21.65, + insulin_duration_min=300, + carbs_enabled=True + ), + device_settings=DeviceSettings( + low_bg_threshold=80, + high_bg_threshold=200, + raw_settings={} + ) + ), + { + "dia": 5.0, + "carbratio": [ + { + "time": "00:00", + "timeAsSeconds": 0, + "value": 6.0 + }, + { + "time": "06:00", + "timeAsSeconds": 6*60*60, + "value": 6.0 + }, + { + "time": "11:00", + "timeAsSeconds": 11*60*60, + "value": 6.0 + }, + { + "time": "12:00", + "timeAsSeconds": 12*60*60, + "value": 6.0 + } + ], + + "carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, + "delay": NIGHTSCOUT_PROFILE_DELAY_VALUE, + "sens": [ + { + "time": "00:00", + "timeAsSeconds": 0, + "value": 30.0 + }, + { + "time": "06:00", + "timeAsSeconds": 6*60*60, + "value": 30.0 + }, + { + "time": "11:00", + "timeAsSeconds": 11*60*60, + "value": 30.0 + }, + { + "time": "12:00", + "timeAsSeconds": 12*60*60, + "value": 30.0 + } + ], + "basal": [ + { + "time": "00:00", + "timeAsSeconds": 0, + "value": 0.8 + }, + { + "time": "06:00", + "timeAsSeconds": 6*60*60, + "value": 1.25 + }, + { + "time": "11:00", + "timeAsSeconds": 11*60*60, + "value": 1.0 + }, + { + "time": "12:00", + "timeAsSeconds": 12*60*60, + "value": 0.8 + } + ], + "target_low": [ + { + "time": "00:00", + "timeAsSeconds": 0, + "value": 80 + } + ], + "target_high": [ + { + "time": "00:00", + "timeAsSeconds": 0, + "value": 200 + } + ], + "timezone": TIMEZONE_NAME, + "startDate": "1970-01-01T00:00:00.000Z", + "units": "mg/dl" + } + ) + + +class TestTandemNightscoutTime(unittest.TestCase): + def test_tandem_to_ns_time(self): + self.assertEqual(tandem_to_ns_time('12:00 AM'), '00:00') + self.assertEqual(tandem_to_ns_time('12:30 AM'), '00:30') + self.assertEqual(tandem_to_ns_time('6:00 AM'), '06:00') + self.assertEqual(tandem_to_ns_time('6:30 AM'), '06:30') + self.assertEqual(tandem_to_ns_time('11:30 AM'), '11:30') + self.assertEqual(tandem_to_ns_time('12:00 PM'), '12:00') + self.assertEqual(tandem_to_ns_time('12:30 PM'), '12:30') + self.assertEqual(tandem_to_ns_time('06:30 PM'), '18:30') + self.assertEqual(tandem_to_ns_time('11:30 PM'), '23:30') + + def test_tandem_to_ns_time_seconds(self): + self.assertEqual(tandem_to_ns_time_seconds('12:00 AM'), 0) + self.assertEqual(tandem_to_ns_time_seconds('12:30 AM'), 30*60) + self.assertEqual(tandem_to_ns_time_seconds('6:00 AM'), 6*60*60) + self.assertEqual(tandem_to_ns_time_seconds('6:30 AM'), 6*60*60 + 30*60) + self.assertEqual(tandem_to_ns_time_seconds('11:30 AM'), 11*60*60 + 30*60) + self.assertEqual(tandem_to_ns_time_seconds('12:00 PM'), 12*60*60) + self.assertEqual(tandem_to_ns_time_seconds('12:30 PM'), 12*60*60 + 30*60) + self.assertEqual(tandem_to_ns_time_seconds('06:30 PM'), 12*60*60 + 6*60*60 + 30*60) + self.assertEqual(tandem_to_ns_time_seconds('11:30 PM'), 12*60*60 + 11*60*60 + 30*60) if __name__ == '__main__': unittest.main() \ No newline at end of file