mirror of
https://github.com/bckelley/tconnectsync.git
synced 2026-08-24 03:34:12 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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+",
|
||||
|
||||
Reference in New Issue
Block a user