From 6f15ef885713dd73f437dadbaf014b3e298dfc9a Mon Sep 17 00:00:00 2001 From: James Woglom Date: Thu, 11 Aug 2022 00:26:05 -0400 Subject: [PATCH] use domain objects for webui scraping pump profiles --- tconnectsync/api/webui.py | 60 +++++++++++++++------- tconnectsync/domain/device_settings.py | 27 ++++++++++ tconnectsync/util/__init__.py | 11 ++++ tconnectsync/util/constants.py | 4 ++ tests/api/test_webui.py | 69 +++++++++++++------------- 5 files changed, 120 insertions(+), 51 deletions(-) create mode 100644 tconnectsync/domain/device_settings.py create mode 100644 tconnectsync/util/constants.py diff --git a/tconnectsync/api/webui.py b/tconnectsync/api/webui.py index 1cb2282..9e03179 100644 --- a/tconnectsync/api/webui.py +++ b/tconnectsync/api/webui.py @@ -1,3 +1,4 @@ +from typing import List import requests import urllib import datetime @@ -7,6 +8,10 @@ import logging from bs4 import BeautifulSoup +from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment +from tconnectsync.util import removesuffix, removeprefix +from tconnectsync.util.constants import MMOLL_TO_MGDL + from .common import base_headers, ApiException logger = logging.getLogger(__name__) @@ -92,12 +97,11 @@ class WebUIScraper: settings_guid = settings_a.attrs['href'].split('?guid=')[1] if serial_number: - devices[serial_number] = { - 'name': device_name, - 'model_number': model_number, - 'status': status, - 'guid': settings_guid - } + devices[serial_number] = Device( + name=device_name, + model_number=model_number, + status=status, + guid=settings_guid) return devices @@ -106,7 +110,7 @@ class WebUIScraper: Note that pump_guid is NOT the serial number of the pump, and should be obtained from my_devices()[str(serial_number)]['guid'] """ - def device_settings_from_guid(self, pump_guid): + def device_settings_from_guid(self, pump_guid: str) -> List[Profile]: profiles = [] settings = {} r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid) @@ -124,12 +128,33 @@ class WebUIScraper: return profiles, settings - def _parse_profile_tbl(self, tbl): + def _parse_profile_tbl(self, tbl) -> Profile: profile = {} profile["title"] = self.strip(tbl.select_one('.setting_title').text) profile["active"] = bool(tbl.find(text='Active at the time of upload')) profile["segments"] = [] + def parse_basal_rate(rate) -> float: + return float(removesuffix(rate, ' u/hr')) + + def parse_factor(ratio) -> int: + return parse_bg_mgdl(removeprefix(ratio, '1u:')) + + def parse_ratio(ratio) -> float: + return float(removesuffix(removeprefix(ratio, '1u:'), ' g')) + + def parse_bg_mgdl(bg) -> int: + if bg.endswith(' mg/dL'): + return float(removesuffix(bg, ' mg/dL')) + elif bg.endswith(' mmol/L'): + return float(removesuffix(bg, ' mmol/L')) * MMOLL_TO_MGDL + raise ValueError(bg) + + def hours_to_mins(text) -> int: + hrmin = removesuffix(text, " hours") + hr, min = hrmin.split(":", 1) + return int(min) + int(hr)*60 + for tr in tbl.select('tr'): # Skip header rows if tr.select_one('.setting_bg'): @@ -149,19 +174,20 @@ class WebUIScraper: t = "12:00 AM" elif display_time == "Noon": t = "12:00 PM" + segment = { "display_time": display_time, "time": t, - "basal_rate": self.strip(tds[1].text), - "correction_factor": self.strip(tds[2].text), - "carb_ratio": self.strip(tds[3].text), - "target_bg": self.strip(tds[4].text) + "basal_rate": parse_basal_rate(self.strip(tds[1].text)), + "correction_factor": parse_factor(self.strip(tds[2].text)), + "carb_ratio": parse_ratio(self.strip(tds[3].text)), + "target_bg_mgdl": parse_bg_mgdl(self.strip(tds[4].text)) } - profile["segments"].append(segment) + profile["segments"].append(ProfileSegment(**segment)) continue if tr.find(text='Calculated Total Daily Basal'): - profile["calculated_total_daily_basal"] = self.strip(tds[1].text) + profile["calculated_total_daily_basal"] = float(removesuffix(self.strip(tds[1].text), " units")) continue # Last row @@ -175,12 +201,12 @@ class WebUIScraper: key = self.strip(key) val = self.strip(val) if key == 'Duration of Insulin': - profile["insulin_duration"] = val + profile["insulin_duration_min"] = hours_to_mins(val) elif key == 'Carbohydrates': - profile["carbohydrates"] = val + profile["carbs_enabled"] = self.strip(val.lower()) == "on" - return profile + return Profile(**profile) def _parse_settings_tbl(self, tbl): outer_tr = tbl.select('tr')[2] diff --git a/tconnectsync/domain/device_settings.py b/tconnectsync/domain/device_settings.py new file mode 100644 index 0000000..a64b071 --- /dev/null +++ b/tconnectsync/domain/device_settings.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import List, Optional + +@dataclass +class Device: + name: str + model_number: str + status: str + guid: Optional[str] + +@dataclass +class ProfileSegment: + display_time: str # Identical to time except written out as Midnight or Noon + time: str + basal_rate: float # _ u/hr + correction_factor: int # 1u: _ mg/dL + carb_ratio: float # 1u: _ g + target_bg_mgdl: int + +@dataclass +class Profile: + title: str + active: bool + segments: List[ProfileSegment] + calculated_total_daily_basal: float # in units + insulin_duration_min: int + carbs_enabled: bool diff --git a/tconnectsync/util/__init__.py b/tconnectsync/util/__init__.py index ec28b14..f60db25 100644 --- a/tconnectsync/util/__init__.py +++ b/tconnectsync/util/__init__.py @@ -15,3 +15,14 @@ def timeago(timestamp): ret += '%d minutes' % (seconds//60) return fmt % ret + +# String methods only available in python 3.9+ +def removesuffix(input_string, suffix): + if suffix and input_string.endswith(suffix): + return input_string[:-len(suffix)] + return input_string + +def removeprefix(input_string, prefix): + if prefix and input_string.startswith(prefix): + return input_string[len(prefix):] + return input_string \ No newline at end of file diff --git a/tconnectsync/util/constants.py b/tconnectsync/util/constants.py new file mode 100644 index 0000000..2553a37 --- /dev/null +++ b/tconnectsync/util/constants.py @@ -0,0 +1,4 @@ + +# http://www.soc-bdr.org/rds/authors/unit_tables_conversions_and_genetic_dictionaries/conversion_glucose_mg_dl_to_mmol_l/index_en.html +MMOLL_TO_MGDL = 18.0182 +MGDL_TO_MMOLL = 0.0555 \ No newline at end of file diff --git a/tests/api/test_webui.py b/tests/api/test_webui.py index ba8fe06..c61d38a 100644 --- a/tests/api/test_webui.py +++ b/tests/api/test_webui.py @@ -11,6 +11,7 @@ import requests_mock from bs4 import BeautifulSoup from tconnectsync.api.webui import WebUIScraper +from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment from .fake import ControlIQApi @@ -293,30 +294,30 @@ class TestWebUIScraper(unittest.TestCase): devices = webui.my_devices() self.assertDictEqual(devices, { - '100001': { + '100001': Device(**{ 'name': 't:slim X2™ Insulin Pump', 'model_number': '001002717', 'status': 'Activated — Dec 30 2021', 'guid': '00000000-0000-0000-0000-000000000001' - }, - '10000002': { + }), + '10000002': Device(**{ 'name': 't:slim X2™ Insulin Pump', 'model_number': '001000354', 'status': 'Activated — Oct 26 2021', 'guid': '00000000-0000-0000-0000-000000000002' - }, - '100003': { + }), + '100003': Device(**{ 'name': 't:slim X2™ Insulin Pump', 'model_number': '001000096', 'status': 'Activated — Nov 20 2017', 'guid': '00000000-0000-0000-0000-000000000003' - }, - 'ABCDEFGH': { + }), + 'ABCDEFGH': Device(**{ 'name': 'OneTouch Verio IQ', 'model_number': 'VERIO IQ', 'status': 'Activated — Jan 17 2018', 'guid': None - }}) + })}) PUMP_SETTINGS_HTML = """