Fold pump-logs JSON handling into Event()/Events()

Event(x) and Events(x) now accept either a raw binary event/stream or a
pump-logs JSON event dict / iterable of dicts, dispatching on input type,
replacing the separate Event_from_json/Events_from_json functions. Point
pump_events() and all tests at the unified entry points, and add real-JSON
sync tests for CGM readings, user-mode sleep/exercise, and alarms alongside
the existing binary-fixture tests.
This commit is contained in:
James Woglom
2026-07-01 02:52:16 +00:00
parent d1d67b5042
commit 69637015e2
7 changed files with 204 additions and 50 deletions
+2 -2
View File
@@ -25,7 +25,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 ..eventparser.generic import Events_from_json
from ..eventparser.generic import Events
logger = logging.getLogger(__name__)
@@ -676,6 +676,6 @@ class TandemSourceApi:
# clockChanges (LID_TIME_CHANGED/LID_DATE_CHANGED) are not consumed by any
# processor, so they are counted for visibility but not parsed.
logger.info(f"Read {len(events)} events ({clock_change_count} clock changes skipped)")
return Events_from_json(events)
return Events(events)
+16 -18
View File
@@ -11,33 +11,31 @@ from .utils import batched
logger = logging.getLogger(__name__)
def Event(x):
# Accepts either a 26-byte binary event or a pump-logs JSON event (dict).
if isinstance(x, dict):
raw_event = RawEvent.build_from_json(x)
if not raw_event.id in EVENT_IDS:
# Log unknown events with their property keys for reverse-engineering
props = ' '.join(x['eventProperties'].keys())
logger.debug(f"UNKNOWN_JSON_EVENT | id={raw_event.id} | seqNum={raw_event.seqNum} | timestamp={raw_event.timestamp.isoformat()} | props={props}")
return raw_event
return EVENT_IDS[raw_event.id].build_from_json(x)
raw_event = RawEvent.build(x)
if not raw_event.id in EVENT_IDS:
# Log unknown events with full hex dump for reverse-engineering
hex_dump = ' '.join(f'{b:02x}' for b in x[:EVENT_LEN])
# Also log seqNum and timestamp for correlation
logger.debug(f"UNKNOWN_EVENT | id={raw_event.id} | seqNum={raw_event.seqNum} | timestamp={raw_event.timestamp.isoformat()} | bytes={hex_dump}")
return raw_event
return EVENT_IDS[raw_event.id].build(x)
Events = lambda x: (Event(bytearray(e)) for e in batched(x, EVENT_LEN))
def Events(x):
# Accepts either a raw binary event stream or an iterable of pump-logs JSON events.
if isinstance(x, (bytes, bytearray)):
return (Event(bytearray(e)) for e in batched(x, EVENT_LEN))
return (Event(e) for e in x)
def decode_raw_events(raw):
return base64.b64decode(raw)
def Event_from_json(event):
raw_event = RawEvent.build_from_json(event)
if not raw_event.id in EVENT_IDS:
# Log unknown events with their property keys for reverse-engineering
props = ' '.join(event['eventProperties'].keys())
# Also log seqNum and timestamp for correlation
logger.debug(f"UNKNOWN_JSON_EVENT | id={raw_event.id} | seqNum={raw_event.seqNum} | timestamp={raw_event.timestamp.isoformat()} | props={props}")
return raw_event
return EVENT_IDS[raw_event.id].build_from_json(event)
Events_from_json = lambda events: (Event_from_json(e) for e in events)
+21 -21
View File
@@ -2,7 +2,7 @@
import unittest
from tconnectsync.eventparser.generic import Event_from_json, Events_from_json
from tconnectsync.eventparser.generic import Event, Events
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.raw_event import RawEvent
@@ -70,46 +70,46 @@ class TestBuildFromJson(unittest.TestCase):
maxDiff = None
def test_dispatches_to_correct_class(self):
self.assertIsInstance(Event_from_json(BASAL_279), eventtypes.LidBasalDelivery)
self.assertIsInstance(Event_from_json(ALARM_5), eventtypes.LidAlarmActivated)
self.assertIsInstance(Event(BASAL_279), eventtypes.LidBasalDelivery)
self.assertIsInstance(Event(ALARM_5), eventtypes.LidAlarmActivated)
def test_plain_fields(self):
ev = Event_from_json(BASAL_279)
ev = Event(BASAL_279)
self.assertEqual(ev.commandedRate, 0)
self.assertEqual(ev.profileBasalRate, 1000)
self.assertEqual(ev.tempRate, 65535)
def test_envelope_fields(self):
ev = Event_from_json(BASAL_279)
ev = Event(BASAL_279)
self.assertEqual(ev.seqNum, 393131)
self.assertEqual(ev.eventId, 279)
def test_timestamp_preserves_wall_clock(self):
# eventTimestamp keeps pumpDateTime's wall-clock (tz forced to the
# configured TIMEZONE_NAME), so the naive portion round-trips exactly.
ev = Event_from_json(BASAL_279)
ev = Event(BASAL_279)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-04-30T00:03:29")
def test_missing_plain_field_defaults_to_none(self):
event = dict(BASAL_279)
event["eventProperties"] = {k: v for k, v in BASAL_279["eventProperties"].items() if k != "tempRate"}
ev = Event_from_json(event)
ev = Event(event)
self.assertIsNone(ev.tempRate)
self.assertEqual(ev.commandedRate, 0) # others still parse
def test_extra_keys_are_ignored(self):
event = dict(BASAL_279)
event["eventProperties"] = dict(BASAL_279["eventProperties"], someFutureField=42)
ev = Event_from_json(event) # must not raise
ev = Event(event) # must not raise
self.assertFalse(hasattr(ev, "someFutureField"))
def test_events_from_json_yields_in_order(self):
out = list(Events_from_json([BASAL_279, ALARM_5]))
out = list(Events([BASAL_279, ALARM_5]))
self.assertEqual([type(e).__name__ for e in out],
["LidBasalDelivery", "LidAlarmActivated"])
def test_unknown_eventcode_falls_back_to_rawevent(self):
ev = Event_from_json({
ev = Event({
"eventCode": 99999,
"sequenceNumber": 7,
"pumpDateTime": "2026-04-30T00:00:00",
@@ -128,21 +128,21 @@ class TestEnumAndRatioFields(unittest.TestCase):
def test_enum_resolves_from_raw_int(self):
# commandedRateSource:3 -> Algorithm
ev = Event_from_json(BASAL_279)
ev = Event(BASAL_279)
self.assertEqual(ev.commandedRateSourceRaw, 3)
self.assertEqual(ev.commandedRateSource,
eventtypes.LidBasalDelivery.CommandedratesourceEnum.Algorithm)
def test_dictionary_enum_resolves_from_raw_int(self):
# alarmId:18 -> ResumePumpAlarm (stored on the alarmidRaw attr)
ev = Event_from_json(ALARM_5)
ev = Event(ALARM_5)
self.assertEqual(ev.alarmidRaw, 18)
self.assertEqual(ev.alarmid,
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm)
def test_multiple_enums_on_one_event(self):
# requestedAction:2 -> StopSleep; previousUserMode:1 -> Sleeping
ev = Event_from_json(UMC_229)
ev = Event(UMC_229)
self.assertEqual(ev.requestedaction,
eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
self.assertEqual(ev.previoususermode,
@@ -150,13 +150,13 @@ class TestEnumAndRatioFields(unittest.TestCase):
def test_ratio_field_scales(self):
# rate:-6 -> -0.6 mg/dL/min (rateRaw ×0.1)
ev = Event_from_json(CGM_399)
ev = Event(CGM_399)
self.assertEqual(ev.rateRaw, -6)
self.assertAlmostEqual(ev.rate, -0.6)
def test_enum_zero_value_resolves(self):
# glucoseValueStatus:0 -> PreciseValue (0 must not be treated as missing)
ev = Event_from_json(CGM_399)
ev = Event(CGM_399)
self.assertEqual(ev.glucosevaluestatusRaw, 0)
self.assertEqual(ev.glucosevaluestatus,
eventtypes.LidCgmDataG7.GlucosevaluestatusEnum.PreciseValue)
@@ -169,21 +169,21 @@ class TestBitmaskFields(unittest.TestCase):
def test_single_bit_array(self):
# activeSleepSchedule:[0] -> 1<<0 == 1
ev = Event_from_json(UMC_229)
ev = Event(UMC_229)
self.assertEqual(ev.activesleepscheduleRaw, 1)
self.assertEqual(ev.activesleepschedule,
eventtypes.LidAaUserModeChange.ActivesleepscheduleBitmask.SleepSchedule1IsActive)
def test_cgm_datatype_array(self):
# cgmDataType:[0] -> 1<<0 == 1 -> Fmr
ev = Event_from_json(CGM_399)
ev = Event(CGM_399)
self.assertEqual(ev.cgmDataTypeRaw, 1)
self.assertEqual(ev.cgmDataType,
eventtypes.LidCgmDataG7.CgmdatatypeBitmask.Fmr)
def test_multi_bit_array_round_trips_to_int(self):
# egvInfoBitmask:[0,5,6,7,8,11,12] -> sum(1<<i) == 6625
ev = Event_from_json(CGM_399)
ev = Event(CGM_399)
self.assertEqual(ev.egvInfoBitmaskRaw,
sum(1 << i for i in [0, 5, 6, 7, 8, 11, 12]))
self.assertEqual(ev.egvInfoBitmaskRaw, 6625)
@@ -192,7 +192,7 @@ class TestBitmaskFields(unittest.TestCase):
# An empty array must fold to 0, not None (matches the byte path).
event = dict(UMC_229)
event["eventProperties"] = dict(UMC_229["eventProperties"], activeSleepSchedule=[])
ev = Event_from_json(event)
ev = Event(event)
self.assertEqual(ev.activesleepscheduleRaw, 0)
@@ -204,14 +204,14 @@ class TestRawFieldShims(unittest.TestCase):
def test_raw_timestamp_shim_available(self):
# process_device_status uses `sorted(events, key=lambda x: x.raw.timestamp)`,
# so adapted events must expose raw.timestamp as the wall-clock instant.
ev = Event_from_json(BASAL_279)
ev = Event(BASAL_279)
self.assertEqual(ev.raw.timestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T00:03:29")
def test_cgm_egv_timestamp_is_raw_seconds(self):
# egvTimeStamp (camelCase in the JSON) normalizes onto egvTimestamp and is
# kept as a raw seconds int; ProcessCGMReading adds TANDEM_EPOCH to it.
ev = Event_from_json(CGM_399)
ev = Event(CGM_399)
self.assertEqual(ev.egvTimestamp, 579571288)
self.assertEqual(ev.currentglucosedisplayvalue, 167)
+68 -1
View File
@@ -5,7 +5,7 @@ import arrow
from tconnectsync.sync.tandemsource.process_alarm import ProcessAlarm
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event
from tconnectsync.eventparser.generic import Event, Events
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
@@ -60,6 +60,73 @@ class TestProcessAlarm(unittest.TestCase):
# Real captured LID_ALARM_ACTIVATED (eventCode 5) events
# (deviceAssignmentId redacted).
ALARM_PUMP_RESET = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 5,
"sequenceGroup": 0,
"sequenceNumber": 2353636,
"pumpDateTime": "2024-02-26T22:44:48",
"estimatedDateTime": "2024-02-26T22:44:48Z",
"eventProperties": {"alarmId": 3, "faultLocatorData": 8230, "param1": 0, "param2": 0},
}
ALARM_EMPTY_CARTRIDGE = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 5,
"sequenceGroup": 0,
"sequenceNumber": 1124751,
"pumpDateTime": "2024-12-23T09:36:37",
"estimatedDateTime": "2024-12-23T09:36:37Z",
"eventProperties": {"alarmId": 8, "faultLocatorData": 8241, "param1": 103, "param2": 9.475377},
}
ALARM_RESUME = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 5,
"sequenceGroup": 0,
"sequenceNumber": 448136,
"pumpDateTime": "2026-05-16T00:06:00",
"estimatedDateTime": "2026-05-16T00:06:00Z",
"eventProperties": {"alarmId": 18, "faultLocatorData": 8311, "param1": 5228339, "param2": 0},
}
class TestProcessAlarmJson(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.process = ProcessAlarm(self.tconnect, self.nightscout, 'abcdef', pretend=False)
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
def test_reportable_alarms(self):
p = self.process.process(list(Events([dict(ALARM_PUMP_RESET), dict(ALARM_EMPTY_CARTRIDGE)])), None, None)
self.assertEqual(len(p), 2)
self.assertDictEqual(p[0], {
'eventType': 'Alarm',
'reason': 'PumpResetAlarm',
'notes': 'PumpResetAlarm',
'created_at': '2024-02-26 22:44:48-05:00',
'enteredBy': 'Pump (tconnectsync)',
'pump_event_id': '2353636'
})
self.assertDictEqual(p[1], {
'eventType': 'Alarm',
'reason': 'EmptyCartridgeAlarm',
'notes': 'EmptyCartridgeAlarm',
'created_at': '2024-12-23 09:36:37-05:00',
'enteredBy': 'Pump (tconnectsync)',
'pump_event_id': '1124751'
})
def test_resume_alarm_skipped(self):
p = self.process.process(list(Events([dict(ALARM_RESUME)])), None, None)
self.assertEqual(p, [])
if __name__ == '__main__':
unittest.main()
@@ -5,7 +5,7 @@ import arrow
from tconnectsync.sync.tandemsource.process_cgm_reading import ProcessCGMReading
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event, Events_from_json
from tconnectsync.eventparser.generic import Event, Events
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
@@ -421,7 +421,7 @@ class TestProcessCGMReadingFSL3(unittest.TestCase):
# Real LID_CGM_DATA_G7 pump-logs JSON events captured from a live Mobi account
# (deviceAssignmentId redacted). These exercise the production path
# (Events_from_json -> ProcessCGMReading) rather than the binary decoder.
# (Events -> ProcessCGMReading) rather than the binary decoder.
G7_JSON_1 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 484329, "pumpDateTime": "2026-05-26T00:04:12", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -3, "algorithmState": 32, "rssi": -82, "currentGlucoseDisplayValue": 347, "egvTimeStamp": 580608249, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-26T00:04:12Z"}
G7_JSON_2 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 484338, "pumpDateTime": "2026-05-26T00:09:12", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -4, "algorithmState": 32, "rssi": -84, "currentGlucoseDisplayValue": 345, "egvTimeStamp": 580608548, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-26T00:09:12Z"}
G7_JSON_3 = {"deviceAssignmentId": "00000000-0000-0000-0000-000000000000", "eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 484351, "pumpDateTime": "2026-05-26T00:14:12", "eventProperties": {"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -4, "algorithmState": 32, "rssi": -78, "currentGlucoseDisplayValue": 341, "egvTimeStamp": 580608849, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12], "interval": 0, "reservedD15": 0}, "estimatedDateTime": "2026-05-26T00:14:12Z"}
@@ -438,7 +438,7 @@ class TestProcessCGMReadingG7Json(unittest.TestCase):
self.process = ProcessCGMReading(self.tconnect, self.nightscout, 'abcdef', pretend=False, timezone='America/New_York')
def test_single_g7_json_reading(self):
events = list(Events_from_json([dict(G7_JSON_1)]))
events = list(Events([dict(G7_JSON_1)]))
self.assertEqual(type(events[0]), eventtypes.LidCgmDataG7)
self.assertEqual(events[0].currentglucosedisplayvalue, 347)
self.assertEqual(events[0].egvTimestamp, 580608249)
@@ -451,7 +451,7 @@ class TestProcessCGMReadingG7Json(unittest.TestCase):
self.assertEqual(p[0]['pump_event_id'], '484329')
def test_multiple_g7_json_readings(self):
events = list(Events_from_json([dict(G7_JSON_1), dict(G7_JSON_2), dict(G7_JSON_3)]))
events = list(Events([dict(G7_JSON_1), dict(G7_JSON_2), dict(G7_JSON_3)]))
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual([e['sgv'] for e in p], [347, 345, 341])
self.assertEqual([e['pump_event_id'] for e in p], ['484329', '484338', '484351'])
@@ -459,7 +459,7 @@ class TestProcessCGMReadingG7Json(unittest.TestCase):
def test_skips_readings_at_or_before_last_upload(self):
# Only readings strictly after the last Nightscout upload are returned.
self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: {'dateString': '2026-05-26T00:09:08-0400'}
events = list(Events_from_json([dict(G7_JSON_1), dict(G7_JSON_2), dict(G7_JSON_3)]))
events = list(Events([dict(G7_JSON_1), dict(G7_JSON_2), dict(G7_JSON_3)]))
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual([e['sgv'] for e in p], [341])
@@ -6,7 +6,7 @@ import struct
from tconnectsync.sync.tandemsource.process_device_status import ProcessDeviceStatus
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.events import UINT16
from tconnectsync.eventparser.generic import Event, Event_from_json
from tconnectsync.eventparser.generic import Event, Events
from tconnectsync.eventparser.raw_event import RawEvent
from ...api.fake import TConnectApi
@@ -155,7 +155,7 @@ class TestProcessDeviceStatus(unittest.TestCase):
# than raise. Feed a real non-daily-basal event (eventCode 16).
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
events = [Event_from_json({
events = [Event({
"eventCode": 16,
"sequenceGroup": 0,
"sequenceNumber": 100,
@@ -5,7 +5,7 @@ import arrow
from tconnectsync.sync.tandemsource.process_user_mode import ProcessUserMode
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event
from tconnectsync.eventparser.generic import Event, Events
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
@@ -277,6 +277,95 @@ class TestProcessUserModeExercise(unittest.TestCase):
# Real captured LID_AA_USER_MODE_CHANGE (eventCode 229) events
# (deviceAssignmentId redacted).
SLEEP_START = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456855,
"pumpDateTime": "2026-05-18T10:16:00",
"estimatedDateTime": "2026-05-18T10:16:00Z",
"eventProperties": {
"currentUserMode": 1, "previousUserMode": 0, "requestedAction": 1,
"spareA3": 0, "sleepStartedByGui": 1, "activeSleepSchedule": [0],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
}
SLEEP_STOP = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456952,
"pumpDateTime": "2026-05-18T10:19:55",
"estimatedDateTime": "2026-05-18T10:19:55Z",
"eventProperties": {
"currentUserMode": 0, "previousUserMode": 1, "requestedAction": 2,
"spareA3": 0, "sleepStartedByGui": 1, "activeSleepSchedule": [0],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
}
EXERCISE_START = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456961,
"pumpDateTime": "2026-05-18T10:20:04",
"estimatedDateTime": "2026-05-18T10:20:04Z",
"eventProperties": {
"currentUserMode": 2, "previousUserMode": 0, "requestedAction": 3,
"spareA3": 0, "sleepStartedByGui": 0, "activeSleepSchedule": [],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
}
EXERCISE_STOP = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456965,
"pumpDateTime": "2026-05-18T10:20:15",
"estimatedDateTime": "2026-05-18T10:20:15Z",
"eventProperties": {
"currentUserMode": 1, "previousUserMode": 2, "requestedAction": 4,
"spareA3": 0, "sleepStartedByGui": 0, "activeSleepSchedule": [0],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
}
class TestProcessUserModeJson(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.process = ProcessUserMode(self.tconnect, self.nightscout, 'abcdef', pretend=False)
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
def test_sleep_and_exercise_pairs(self):
events = list(Events([dict(SLEEP_START), dict(SLEEP_STOP), dict(EXERCISE_START), dict(EXERCISE_STOP)]))
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 2)
self.assertEqual(p[0]['eventType'], 'Sleep')
self.assertEqual(p[0]['reason'], 'Sleep (Manual)')
self.assertEqual(p[0]['pump_event_id'], '456855,456952')
self.assertEqual(p[0]['created_at'], '2026-05-18 10:16:00-04:00')
self.assertAlmostEqual(p[0]['duration'], 3.9166666666666665)
self.assertEqual(p[1]['eventType'], 'Exercise')
self.assertEqual(p[1]['pump_event_id'], '456961,456965')
self.assertEqual(p[1]['created_at'], '2026-05-18 10:20:04-04:00')
self.assertAlmostEqual(p[1]['duration'], 0.18333333333333332)
if __name__ == '__main__':
unittest.main()