mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-24 03:34:12 -05:00
Migrate metadata consumers to BFF pump_metadata()
Switch choose_device, check, update_profiles, process.py and cli_helpers from the old pump_event_metadata() (reportsfacade) to the normalized pump_metadata() (BFF): tconnectDeviceId -> deviceId (UUID), and lastUpload.settings -> settings. Handle the BFF returning never-uploaded pumps (maxDateWithEvents=None): skip them in the most-recent auto-select and fall back to the first pump; raise a clear NoDevicesFound on an empty account instead of an opaque TypeError. Clean stale comments/docstrings in tandemsource.py. Tests: add tests/sync/tandemsource/test_choose_device.py (11 cases: explicit/auto/never-uploaded/empty/InvalidSerialNumber/stale-warning) and tests/api/test_tandemsource.py (pump_metadata adapter mapping), and update the test_process fixture to the new keys.
This commit is contained in:
@@ -618,15 +618,14 @@ class TandemSourceApi:
|
||||
DEFAULT_EVENT_IDS: List[int] = [229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,480,399,256,213,406,477,394,212,404,214,405,486,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191]
|
||||
|
||||
"""
|
||||
Returns raw unparsed string for pump events
|
||||
tconnect_device_id is "tconnectDeviceId" from pump_event_metadata()
|
||||
Returns raw unparsed string for pump events.
|
||||
tconnect_device_id is the device id from pump_metadata() (deviceId).
|
||||
"""
|
||||
def pump_events_raw(self, tconnect_device_id: str, min_date: Optional[str] = None, max_date: Optional[str] = None, event_ids_filter: Optional[List[int]] = DEFAULT_EVENT_IDS) -> str:
|
||||
minDate = parse_ymd_date(min_date)
|
||||
maxDate = parse_ymd_date(max_date)
|
||||
logger.debug(f'pump_events_raw({tconnect_device_id}, {minDate}, {maxDate})')
|
||||
|
||||
# default: 229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,399,256,213,406,394,212,404,214,405,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191
|
||||
eventIdsFilter = '%2C'.join(map(str, event_ids_filter)) if event_ids_filter else None
|
||||
return self.get('api/reports/reportsfacade/pumpevents/%s/%s?minDate=%s&maxDate=%s%s' % (
|
||||
self.pumperId,
|
||||
|
||||
@@ -90,7 +90,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
serialNumberToPump = None
|
||||
try:
|
||||
log("Fetching pump metadata...")
|
||||
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
|
||||
pumpEventMetadata = tconnect.tandemsource.pump_metadata()
|
||||
|
||||
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
|
||||
log(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
|
||||
@@ -102,7 +102,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
|
||||
log(f'ChooseDevice selected: {tconnectDevice}')
|
||||
|
||||
tconnectDeviceId = tconnectDevice['tconnectDeviceId']
|
||||
tconnectDeviceId = tconnectDevice['deviceId']
|
||||
|
||||
log(f'Fetching pump events for {tconnectDeviceId=} {time_start=} {time_end=} fetch_all_event_types=False')
|
||||
|
||||
@@ -187,7 +187,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
if serialNumberToPump:
|
||||
for i, (pumpSerial, pumpDetails) in enumerate(serialNumberToPump.items()):
|
||||
sanitizedData[f'PUMP_SERIAL_{i}'] = pumpSerial
|
||||
sanitizedData[f'TCONNECT_DEVICE_ID_{i}'] = pumpDetails['tconnectDeviceId']
|
||||
sanitizedData[f'TCONNECT_DEVICE_ID_{i}'] = pumpDetails['deviceId']
|
||||
loglines = [run_sanitize(i, sanitizedData) for i in loglines]
|
||||
|
||||
f.writelines(loglines)
|
||||
|
||||
@@ -11,7 +11,10 @@ class ChooseDevice:
|
||||
def choose(self):
|
||||
tconnect = self.tconnect
|
||||
|
||||
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
|
||||
pumpEventMetadata = tconnect.tandemsource.pump_metadata()
|
||||
|
||||
if not pumpEventMetadata:
|
||||
raise NoDevicesFound('No pumps are present on your Tandem Source account')
|
||||
|
||||
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
|
||||
logger.info(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
|
||||
@@ -38,19 +41,25 @@ class ChooseDevice:
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse maxDateWithEvents to check for staleness: {e}")
|
||||
|
||||
logger.info(f'Using pump with serial: {tconnectDevice["serialNumber"]} (tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
|
||||
logger.info(f'Using pump with serial: {tconnectDevice["serialNumber"]} (deviceId: {tconnectDevice["deviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
|
||||
else:
|
||||
# The BFF device list includes pumps that have never uploaded
|
||||
# (maxDateWithEvents is None); skip those when picking the most
|
||||
# recent one, and only fall back to one of them if nothing else.
|
||||
maxDateSeen = None
|
||||
for pump in pumpEventMetadata:
|
||||
if not tconnectDevice:
|
||||
if not pump.get('maxDateWithEvents'):
|
||||
continue
|
||||
pumpMaxDate = arrow.get(pump['maxDateWithEvents'])
|
||||
if not tconnectDevice or pumpMaxDate > maxDateSeen:
|
||||
maxDateSeen = pumpMaxDate
|
||||
tconnectDevice = pump
|
||||
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
|
||||
else:
|
||||
if arrow.get(pump['maxDateWithEvents']) > maxDateSeen:
|
||||
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
|
||||
tconnectDevice = pump
|
||||
|
||||
logger.info(f'Using most recent pump (serial: {tconnectDevice["serialNumber"]}, tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
|
||||
# If no pump has any events yet, fall back to the first one.
|
||||
if not tconnectDevice:
|
||||
tconnectDevice = pumpEventMetadata[0]
|
||||
|
||||
logger.info(f'Using most recent pump (serial: {tconnectDevice["serialNumber"]}, deviceId: {tconnectDevice["deviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
|
||||
|
||||
|
||||
return tconnectDevice
|
||||
@@ -58,5 +67,10 @@ class ChooseDevice:
|
||||
|
||||
|
||||
class InvalidSerialNumber(RuntimeError):
|
||||
def __str__(self):
|
||||
return "%s: %s" % (self.__class__.__name__, super().__str__())
|
||||
|
||||
|
||||
class NoDevicesFound(RuntimeError):
|
||||
def __str__(self):
|
||||
return "%s: %s" % (self.__class__.__name__, super().__str__())
|
||||
@@ -17,4 +17,4 @@ def fetch_oneshot(username, password, time_start=None, time_end=None, region='US
|
||||
time_start = time_end - datetime.timedelta(days=1)
|
||||
|
||||
tconnectDevice = ChooseDevice(secret, tconnect).choose()
|
||||
return tconnect.tandemsource.pump_events(tconnectDevice['tconnectDeviceId'], time_start, time_end, fetch_all_event_types=secret.FETCH_ALL_EVENT_TYPES)
|
||||
return tconnect.tandemsource.pump_events(tconnectDevice['deviceId'], time_start, time_end, fetch_all_event_types=secret.FETCH_ALL_EVENT_TYPES)
|
||||
@@ -7,7 +7,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from ...api import TConnectApi
|
||||
from ...nightscout import NightscoutApi
|
||||
from ...api.tandemsource import PumpEventMetadata
|
||||
from ...api.tandemsource import PumpMetadata
|
||||
|
||||
from ...features import DEVICE_STATUS, DEFAULT_FEATURES
|
||||
from ...eventparser import events as eventtypes
|
||||
@@ -28,10 +28,10 @@ from .update_profiles import UpdateProfiles
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessTimeRange:
|
||||
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnectDevice: "PumpEventMetadata", pretend: bool, secret: ModuleType, features: List[str] = DEFAULT_FEATURES) -> None:
|
||||
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnectDevice: "PumpMetadata", pretend: bool, secret: ModuleType, features: List[str] = DEFAULT_FEATURES) -> None:
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnectDevice['tconnectDeviceId']
|
||||
self.tconnect_device_id = tconnectDevice['deviceId']
|
||||
self.max_date_with_events = tconnectDevice['maxDateWithEvents']
|
||||
self.pretend = pretend
|
||||
self.secret = secret
|
||||
|
||||
@@ -35,16 +35,16 @@ class UpdateProfiles:
|
||||
upload_mode = _get_default_upload_mode()
|
||||
logger.debug("UpdateProfiles: getting Tandem Source profile data")
|
||||
|
||||
all_metadata = self.tconnect.tandemsource.pump_event_metadata()
|
||||
all_metadata = self.tconnect.tandemsource.pump_metadata()
|
||||
pump_meta = None
|
||||
for m in all_metadata:
|
||||
if m['tconnectDeviceId'] == self.tconnect_device_id:
|
||||
if m['deviceId'] == self.tconnect_device_id:
|
||||
pump_meta = m
|
||||
|
||||
if not pump_meta:
|
||||
return False
|
||||
|
||||
raw_settings = pump_meta.get("lastUpload", {}).get("settings")
|
||||
raw_settings = pump_meta.get("settings")
|
||||
if not raw_settings:
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from tconnectsync.api.tandemsource import TandemSourceApi
|
||||
|
||||
|
||||
# Representative GET api/reports/bff/pumper/{pumperId} response, mirroring the
|
||||
# structure of a real captured account response: one active pump with settings
|
||||
# and one never-uploaded pump (null date/settings fields).
|
||||
BFF_PUMPER = {
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"name": "Test User",
|
||||
"dateOfBirth": "1990-01-01",
|
||||
"lowGlucoseThreshold": 70,
|
||||
"highGlucoseThreshold": 180,
|
||||
"country": "US",
|
||||
"pumps": [
|
||||
{
|
||||
"algorithm": "Control-IQ",
|
||||
"availableDataRange": {"start": "2021-05-06T12:31:19", "end": "2022-02-16T22:45:58"},
|
||||
"assignmentId": "1b493210-9336-4901-a329-a352775738c5",
|
||||
"lastUploadDate": "2022-09-20T05:50:12Z",
|
||||
"maxDateOfEvents": "2022-02-16T22:45:58",
|
||||
"modelNumber": "1000354",
|
||||
"modelName": "t:slim X2™ Insulin Pump",
|
||||
"partNumber": "1011979",
|
||||
"serialNumber": "90556643",
|
||||
"softwareVersion": "7.8.0.0",
|
||||
"lastUploadClientType": "mobile_tconnect",
|
||||
"settings": {
|
||||
"id": "b7f931c8-63cd-44c9-86aa-56826f9057e5",
|
||||
"deviceAssignmentId": "1b493210-9336-4901-a329-a352775738c5",
|
||||
"uploadedTimeStamp": "2022-09-10T21:07:43.497",
|
||||
"settingsHash": "29EDDA8E7A72C1AD060271268CC7AE81FAD54B9D",
|
||||
"uploadId": "5da6a0ca-86c3-440f-9462-fa53168dcb9d",
|
||||
"details": {"profiles": {"numberOfProfiles": 1}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"algorithm": "Basal-IQ",
|
||||
"availableDataRange": {"start": None, "end": None},
|
||||
"assignmentId": "f6631fff-f403-4ce4-9362-83eff9e2850e",
|
||||
"glucoseUnit": None,
|
||||
"lastUploadDate": None,
|
||||
"maxDateOfEvents": None,
|
||||
"modelNumber": "1000096",
|
||||
"modelName": "t:slim X2™ Insulin Pump",
|
||||
"partNumber": "1003314",
|
||||
"serialNumber": "514387",
|
||||
"softwareVersion": "6.0.3.0",
|
||||
"lastUploadClientType": None,
|
||||
"settings": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestPumpMetadataAdapter(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def _api(self):
|
||||
# Bypass __init__ (which performs a network login) to test the adapter.
|
||||
return TandemSourceApi.__new__(TandemSourceApi)
|
||||
|
||||
def test_bff_pump_to_metadata_active_pump(self):
|
||||
meta = TandemSourceApi._bff_pump_to_metadata(BFF_PUMPER["pumps"][0])
|
||||
self.assertEqual(meta["deviceId"], "1b493210-9336-4901-a329-a352775738c5")
|
||||
self.assertEqual(meta["serialNumber"], "90556643")
|
||||
self.assertEqual(meta["modelNumber"], "1000354")
|
||||
self.assertEqual(meta["softwareVersion"], "7.8.0.0")
|
||||
self.assertEqual(meta["algorithm"], "Control-IQ")
|
||||
# maxDateOfEvents -> maxDateWithEvents
|
||||
self.assertEqual(meta["maxDateWithEvents"], "2022-02-16T22:45:58")
|
||||
# availableDataRange.start -> minDateWithEvents
|
||||
self.assertEqual(meta["minDateWithEvents"], "2021-05-06T12:31:19")
|
||||
# settings.details -> settings
|
||||
self.assertEqual(meta["settings"], {"profiles": {"numberOfProfiles": 1}})
|
||||
|
||||
def test_bff_pump_to_metadata_never_uploaded_pump(self):
|
||||
meta = TandemSourceApi._bff_pump_to_metadata(BFF_PUMPER["pumps"][1])
|
||||
self.assertEqual(meta["deviceId"], "f6631fff-f403-4ce4-9362-83eff9e2850e")
|
||||
self.assertEqual(meta["serialNumber"], "514387")
|
||||
# null date/settings fields map to None, not KeyError
|
||||
self.assertIsNone(meta["maxDateWithEvents"])
|
||||
self.assertIsNone(meta["minDateWithEvents"])
|
||||
self.assertIsNone(meta["settings"])
|
||||
|
||||
def test_pump_metadata_maps_all_pumps(self):
|
||||
api = self._api()
|
||||
with patch.object(TandemSourceApi, "get_pumper", return_value=BFF_PUMPER):
|
||||
metas = api.pump_metadata()
|
||||
self.assertEqual(len(metas), 2)
|
||||
self.assertEqual(
|
||||
[m["deviceId"] for m in metas],
|
||||
[
|
||||
"1b493210-9336-4901-a329-a352775738c5",
|
||||
"f6631fff-f403-4ce4-9362-83eff9e2850e",
|
||||
],
|
||||
)
|
||||
|
||||
def test_pump_metadata_empty_when_no_pumps(self):
|
||||
api = self._api()
|
||||
with patch.object(TandemSourceApi, "get_pumper", return_value={}):
|
||||
self.assertEqual(api.pump_metadata(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import arrow
|
||||
|
||||
from tconnectsync.sync.tandemsource.choose_device import (
|
||||
ChooseDevice,
|
||||
InvalidSerialNumber,
|
||||
NoDevicesFound,
|
||||
)
|
||||
|
||||
from ...api.fake import TConnectApi
|
||||
from ...secrets import build_secrets
|
||||
|
||||
LOGGER = "tconnectsync.sync.tandemsource.choose_device"
|
||||
|
||||
|
||||
class FakeTandemSourceApi:
|
||||
"""Fake TandemSource API returning a configurable pump_metadata() list."""
|
||||
def __init__(self, pumps=None):
|
||||
self._pumps = pumps if pumps is not None else []
|
||||
|
||||
def pump_metadata(self):
|
||||
return self._pumps
|
||||
|
||||
def needs_relogin(self):
|
||||
return False
|
||||
|
||||
|
||||
def pump(serial, deviceId=None, maxDate=None):
|
||||
return {
|
||||
'serialNumber': serial,
|
||||
'deviceId': deviceId if deviceId is not None else ('dev-' + serial),
|
||||
'maxDateWithEvents': maxDate,
|
||||
}
|
||||
|
||||
|
||||
class TestChooseDevice(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def _choose(self, pumps, **secret_kwargs):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._tandemsource = FakeTandemSourceApi(pumps=pumps)
|
||||
secret = build_secrets(**secret_kwargs)
|
||||
return ChooseDevice(secret, tconnect)
|
||||
|
||||
# --- auto-select branch (sentinel 11111111 or falsy) ---
|
||||
|
||||
def test_empty_list_raises_no_devices_found(self):
|
||||
cd = self._choose([], PUMP_SERIAL_NUMBER=11111111)
|
||||
with self.assertRaises(NoDevicesFound):
|
||||
cd.choose()
|
||||
|
||||
def test_auto_selects_most_recent_regardless_of_order(self):
|
||||
older = pump('111', maxDate=arrow.utcnow().shift(days=-10).isoformat())
|
||||
newer = pump('222', maxDate=arrow.utcnow().shift(days=-1).isoformat())
|
||||
cd = self._choose([newer, older], PUMP_SERIAL_NUMBER=11111111)
|
||||
self.assertIs(cd.choose(), newer)
|
||||
|
||||
def test_auto_select_skips_never_uploaded_pumps(self):
|
||||
never = pump('111', maxDate=None) # first in the list
|
||||
dated = pump('222', maxDate=arrow.utcnow().shift(days=-2).isoformat())
|
||||
cd = self._choose([never, dated], PUMP_SERIAL_NUMBER=11111111)
|
||||
self.assertIs(cd.choose(), dated)
|
||||
|
||||
def test_auto_select_falls_back_to_first_when_all_never_uploaded(self):
|
||||
first = pump('111', maxDate=None)
|
||||
second = pump('222', maxDate=None)
|
||||
cd = self._choose([first, second], PUMP_SERIAL_NUMBER=11111111)
|
||||
self.assertIs(cd.choose(), first)
|
||||
|
||||
def test_sentinel_int_triggers_auto_select(self):
|
||||
p = pump('90556643', maxDate=arrow.utcnow().shift(days=-1).isoformat())
|
||||
cd = self._choose([p], PUMP_SERIAL_NUMBER=11111111)
|
||||
# Must not raise InvalidSerialNumber even though no serial == 11111111
|
||||
self.assertIs(cd.choose(), p)
|
||||
|
||||
def test_falsy_serial_triggers_auto_select(self):
|
||||
p = pump('90556643', maxDate=arrow.utcnow().shift(days=-1).isoformat())
|
||||
cd = self._choose([p], PUMP_SERIAL_NUMBER=None)
|
||||
self.assertIs(cd.choose(), p)
|
||||
|
||||
# --- explicit-serial branch ---
|
||||
|
||||
def test_explicit_serial_overrides_recency(self):
|
||||
chosen = pump('90556643', maxDate=arrow.utcnow().shift(days=-10).isoformat())
|
||||
newer = pump('99999999', maxDate=arrow.utcnow().shift(days=-1).isoformat())
|
||||
cd = self._choose([newer, chosen], PUMP_SERIAL_NUMBER=90556643)
|
||||
self.assertIs(cd.choose(), chosen)
|
||||
|
||||
def test_unknown_serial_raises_invalid_serial_number(self):
|
||||
p = pump('90556643', maxDate=arrow.utcnow().shift(days=-1).isoformat())
|
||||
cd = self._choose([p], PUMP_SERIAL_NUMBER=12345678)
|
||||
with self.assertRaisesRegex(InvalidSerialNumber, "is not present on your account"):
|
||||
cd.choose()
|
||||
|
||||
# --- stale-pump warning ---
|
||||
|
||||
def test_stale_selected_pump_warns(self):
|
||||
p = pump('90556643', maxDate=arrow.utcnow().shift(days=-5).isoformat())
|
||||
cd = self._choose([p], PUMP_SERIAL_NUMBER=90556643)
|
||||
with self.assertLogs(LOGGER, level="WARNING") as cm:
|
||||
cd.choose()
|
||||
self.assertTrue(any("no events in the last" in m for m in cm.output))
|
||||
|
||||
def test_fresh_selected_pump_does_not_warn(self):
|
||||
p = pump('90556643', maxDate=arrow.utcnow().shift(days=-1).isoformat())
|
||||
cd = self._choose([p], PUMP_SERIAL_NUMBER=90556643)
|
||||
# assertNoLogs is 3.10+; CI runs 3.8, so assert INFO logs contain no warning text.
|
||||
with self.assertLogs(LOGGER, level="INFO") as cm:
|
||||
cd.choose()
|
||||
self.assertFalse(any("no events in the last" in m for m in cm.output))
|
||||
|
||||
def test_unparseable_date_on_selected_pump_is_swallowed(self):
|
||||
p = pump('90556643', maxDate="not-a-date")
|
||||
cd = self._choose([p], PUMP_SERIAL_NUMBER=90556643)
|
||||
with self.assertLogs(LOGGER, level="INFO") as cm:
|
||||
device = cd.choose()
|
||||
self.assertIs(device, p)
|
||||
self.assertFalse(any("no events in the last" in m for m in cm.output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -33,9 +33,9 @@ class FakeTandemSourceApi:
|
||||
def pump_events(self, device_id, time_start, time_end, fetch_all_event_types=False):
|
||||
return self.events
|
||||
|
||||
def pump_event_metadata(self):
|
||||
def pump_metadata(self):
|
||||
"""Return empty metadata for testing"""
|
||||
return {}
|
||||
return []
|
||||
|
||||
def needs_relogin(self):
|
||||
return False
|
||||
@@ -52,7 +52,7 @@ class TestProcessTimeRangeBasalDuration(unittest.TestCase):
|
||||
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
|
||||
|
||||
self.tconnectDevice = {
|
||||
'tconnectDeviceId': 'test-device-123',
|
||||
'deviceId': 'test-device-123',
|
||||
'maxDateWithEvents': '2025-11-18T13:00:00-05:00'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user