Normalize BFF pump dates to UTC and fix BffPump optional-field typing

maxDateOfEvents and availableDataRange.start are pump-local naive
wall-clock strings, but consumers parse them with arrow.get() (assumes
UTC) and compare against arrow.utcnow()/time.time(). Normalize both to
true UTC at the adapter boundary via _naive_local_to_utc (interpreting
the naive value in TIMEZONE_NAME), fixing the off-by-offset staleness
warning and update-timing telemetry.

Split BffPump into a required base plus a total=False extension so the
always-present fields are typed required, and read algorithm (canonically
optional) via .get() to avoid a KeyError.
This commit is contained in:
James Woglom
2026-07-01 03:39:07 +00:00
parent b7375d11da
commit 46a5a28baf
2 changed files with 119 additions and 24 deletions
+74 -20
View File
@@ -24,7 +24,7 @@ from jwt.algorithms import RSAAlgorithm
from ..util import timeago, cap_length
from .common import parse_ymd_date, base_headers, base_session, ApiException, ApiLoginException
from ..secret import CACHE_CREDENTIALS, CACHE_CREDENTIALS_PATH
from ..secret import CACHE_CREDENTIALS, CACHE_CREDENTIALS_PATH, TIMEZONE_NAME
from ..eventparser.generic import Events
logger = logging.getLogger(__name__)
@@ -79,26 +79,36 @@ class PumpSettingsEnvelope(TypedDict):
details: dict
class BffPump(TypedDict, total=False):
"""One element of BffPumper.pumps, from GET api/reports/bff/pumper/{pumperId}.
class BffPumpRequired(TypedDict):
"""Fields always present on a BffPump, even for a never-uploaded pump
(verified against a real captured GET api/reports/bff/pumper/{pumperId}
response).
`assignmentId` is the pump's UUID device id used as the path segment for
the pump-logs endpoint (replaces the old numeric tconnectDeviceId).
Several fields (settings, *Date*, lastUploadClientType, glucoseUnit,
availableDataRange.start/end) are null or absent for never-uploaded or
retired pumps, hence total=False.
"""
algorithm: str
availableDataRange: AvailableDataRange
assignmentId: str
serialNumber: str
modelNumber: str
modelName: str
softwareVersion: str
class BffPump(BffPumpRequired, total=False):
"""One element of BffPumper.pumps, from GET api/reports/bff/pumper/{pumperId}.
Extends BffPumpRequired with fields that are null or absent for
never-uploaded or retired pumps (settings, *Date*, lastUploadClientType,
glucoseUnit, availableDataRange.start/end), hence total=False. `algorithm`
is optional in the canonical BFF source (PumpAlgorithm | undefined) and so
must be accessed defensively.
"""
algorithm: Optional[str]
availableDataRange: AvailableDataRange
glucoseUnit: Optional[str]
lastUploadDate: Optional[str]
maxDateOfEvents: Optional[str]
modelNumber: str
modelName: str
partNumber: str
serialNumber: str
softwareVersion: str
lastUploadClientType: Optional[str]
settings: Optional[PumpSettingsEnvelope]
@@ -122,16 +132,24 @@ class PumpMetadata(TypedDict, total=False):
replacement for the old reportsfacade pump-event-metadata shape.
deviceId is the UUID assignmentId used as the pump-logs path segment (it
replaces the old numeric tconnectDeviceId). date fields are ISO-8601
strings or None; settings is the pump settings blob (BffPump.settings
.details) or None for a pump that has never uploaded.
replaces the old numeric tconnectDeviceId). settings is the pump settings
blob (BffPump.settings.details) or None for a pump that has never uploaded.
Date fields are ISO-8601 strings or None. maxDateWithEvents and
minDateWithEvents are normalized to true UTC here (previously they were the
pump-local naive wall-clock strings carried verbatim from BffPump's
maxDateOfEvents / availableDataRange.start): the BFF sends those two fields
without a tz, so they are interpreted in the configured TIMEZONE_NAME and
converted to UTC so consumers can compare them against arrow.utcnow() /
time.time(). (BffPump.lastUploadDate, by contrast, already carries a 'Z'
and is true UTC.)
"""
deviceId: str
serialNumber: str
modelNumber: str
modelName: str
softwareVersion: str
algorithm: str
algorithm: Optional[str]
maxDateWithEvents: Optional[str]
minDateWithEvents: Optional[str]
settings: Optional[dict]
@@ -576,9 +594,45 @@ class TandemSourceApi:
pumps[].settings.details carries the pump settings blob."""
return self.get('api/reports/bff/pumper/%s' % (self.pumperId), {})
@staticmethod
def _naive_local_to_utc(value: Optional[str]) -> Optional[str]:
"""Normalize a BFF pump-local naive wall-clock timestamp to a true UTC
ISO-8601 string.
The BFF sends maxDateOfEvents / availableDataRange.start with no tz
(e.g. "2022-02-16T22:45:58") even though they are the pump's local
wall-clock time. Downstream consumers parse them with arrow.get(...),
which assumes UTC, and compare against arrow.utcnow() / time.time()
(real UTC), so we shift them here by interpreting the naive value in the
configured TIMEZONE_NAME and converting to UTC. Values that already
carry a tz (defensive; not seen for these two fields) are passed
through unchanged so we never double-shift. None passes through as None
(never-uploaded pumps).
"""
if not value:
return value
# If the string already carries a tz (a trailing 'Z' or a +HH:MM /
# -HH:MM offset after the time portion), trust it and never double-shift.
# Otherwise it's a naive pump-local wall-clock value: interpret it in the
# configured TIMEZONE_NAME. (Per the BFF data these two fields are always
# naive; the has-tz branch is purely defensive.)
time_part = value.split('T', 1)[-1]
has_tz = value.endswith('Z') or '+' in time_part or '-' in time_part
if has_tz:
parsed = arrow.get(value)
else:
parsed = arrow.get(value, tzinfo=TIMEZONE_NAME)
return parsed.to('UTC').isoformat()
@staticmethod
def _bff_pump_to_metadata(pump: BffPump) -> PumpMetadata:
"""Adapt one BffPump into the normalized PumpMetadata shape."""
"""Adapt one BffPump into the normalized PumpMetadata shape.
maxDateOfEvents and availableDataRange.start are pump-local naive
wall-clock strings; we normalize them to true UTC (via
_naive_local_to_utc) so consumers that compare against arrow.utcnow() /
time.time() are correct.
"""
settings = pump.get('settings')
data_range = pump.get('availableDataRange') or {}
meta: PumpMetadata = {
@@ -587,9 +641,9 @@ class TandemSourceApi:
'modelNumber': pump['modelNumber'],
'modelName': pump['modelName'],
'softwareVersion': pump['softwareVersion'],
'algorithm': pump['algorithm'],
'maxDateWithEvents': pump.get('maxDateOfEvents'),
'minDateWithEvents': data_range.get('start'),
'algorithm': pump.get('algorithm'),
'maxDateWithEvents': TandemSourceApi._naive_local_to_utc(pump.get('maxDateOfEvents')),
'minDateWithEvents': TandemSourceApi._naive_local_to_utc(data_range.get('start')),
'settings': settings['details'] if settings else None,
}
return meta
+45 -4
View File
@@ -77,10 +77,12 @@ class TestPumpMetadataAdapter(unittest.TestCase):
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")
# maxDateOfEvents -> maxDateWithEvents, normalized from pump-local naive
# (America/New_York, EST/UTC-5 in Feb) to true UTC.
self.assertEqual(meta["maxDateWithEvents"], "2022-02-17T03:45:58+00:00")
# availableDataRange.start -> minDateWithEvents, normalized from
# pump-local naive (America/New_York, EDT/UTC-4 in May) to true UTC.
self.assertEqual(meta["minDateWithEvents"], "2021-05-06T16:31:19+00:00")
# settings.details -> settings
self.assertEqual(meta["settings"], {"profiles": {"numberOfProfiles": 1}})
@@ -129,6 +131,45 @@ class TestPumpMetadataAdapter(unittest.TestCase):
with self.assertRaises(KeyError):
TandemSourceApi._bff_pump_to_metadata(pump)
def test_other_required_keys_raise_when_absent(self):
# The 5 always-present BFF fields are still accessed with required
# subscript syntax; absence is a genuine error.
for key in ("assignmentId", "modelNumber", "modelName", "softwareVersion"):
pump = dict(BFF_PUMPER["pumps"][0])
del pump[key]
with self.assertRaises(KeyError, msg=key):
TandemSourceApi._bff_pump_to_metadata(pump)
def test_missing_algorithm_maps_to_none(self):
# algorithm is optional in the canonical BFF source; its absence must
# not raise (previously a KeyError) and should map to None.
pump = dict(BFF_PUMPER["pumps"][0])
del pump["algorithm"]
meta = TandemSourceApi._bff_pump_to_metadata(pump)
self.assertIsNone(meta["algorithm"])
def test_naive_dates_normalized_to_utc(self):
# America/New_York is set in tests/conftest.py. Feb -> EST (UTC-5),
# May -> EDT (UTC-4).
meta = TandemSourceApi._bff_pump_to_metadata(BFF_PUMPER["pumps"][0])
self.assertEqual(meta["maxDateWithEvents"], "2022-02-17T03:45:58+00:00")
self.assertEqual(meta["minDateWithEvents"], "2021-05-06T16:31:19+00:00")
def test_naive_local_to_utc_none_passthrough(self):
self.assertIsNone(TandemSourceApi._naive_local_to_utc(None))
def test_naive_local_to_utc_idempotent_no_double_shift(self):
# A value that already carries a tz must not be shifted again. Feed the
# already-UTC output back in and confirm it is unchanged.
first = TandemSourceApi._naive_local_to_utc("2022-02-16T22:45:58")
self.assertEqual(first, "2022-02-17T03:45:58+00:00")
self.assertEqual(TandemSourceApi._naive_local_to_utc(first), first)
# A 'Z'-suffixed (true UTC) value is passed through as UTC unchanged.
self.assertEqual(
TandemSourceApi._naive_local_to_utc("2022-09-20T05:50:12Z"),
"2022-09-20T05:50:12+00:00",
)
def test_mobi_controliq_plus_passthrough(self):
pump = {
"algorithm": "Control-IQ+",