From 69637015e2697914bc5c4d6f70743ed489d22071 Mon Sep 17 00:00:00 2001 From: James Woglom Date: Wed, 1 Jul 2026 02:52:16 +0000 Subject: [PATCH] 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. --- tconnectsync/api/tandemsource.py | 4 +- tconnectsync/eventparser/generic.py | 34 ++++--- tests/eventparser/test_from_json.py | 42 ++++----- tests/sync/tandemsource/test_process_alarm.py | 69 +++++++++++++- .../tandemsource/test_process_cgm_reading.py | 10 +- .../test_process_device_status.py | 4 +- .../tandemsource/test_process_user_mode.py | 91 ++++++++++++++++++- 7 files changed, 204 insertions(+), 50 deletions(-) diff --git a/tconnectsync/api/tandemsource.py b/tconnectsync/api/tandemsource.py index 15cea0d..d63e745 100644 --- a/tconnectsync/api/tandemsource.py +++ b/tconnectsync/api/tandemsource.py @@ -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) diff --git a/tconnectsync/eventparser/generic.py b/tconnectsync/eventparser/generic.py index 7bb66bc..441800f 100644 --- a/tconnectsync/eventparser/generic.py +++ b/tconnectsync/eventparser/generic.py @@ -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) \ No newline at end of file diff --git a/tests/eventparser/test_from_json.py b/tests/eventparser/test_from_json.py index fa782c0..e8ef077 100644 --- a/tests/eventparser/test_from_json.py +++ b/tests/eventparser/test_from_json.py @@ -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< 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]) diff --git a/tests/sync/tandemsource/test_process_device_status.py b/tests/sync/tandemsource/test_process_device_status.py index 9d09dbd..9e1b471 100644 --- a/tests/sync/tandemsource/test_process_device_status.py +++ b/tests/sync/tandemsource/test_process_device_status.py @@ -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, diff --git a/tests/sync/tandemsource/test_process_user_mode.py b/tests/sync/tandemsource/test_process_user_mode.py index ab4ac5f..4398cd0 100644 --- a/tests/sync/tandemsource/test_process_user_mode.py +++ b/tests/sync/tandemsource/test_process_user_mode.py @@ -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() \ No newline at end of file