diff --git a/tconnectsync/api/tandemsource.py b/tconnectsync/api/tandemsource.py index ae3e571..18c3b83 100644 --- a/tconnectsync/api/tandemsource.py +++ b/tconnectsync/api/tandemsource.py @@ -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, diff --git a/tconnectsync/check.py b/tconnectsync/check.py index 56719e5..ce7bc14 100644 --- a/tconnectsync/check.py +++ b/tconnectsync/check.py @@ -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) diff --git a/tconnectsync/sync/tandemsource/choose_device.py b/tconnectsync/sync/tandemsource/choose_device.py index cfdda9c..b1dca5d 100644 --- a/tconnectsync/sync/tandemsource/choose_device.py +++ b/tconnectsync/sync/tandemsource/choose_device.py @@ -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__()) \ No newline at end of file diff --git a/tconnectsync/sync/tandemsource/cli_helpers.py b/tconnectsync/sync/tandemsource/cli_helpers.py index b101be6..5c9494d 100644 --- a/tconnectsync/sync/tandemsource/cli_helpers.py +++ b/tconnectsync/sync/tandemsource/cli_helpers.py @@ -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) \ No newline at end of file + return tconnect.tandemsource.pump_events(tconnectDevice['deviceId'], time_start, time_end, fetch_all_event_types=secret.FETCH_ALL_EVENT_TYPES) \ No newline at end of file diff --git a/tconnectsync/sync/tandemsource/process.py b/tconnectsync/sync/tandemsource/process.py index 2e64f2c..1757dcb 100644 --- a/tconnectsync/sync/tandemsource/process.py +++ b/tconnectsync/sync/tandemsource/process.py @@ -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 diff --git a/tconnectsync/sync/tandemsource/update_profiles.py b/tconnectsync/sync/tandemsource/update_profiles.py index 5aa854d..3d5cd7f 100644 --- a/tconnectsync/sync/tandemsource/update_profiles.py +++ b/tconnectsync/sync/tandemsource/update_profiles.py @@ -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 diff --git a/tests/api/test_tandemsource.py b/tests/api/test_tandemsource.py new file mode 100644 index 0000000..df8d9c0 --- /dev/null +++ b/tests/api/test_tandemsource.py @@ -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() diff --git a/tests/sync/tandemsource/test_choose_device.py b/tests/sync/tandemsource/test_choose_device.py new file mode 100644 index 0000000..81e4689 --- /dev/null +++ b/tests/sync/tandemsource/test_choose_device.py @@ -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() diff --git a/tests/sync/tandemsource/test_process.py b/tests/sync/tandemsource/test_process.py index ae49932..c8aa096 100644 --- a/tests/sync/tandemsource/test_process.py +++ b/tests/sync/tandemsource/test_process.py @@ -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' }