mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-24 03:34:12 -05:00
use domain objects for webui scraping pump profiles
This commit is contained in:
+43
-17
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+35
-34
@@ -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 = """
|
||||
<!DOCTYPE html
|
||||
@@ -956,42 +957,42 @@ class TestWebUIScraper(unittest.TestCase):
|
||||
text=self.PUMP_SETTINGS_HTML)
|
||||
|
||||
profiles, settings = webui.device_settings_from_guid('00000000-0000-0000-0000-000000000001')
|
||||
self.assertListEqual(profiles, [{
|
||||
self.assertListEqual(profiles, [Profile(**{
|
||||
'title': 'A',
|
||||
'active': True,
|
||||
'segments': [{
|
||||
'segments': [ProfileSegment(**{
|
||||
'display_time': 'Midnight',
|
||||
'time': '12:00 AM',
|
||||
'basal_rate': '0.800 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}, {
|
||||
'basal_rate': 0.800,
|
||||
'correction_factor': 30.0,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
}), ProfileSegment(**{
|
||||
'display_time': '6:00 AM',
|
||||
'time': '6:00 AM',
|
||||
'basal_rate': '1.250 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}, {
|
||||
'basal_rate': 1.250,
|
||||
'correction_factor': 30,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
}), ProfileSegment(**{
|
||||
'display_time': '11:00 AM',
|
||||
'time': '11:00 AM',
|
||||
'basal_rate': '1.000 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}, {
|
||||
'basal_rate': 1.000,
|
||||
'correction_factor': 30,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
}), ProfileSegment(**{
|
||||
'display_time': 'Noon',
|
||||
'time': '12:00 PM',
|
||||
'basal_rate': '0.800 u/hr',
|
||||
'correction_factor': '1u:30 mg/dL',
|
||||
'carb_ratio': '1u:6.0 g',
|
||||
'target_bg': '110 mg/dL'
|
||||
}],
|
||||
'calculated_total_daily_basal': '21.65 units',
|
||||
'insulin_duration': '5:00 hours',
|
||||
'carbohydrates': 'On'
|
||||
}])
|
||||
'basal_rate': 0.800,
|
||||
'correction_factor': 30,
|
||||
'carb_ratio': 6.0,
|
||||
'target_bg_mgdl': 110
|
||||
})],
|
||||
'calculated_total_daily_basal': 21.65,
|
||||
'insulin_duration_min': 5*60,
|
||||
'carbs_enabled': True
|
||||
})])
|
||||
|
||||
self.assertDictEqual(settings, {
|
||||
'Alerts': {
|
||||
|
||||
Reference in New Issue
Block a user