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:
James Woglom
2026-07-01 01:18:52 +00:00
parent 5c5449d5bd
commit 4c548203a5
9 changed files with 273 additions and 25 deletions
+2 -3
View File
@@ -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,
+3 -3
View File
@@ -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)
+3 -3
View File
@@ -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
+111
View File
@@ -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()
+3 -3
View File
@@ -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'
}