Generate JSON->event adapter via the events codemod

Extend build_events.py (the codemod) so events.json stays the source of
truth: each generated event class gains build_from_json(event), mapping a
pump-logs JSON event's eventProperties onto its {field}[Raw] attrs
(matched by normalized field name), plus RawEvent.build_from_json() which
derives timestampRaw from pumpDateTime so eventTimestamp/seqNum/eventId
keep working. Add generic.Event_from_json()/Events_from_json() dispatchers
mirroring Event()/Events(); unknown eventCode -> bare RawEvent.

Enum/dictionary/ratio fields are raw ints (map directly onto {field}Raw so
existing properties resolve); bitmask fields arrive as arrays of set-bit
indices, converted to the raw int via _bitmask_arr_to_int. events.py is
regenerated; the byte-path build() is unchanged.

Tests: tests/eventparser/test_from_json.py.
This commit is contained in:
James Woglom
2026-07-01 02:04:24 +00:00
parent d2e947ffa3
commit 0779803de2
6 changed files with 854 additions and 1 deletions
+42
View File
@@ -1,6 +1,12 @@
import re
def _norm(s):
return re.sub(r'[^a-z0-9]', '', s.lower())
header = '''# THIS FILE IS AUTOGENERATED. DO NOT EDIT. header = '''# THIS FILE IS AUTOGENERATED. DO NOT EDIT.
import struct import struct
import logging import logging
import re
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, IntFlag from enum import Enum, IntFlag
from .raw_event import RawEvent, BaseEvent from .raw_event import RawEvent, BaseEvent
@@ -9,6 +15,19 @@ logger = logging.getLogger(__name__)
EVENT_LEN = 26 EVENT_LEN = 26
def _norm(s):
return re.sub(r'[^a-z0-9]', '', s.lower())
def _bitmask_arr_to_int(v):
# pump-logs bitmask fields arrive as arrays of set-bit indices; convert to the
# int the generated IntFlag / bitmask_to_list expects. Tolerate an int too.
if isinstance(v, (list, tuple)):
r = 0
for i in v:
r |= (1 << int(i))
return r
return int(v) if v is not None else 0
''' '''
TYPE_TO_STRUCT = { TYPE_TO_STRUCT = {
@@ -56,6 +75,14 @@ class {name}(BaseEvent):
{build_p2} {build_p2}
) )
@staticmethod
def build_from_json(event):
props = {{_norm(k): v for k, v in event.get("eventProperties", {{}}).items()}}
return {name}(
raw = RawEvent.build_from_json(event),
{build_json}
)
@property @property
def eventTimestamp(self): def eventTimestamp(self):
return self.raw.timestamp return self.raw.timestamp
@@ -130,6 +157,20 @@ def build_decode(event_def):
return '\n'.join([f'{" "*8}{f}' for f in p1s]), '\n'.join([f'{" "*12}{f}' for f in p2s]) return '\n'.join([f'{" "*8}{f}' for f in p1s]), '\n'.join([f'{" "*12}{f}' for f in p2s])
def build_json_kwargs(event_def):
lines = []
for name, field in event_def["data"].items():
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
attr = f'{fieldNameFormat(name)}{suffix}'
key = _norm(name)
is_bitmask = "transform" in field and any(tx[0] == 'bitmask' for tx in field["transform"])
if is_bitmask:
lines.append(f'{attr} = _bitmask_arr_to_int(props.get("{key}", 0)),')
else:
lines.append(f'{attr} = props.get("{key}", None),')
return '\n'.join([f'{" "*12}{l}' for l in lines])
def build_transform_funcs(event_def): def build_transform_funcs(event_def):
try: try:
from transforms import TRANSFORMS from transforms import TRANSFORMS
@@ -154,6 +195,7 @@ def build_event(event_id, event_def):
fields_dict = build_fields_dict(event_def), fields_dict = build_fields_dict(event_def),
build_p1 = build_decode(event_def)[0], build_p1 = build_decode(event_def)[0],
build_p2 = build_decode(event_def)[1], build_p2 = build_decode(event_def)[1],
build_json = build_json_kwargs(event_def),
transform_funcs = build_transform_funcs(event_def), transform_funcs = build_transform_funcs(event_def),
id = event_id, id = event_id,
raw_name = event_def["name"] raw_name = event_def["name"]
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -25,4 +25,19 @@ def Event(x):
Events = lambda x: (Event(bytearray(e)) for e in batched(x, EVENT_LEN)) Events = lambda x: (Event(bytearray(e)) for e in batched(x, EVENT_LEN))
def decode_raw_events(raw): def decode_raw_events(raw):
return base64.b64decode(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)
+16
View File
@@ -48,6 +48,22 @@ class RawEvent:
raw = raw raw = raw
) )
@staticmethod
def build_from_json(event):
# pump-logs JSON events carry pumpDateTime (naive local wall-clock,
# no tz). Reproduce the byte path: store timestampRaw as seconds since
# TANDEM_EPOCH parsed AS IF UTC, so the .timestamp property re-forces
# the same wall-clock into TIMEZONE_NAME. source is unused; raw bytes
# are absent on the JSON path.
timestampRaw = arrow.get(event["pumpDateTime"]).int_timestamp - TANDEM_EPOCH
return RawEvent(
source = 0,
id = event["eventCode"],
timestampRaw = timestampRaw,
seqNum = event["sequenceNumber"],
raw = b''
)
@property @property
def timestamp(self): def timestamp(self):
# Event timestamps do not have TZ data attached to them when parsed, # Event timestamps do not have TZ data attached to them when parsed,
View File
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.eventparser.generic import Event_from_json, Events_from_json
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.raw_event import RawEvent
# Trimmed real pump-log events (values from a captured account response).
BASAL_279 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 279,
"sequenceGroup": 0,
"sequenceNumber": 393131,
"pumpDateTime": "2026-04-30T00:03:29",
"eventProperties": {
"commandedRateSource": 3, "reservedA2": 0, "spareA3": 0,
"commandedRate": 0, "profileBasalRate": 1000, "algorithmRate": 0,
"tempRate": 65535,
},
"estimatedDateTime": "2026-04-30T00:03:29Z",
}
ALARM_5 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 5,
"sequenceGroup": 0,
"sequenceNumber": 500001,
"pumpDateTime": "2026-04-30T01:00:00",
"eventProperties": {"alarmId": 18, "faultLocatorData": 8311, "param1": 3993668, "param2": 0},
"estimatedDateTime": "2026-04-30T01:00:00Z",
}
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)
def test_plain_fields(self):
ev = Event_from_json(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)
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)
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)
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
self.assertFalse(hasattr(ev, "someFutureField"))
def test_events_from_json_yields_in_order(self):
out = list(Events_from_json([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({
"eventCode": 99999,
"sequenceNumber": 7,
"pumpDateTime": "2026-04-30T00:00:00",
"eventProperties": {},
})
self.assertIs(type(ev), RawEvent)
self.assertEqual(ev.eventId, 99999)
self.assertEqual(ev.seqNum, 7)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-04-30T00:00:00")
if __name__ == "__main__":
unittest.main()