Compare commits

...
6 Commits
Author SHA1 Message Date
ClaudeandJames Woglom 67731f23ff Name known alert ids in place of placeholders
Rename ALERTS_DICT id 49 from DEFAULT_ALERT_49 to
FILL_TUBING_STILL_IN_PROGRESS, matching pumpX2's AlertResponseType id
49. Regenerate the autogenerated eventparser/events.py via
build_events.py so the embedded AlertidMap/enum stays in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P93H2Avp5zLLg61TzR1ocm
2026-08-16 12:00:18 -04:00
ClaudeandJames Woglom 2b8d8ebaf8 Drop the percent-vs-voltage invariant; cover the charging phase
test_observed_charge_percent_tracks_voltage asserted that batteryChargePercent
is non-decreasing in batteryLipoMilliVolts. That holds across OBSERVED_EVENTS
only because every one of those captures is a resting/discharge sample.
Charging lifts terminal voltage well above the resting SoC curve, so a
charging sample can read a higher voltage at a lower SoC; the invariant fails
15 times in a 452-record capture from a second pump, worst case 3841 mV -> 27%
against 3840 mV -> 57%. It was a false alarm on a correct decoder, and the
per-record equality assertions already pin field alignment. Remove it.

Add RESERIALIZED_BLE_EVENTS, six records from that capture converted into
Source byte order, asserted the same way. They are kept in their own table
rather than merged into OBSERVED_EVENTS, which is Source-captured throughout.
They cover what the Source captures do not: the charging phase, the 3928 mV /
48% sample that disproves the invariant, and finalEventForDay set.

Also soften the finalEventForDay claims. Both day rollovers in that capture
are preceded by final=1, but so is one mid-afternoon record a second before
LID_PUMPING_RESUMED ended an alarm suspension, with no reset following. It
reads as a close-out marker whose usual but not only trigger is the rollover
-- not enough to pin the semantics, so pumpX2's TODO(confirm) stays open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YN9Y6PAaXaXwueiiDvt7zi
2026-08-13 19:45:57 -04:00
ClaudeandJames Woglom da58c6fc81 Drop redundant comments from the device status tests
The test names and assertions already say what these checked; only the
capture labels and the note on how to read them stay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YN9Y6PAaXaXwueiiDvt7zi
2026-08-13 19:45:57 -04:00
ClaudeandJames Woglom 196ffc2cfb Keep the original "Mobi @ ~XX%" capture labels as comments
Carry the collection-time battery annotations on the table entries in their
original wording rather than folding them into a dict key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YN9Y6PAaXaXwueiiDvt7zi
2026-08-13 19:45:57 -04:00
ClaudeandJames Woglom 2753b4da20 Parse every observed event 81 capture in the device status tests
The captures collected from real pumps were sitting in a trailing comment
block, annotated under the old (wrong) field layout, with only some of them
reachable from a skipped test. Promote all 13 to an OBSERVED_EVENTS table and
assert the full decode of each -- seqNum, timestamp, the three float32s and
the three battery fields -- so a bad offset anywhere in the record fails
loudly rather than only where a battery assertion happens to look.

The table also carries the battery level each capture was originally
annotated with. Where that disagrees with the pump's own SoC byte the byte
wins; the labels look like estimates, and their relative ordering agrees with
the decoded values.

Also asserts across the whole set that the SoC byte stays in 0-100, the
voltage stays in 1S LiPo range, and that percent is non-decreasing in
voltage -- corroboration that the two fields are aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YN9Y6PAaXaXwueiiDvt7zi
2026-08-13 19:45:57 -04:00
ClaudeandJames Woglom 97ad0e7629 Fix LID_DAILY_BASAL trailing field offsets
Event 81's last 4 bytes are a single packed uint32:

    (batteryLipoMilliVolts << 16) | (batteryChargePercent << 8) | finalEventForDay

Tandem Source serializes scalars big-endian, so on the wire those bytes land
as [lipo_hi, lipo_lo, percent, final] at absolute offsets 22-25. The schema
instead used the offsets from pumpX2's Java/Swift ports, which decode the same
u32 out of a little-endian BLE stream — correct there, misaligned here. The
result was that every field after iob was wrong: the existing test fixture
decoded to 14080 mV, an impossible voltage for a 1S LiPo.

Re-key the schema to lipo@12 (uint16), percent@14, final@15 (relative offsets)
and regenerate events.py. The same fixture now yields 3830 mV / 55% / 0, and
the one previously annotated "Mobi @ MAX seen" yields 4176 mV / 100% with
finalEventForDay set, at 23:58 pump-local.

batteryChargePercent is now the pump's own state-of-charge byte rather than
(mV - 3584)/768, the linear voltage->SoC approximation the removed
battery_charge_percent transform applied to the two millivolt bytes. It is
already scaled 0-100, so the sync layer drops its *100 factor.

finalEventForDay is decoded but deliberately unused; nothing in the sync layer
acts on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YN9Y6PAaXaXwueiiDvt7zi
2026-08-13 19:45:57 -04:00
7 changed files with 437 additions and 164 deletions
+11 -10
View File
@@ -18,18 +18,19 @@
"offset": 8,
"uom": "units"
},
"batteryChargePercentMSBRaw": {
"type": "uint8",
"offset": 12
},
"batteryChargePercentLSBRaw": {
"type": "uint8",
"offset": 13,
"transform": [["battery_charge_percent", ""]]
},
"batteryLipoMilliVolts": {
"type": "uint16",
"offset": 14
"offset": 12,
"uom": "millivolts"
},
"batteryChargePercent": {
"type": "uint8",
"offset": 14,
"uom": "percent"
},
"finalEventForDay": {
"type": "uint8",
"offset": 15
}
}
},
+16 -19
View File
@@ -189,7 +189,7 @@ class LidAlertActivated(BaseEvent):
"46": "TRANSMITTER_EXPIRING_ALERT3",
"47": "DEFAULT_ALERT_47",
"48": "CGM_UNAVAILABLE",
"49": "DEFAULT_ALERT_49",
"49": "FILL_TUBING_STILL_IN_PROGRESS",
"50": "DEFAULT_ALERT_50",
"51": "CONTROL_IQ_LOW",
"52": "DEFAULT_ALERT_52",
@@ -255,7 +255,7 @@ class LidAlertActivated(BaseEvent):
TransmitterExpiringAlert3 = 46
DefaultAlert47 = 47
CgmUnavailable = 48
DefaultAlert49 = 49
FillTubingStillInProgress = 49
DefaultAlert50 = 50
ControlIqLow = 51
DefaultAlert52 = 52
@@ -1265,7 +1265,7 @@ class LidAlertCleared(BaseEvent):
"46": "TRANSMITTER_EXPIRING_ALERT3",
"47": "DEFAULT_ALERT_47",
"48": "CGM_UNAVAILABLE",
"49": "DEFAULT_ALERT_49",
"49": "FILL_TUBING_STILL_IN_PROGRESS",
"50": "DEFAULT_ALERT_50",
"51": "CONTROL_IQ_LOW",
"52": "DEFAULT_ALERT_52",
@@ -1331,7 +1331,7 @@ class LidAlertCleared(BaseEvent):
TransmitterExpiringAlert3 = 46
DefaultAlert47 = 47
CgmUnavailable = 48
DefaultAlert49 = 49
FillTubingStillInProgress = 49
DefaultAlert50 = 50
ControlIqLow = 51
DefaultAlert52 = 52
@@ -6209,31 +6209,28 @@ class LidDailyBasal(BaseEvent):
dailyTotalBasal: float # units
lastBasalRate: float # units/hour
iob: float # units
batteryChargePercentMSBRaw: int
batteryChargePercentLSBRaw: int
batteryLipoMilliVolts: int
batteryLipoMilliVolts: int # millivolts
batteryChargePercent: int # percent
finalEventForDay: int
@property
def batteryChargePercent(self):
return (256*(self.batteryChargePercentMSBRaw-14)+self.batteryChargePercentLSBRaw)/(3*256)
@staticmethod
def build(raw):
dailyTotalBasal, = struct.unpack_from(FLOAT32, raw[:EVENT_LEN], 10)
lastBasalRate, = struct.unpack_from(FLOAT32, raw[:EVENT_LEN], 14)
iob, = struct.unpack_from(FLOAT32, raw[:EVENT_LEN], 18)
batteryChargePercentMSBRaw, = struct.unpack_from(UINT8, raw[:EVENT_LEN], 22)
batteryChargePercentLSBRaw, = struct.unpack_from(UINT8, raw[:EVENT_LEN], 23)
batteryLipoMilliVolts, = struct.unpack_from(UINT16, raw[:EVENT_LEN], 24)
batteryLipoMilliVolts, = struct.unpack_from(UINT16, raw[:EVENT_LEN], 22)
batteryChargePercent, = struct.unpack_from(UINT8, raw[:EVENT_LEN], 24)
finalEventForDay, = struct.unpack_from(UINT8, raw[:EVENT_LEN], 25)
return LidDailyBasal(
raw = RawEvent.build(raw),
dailyTotalBasal = dailyTotalBasal,
lastBasalRate = lastBasalRate,
iob = iob,
batteryChargePercentMSBRaw = batteryChargePercentMSBRaw,
batteryChargePercentLSBRaw = batteryChargePercentLSBRaw,
batteryLipoMilliVolts = batteryLipoMilliVolts,
batteryChargePercent = batteryChargePercent,
finalEventForDay = finalEventForDay,
)
@staticmethod
@@ -6244,9 +6241,9 @@ class LidDailyBasal(BaseEvent):
dailyTotalBasal = props.get("dailytotalbasal", None),
lastBasalRate = props.get("lastbasalrate", None),
iob = props.get("iob", None),
batteryChargePercentMSBRaw = props.get("batterychargepercentmsbraw", None),
batteryChargePercentLSBRaw = props.get("batterychargepercentlsbraw", None),
batteryLipoMilliVolts = props.get("batterylipomillivolts", None),
batteryChargePercent = props.get("batterychargepercent", None),
finalEventForDay = props.get("finaleventforday", None),
)
@property
@@ -6270,9 +6267,9 @@ class LidDailyBasal(BaseEvent):
dailyTotalBasal=self.dailyTotalBasal,
lastBasalRate=self.lastBasalRate,
iob=self.iob,
batteryChargePercentMSBRaw=self.batteryChargePercentMSBRaw,
batteryChargePercentLSBRaw=self.batteryChargePercentLSBRaw,
batteryLipoMilliVolts=self.batteryLipoMilliVolts,
batteryChargePercent=self.batteryChargePercent,
finalEventForDay=self.finalEventForDay,
)
+1 -1
View File
@@ -48,7 +48,7 @@ ALERTS_DICT = {
"46": "TRANSMITTER_EXPIRING_ALERT3",
"47": "DEFAULT_ALERT_47",
"48": "CGM_UNAVAILABLE",
"49": "DEFAULT_ALERT_49",
"49": "FILL_TUBING_STILL_IN_PROGRESS",
"50": "DEFAULT_ALERT_50",
"51": "CONTROL_IQ_LOW",
"52": "DEFAULT_ALERT_52",
-13
View File
@@ -137,22 +137,9 @@ def transform_ratio(event_def, name, name_fmt, field, tx):
return out
def transform_battery_charge_percent(event_def, name, name_fmt, field, tx):
out = []
out += [
'@property',
f'def batteryChargePercent(self):',
f' return (256*(self.batteryChargePercentMSBRaw-14)+self.batteryChargePercentLSBRaw)/(3*256)',
''
]
return out
TRANSFORMS = {
'enum': transform_enum,
'dictionary': transform_dictionary,
'bitmask': transform_bitmask,
'ratio': transform_ratio,
'battery_charge_percent': transform_battery_charge_percent
}
@@ -69,17 +69,17 @@ class ProcessDeviceStatus:
# yields nothing on the new API; this path stays for the binary decoder
# and in case the endpoint starts returning event 81.
#
# The battery percent is derived from the msb/lsb raw fields; if the
# event arrived without them (an event shape we can't yet parse), skip
# it rather than raise on the arithmetic below.
if event.batteryChargePercentMSBRaw is None or event.batteryChargePercentLSBRaw is None:
# batteryChargePercent is the pump's own state-of-charge byte, already
# scaled 0-100; if the event arrived without it (an event shape we
# can't yet parse), skip it rather than emit a bogus device status.
if event.batteryChargePercent is None:
logger.warning("ProcessDeviceStatus: skipping daily basal event missing battery data: %s" % event)
return None
return NightscoutEntry.devicestatus(
created_at=event.eventTimestamp.format(),
batteryVoltage=(float(event.batteryLipoMilliVolts or 0)/1000),
batteryPercent=int(100*event.batteryChargePercent),
batteryPercent=int(event.batteryChargePercent),
pump_event_id = "%s" % event.seqNum
)
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
import json
import unittest
from tconnectsync.eventparser.generic import Event
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.raw_event import RawEvent
class TestLidDailyBasal(unittest.TestCase):
"""81: LID_DAILY_BASAL. Real captured binary events.
The trailing 4 bytes are one packed uint32:
(batteryLipoMilliVolts << 16) | (batteryChargePercent << 8) | finalEventForDay
Tandem Source serializes scalars big-endian, so on the wire those bytes are
[lipo_hi, lipo_lo, percent, final] at absolute offsets 22, 23, 24, 25.
pumpX2's BLE stream packs the same u32 little-endian, which is why its
Java/Swift ports read the three fields in the opposite order.
finalEventForDay is a boolean close-out marker whose usual -- but not only
-- trigger is the daily rollover: in a 452-record capture it precedes both
day resets, and is also set once mid-afternoon, a second before
LID_PUMPING_RESUMED ended an alarm suspension, with no reset following. Not
enough to pin the semantics, so pumpX2's TODO(confirm) on the field stays
open. The generator has no bool type, so it surfaces as an int here where
the Java/Swift ports expose a bool.
"""
maxDiff = None
def setUp(self):
self.fixtureMidCharge = b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'
self.fixtureMidChargeLater = b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00'
self.fixtureFinalEventForDay = self.fixtureMidCharge[:25] + b'\x01'
self.fixtureFullCharge = self.fixtureMidCharge[:24] + b'\x64' + self.fixtureMidCharge[25:]
def test_dispatches_to_liddailybasal(self):
ev = Event(self.fixtureMidCharge)
self.assertIsInstance(ev, eventtypes.LidDailyBasal)
self.assertIsNot(type(ev), RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureMidCharge)
self.assertEqual(ev.eventId, 81)
self.assertEqual(ev.seqNum, 1046436)
self.assertEqual(ev.raw.timestampRaw, 534123623)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureMidCharge)
self.assertEqual(
ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2024-12-03T23:40:23",
)
def test_leading_float_fields(self):
ev = Event(self.fixtureMidCharge)
self.assertAlmostEqual(ev.dailyTotalBasal, 22.3535, places=4)
self.assertAlmostEqual(ev.lastBasalRate, 0.8, places=4)
self.assertAlmostEqual(ev.iob, 3.9823, places=4)
def test_battery_fields_mid_charge(self):
ev = Event(self.fixtureMidCharge)
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
self.assertEqual(ev.batteryChargePercent, 55)
self.assertEqual(ev.finalEventForDay, 0)
def test_battery_fields_mid_charge_later(self):
ev = Event(self.fixtureMidChargeLater)
self.assertEqual(ev.seqNum, 1046875)
self.assertEqual(ev.batteryLipoMilliVolts, 3827)
self.assertEqual(ev.batteryChargePercent, 54)
self.assertEqual(ev.finalEventForDay, 0)
def test_lipo_millivolts_is_a_plausible_single_cell_voltage(self):
for raw in (self.fixtureMidCharge, self.fixtureMidChargeLater):
with self.subTest(raw=raw):
ev = Event(raw)
self.assertGreater(ev.batteryLipoMilliVolts, 3000)
self.assertLess(ev.batteryLipoMilliVolts, 4400)
def test_final_event_for_day_set(self):
ev = Event(self.fixtureFinalEventForDay)
self.assertEqual(ev.finalEventForDay, 1)
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
self.assertEqual(ev.batteryChargePercent, 55)
def test_battery_charge_percent_is_a_direct_percentage(self):
ev = Event(self.fixtureFullCharge)
self.assertEqual(ev.batteryChargePercent, 100)
self.assertIsInstance(ev.batteryChargePercent, int)
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
self.assertEqual(ev.finalEventForDay, 0)
def test_build_from_json(self):
ev = Event({
"eventCode": 81,
"sequenceGroup": 0,
"sequenceNumber": 1046436,
"pumpDateTime": "2024-12-03T23:40:23",
"eventProperties": {
"dailyTotalBasal": 22.3535,
"lastBasalRate": 0.8,
"iob": 3.9823,
"batteryLipoMilliVolts": 3830,
"batteryChargePercent": 55,
"finalEventForDay": 0,
},
})
self.assertIsInstance(ev, eventtypes.LidDailyBasal)
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
self.assertEqual(ev.batteryChargePercent, 55)
self.assertEqual(ev.finalEventForDay, 0)
def test_todict_is_json_serializable(self):
ev = Event(self.fixtureMidCharge)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 81)
self.assertEqual(d["name"], "LID_DAILY_BASAL")
self.assertEqual(d["seqNum"], 1046436)
self.assertEqual(d["batteryLipoMilliVolts"], 3830)
self.assertEqual(d["batteryChargePercent"], 55)
self.assertEqual(d["finalEventForDay"], 0)
self.assertNotIn("batteryChargePercentMSBRaw", d)
self.assertNotIn("batteryChargePercentLSBRaw", d)
if __name__ == "__main__":
unittest.main()
@@ -1,17 +1,179 @@
#!/usr/bin/env python3
import unittest
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, Events
from tconnectsync.eventparser.raw_event import RawEvent
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
# Every event 81 capture observed from a real pump, with its expected decode.
# The "Mobi @" labels are collection-time estimates; the pump's SoC byte wins.
OBSERVED_EVENTS = [
{
'raw': b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00',
'seqNum': 1046436, 'timestampRaw': 534123623,
'timestamp': '2024-12-03 23:40:23-05:00',
'dailyTotalBasal': 22.3535, 'lastBasalRate': 0.8, 'iob': 3.9823,
'batteryLipoMilliVolts': 3830, 'batteryChargePercent': 55, 'finalEventForDay': 0,
},
{
'raw': b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00',
'seqNum': 1046875, 'timestampRaw': 534133823,
'timestamp': '2024-12-04 02:30:23-05:00',
'dailyTotalBasal': 2.2157, 'lastBasalRate': 1.216, 'iob': 1.7781,
'batteryLipoMilliVolts': 3827, 'batteryChargePercent': 54, 'finalEventForDay': 0,
},
{
'raw': b'\x00Q\x1f\xd6k\x1f\x00\x0f\xfc>A\x0e\xa3\x80?\x9a~\xfa@\x11z6\x0e\xee5\x00',
'seqNum': 1047614, 'timestampRaw': 534145823,
'timestamp': '2024-12-04 05:50:23-05:00',
'dailyTotalBasal': 8.9149, 'lastBasalRate': 1.207, 'iob': 2.2731,
'batteryLipoMilliVolts': 3822, 'batteryChargePercent': 53, 'finalEventForDay': 0,
},
{
# Mobi @ ~80%
'raw': b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00',
'seqNum': 1104, 'timestampRaw': 536702453,
'timestamp': '2025-01-02 20:00:53-05:00',
'dailyTotalBasal': 2.8227, 'lastBasalRate': 0.8, 'iob': 2.6851,
'batteryLipoMilliVolts': 4001, 'batteryChargePercent': 80, 'finalEventForDay': 0,
},
{
# Mobi @ ~55%
'raw': b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00',
'seqNum': 17841, 'timestampRaw': 537138653,
'timestamp': '2025-01-07 21:10:53-05:00',
'dailyTotalBasal': 16.8598, 'lastBasalRate': 0.0, 'iob': 11.6139,
'batteryLipoMilliVolts': 3869, 'batteryChargePercent': 61, 'finalEventForDay': 0,
},
{
# Mobi @ ~45%
'raw': b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00',
'seqNum': 19550, 'timestampRaw': 537188454,
'timestamp': '2025-01-08 11:00:54-05:00',
'dailyTotalBasal': 13.9232, 'lastBasalRate': 0.176, 'iob': 0.0,
'batteryLipoMilliVolts': 3834, 'batteryChargePercent': 55, 'finalEventForDay': 0,
},
{
# Mobi @ ~15%
'raw': b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00',
'seqNum': 22651, 'timestampRaw': 537273173,
'timestamp': '2025-01-09 10:32:53-05:00',
'dailyTotalBasal': 8.6206, 'lastBasalRate': 0.537, 'iob': 0.0274,
'batteryLipoMilliVolts': 3765, 'batteryChargePercent': 32, 'finalEventForDay': 0,
},
{
# Mobi @ MAX seen
'raw': b'\x00Q \x19U=\x00\x01\x0f\xa8A\xa5\x04V?L\xcc\xcd?s\x83b\x10Pd\x01',
'seqNum': 69544, 'timestampRaw': 538531133,
'timestamp': '2025-01-23 23:58:53-05:00',
'dailyTotalBasal': 20.6271, 'lastBasalRate': 0.8, 'iob': 0.9512,
'batteryLipoMilliVolts': 4176, 'batteryChargePercent': 100, 'finalEventForDay': 1,
},
{
# Mobi @ ~20%
'raw': b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00',
'seqNum': 80250, 'timestampRaw': 538819493,
'timestamp': '2025-01-27 08:04:53-05:00',
'dailyTotalBasal': 9.8827, 'lastBasalRate': 2.585, 'iob': 0.3392,
'batteryLipoMilliVolts': 3777, 'batteryChargePercent': 36, 'finalEventForDay': 0,
},
{
# Mobi @ ~10%
'raw': b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00',
'seqNum': 81789, 'timestampRaw': 538860653,
'timestamp': '2025-01-27 19:30:53-05:00',
'dailyTotalBasal': 20.1106, 'lastBasalRate': 0.8, 'iob': 1.7103,
'batteryLipoMilliVolts': 3745, 'batteryChargePercent': 27, 'finalEventForDay': 0,
},
{
# Mobi @ ~10%
'raw': b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00',
'seqNum': 81970, 'timestampRaw': 538864254,
'timestamp': '2025-01-27 20:30:54-05:00',
'dailyTotalBasal': 20.7106, 'lastBasalRate': 0.0, 'iob': 4.3862,
'batteryLipoMilliVolts': 3743, 'batteryChargePercent': 26, 'finalEventForDay': 0,
},
{
# Mobi @ ~5%
'raw': b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00',
'seqNum': 82714, 'timestampRaw': 538878653,
'timestamp': '2025-01-28 00:30:53-05:00',
'dailyTotalBasal': 1.6973, 'lastBasalRate': 3.208, 'iob': 3.8356,
'batteryLipoMilliVolts': 3720, 'batteryChargePercent': 21, 'finalEventForDay': 0,
},
{
# Mobi @ ~5%
'raw': b'\x00Q \x1e\xb0\xcd\x00\x01C\xd3@L9W@#33@\x8b\x04\xd2\x0e\x88\x15\x00',
'seqNum': 82899, 'timestampRaw': 538882253,
'timestamp': '2025-01-28 01:30:53-05:00',
'dailyTotalBasal': 3.191, 'lastBasalRate': 2.55, 'iob': 4.3443,
'batteryLipoMilliVolts': 3720, 'batteryChargePercent': 21, 'finalEventForDay': 0,
},
]
# Captured over BLE from a second pump and re-serialized into Source byte order,
# so they are not "observed from a real pump" in the sense the table above is.
# They cover what the Source captures do not: the charging phase, and
# finalEventForDay set. They are also what shows percent to be non-monotonic in
# voltage -- charging lifts terminal voltage well above the resting SoC curve,
# so a charging sample can read a higher voltage at a lower SoC than a resting
# one. Do not assert a global percent-vs-voltage correlation over these.
RESERIALIZED_BLE_EVENTS = [
# Lowest SoC observed; the pump alarm-suspended for low battery 41 s later.
{
'raw': b'\x10Q#\x01\xb8\xe0\x00\x0b[o@\xe2\x93x=\xcc\xcc\xcd@P\xd6C\x0e\x82\x14\x00',
'seqNum': 744303, 'timestampRaw': 587315424,
'timestamp': '2026-08-11 15:10:24-04:00',
'dailyTotalBasal': 7.0805, 'lastBasalRate': 0.1, 'iob': 3.2631,
'batteryLipoMilliVolts': 3714, 'batteryChargePercent': 20, 'finalEventForDay': 0,
},
# finalEventForDay set mid-afternoon, 1 s before PumpingResumed, no daily reset.
{
'raw': b'\x10Q#\x01\xba\xc5\x00\x0b[\x89@\xe2\x93x\x00\x00\x00\x00@;\x81\xed\x0e\xf0\x18\x01',
'seqNum': 744329, 'timestampRaw': 587315909,
'timestamp': '2026-08-11 15:18:29-04:00',
'dailyTotalBasal': 7.0805, 'lastBasalRate': 0.0, 'iob': 2.9298,
'batteryLipoMilliVolts': 3824, 'batteryChargePercent': 24, 'finalEventForDay': 1,
},
# Charging: higher voltage, lower SoC than the 3900 mV / 55% resting sample.
{
'raw': b'\x10Q#\x01\xbd\x85\x00\x0b[\xb1@\xe7\x1c\x02?\x80\x00\x00@\x1eho\x0fX0\x00',
'seqNum': 744369, 'timestampRaw': 587316613,
'timestamp': '2026-08-11 15:30:13-04:00',
'dailyTotalBasal': 7.2222, 'lastBasalRate': 1.0, 'iob': 2.4751,
'batteryLipoMilliVolts': 3928, 'batteryChargePercent': 48, 'finalEventForDay': 0,
},
{
'raw': b'\x10Q#\x02\xd6\xc5\x00\x0bl\xf4@\xf6e\x08\x00\x00\x00\x00?\xa9eL\x10"W\x00',
'seqNum': 748788, 'timestampRaw': 587388613,
'timestamp': '2026-08-12 11:30:13-04:00',
'dailyTotalBasal': 7.6998, 'lastBasalRate': 0.0, 'iob': 1.3234,
'batteryLipoMilliVolts': 4130, 'batteryChargePercent': 87, 'finalEventForDay': 0,
},
# Day rollover: the next record, 00:00:13, has dailyTotalBasal 0.0.
{
'raw': b'\x10Q#\x024\x95\x00\x0bbfAIP\x93@ \x00\x00@\xb6\xb7\x17\x0f\x1a=\x01',
'seqNum': 746086, 'timestampRaw': 587347093,
'timestamp': '2026-08-11 23:58:13-04:00',
'dailyTotalBasal': 12.5822, 'lastBasalRate': 2.5, 'iob': 5.7098,
'batteryLipoMilliVolts': 3866, 'batteryChargePercent': 61, 'finalEventForDay': 1,
},
# The second rollover; three consecutive records carry final=1 here.
{
'raw': b'\x10Q#\x00\xe3 \x00\x0bOX@\xfabP\x00\x00\x00\x00AX\x99\xaa\x0e\xd3+\x01',
'seqNum': 741208, 'timestampRaw': 587260704,
'timestamp': '2026-08-10 23:58:24-04:00',
'dailyTotalBasal': 7.8245, 'lastBasalRate': 0.0, 'iob': 13.5375,
'batteryLipoMilliVolts': 3795, 'batteryChargePercent': 43, 'finalEventForDay': 1,
},
]
class TestProcessDeviceStatus(unittest.TestCase):
maxDiff = None
@@ -29,9 +191,9 @@ class TestProcessDeviceStatus(unittest.TestCase):
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].batteryChargePercentMSBRaw, 14)
self.assertEqual(events[0].batteryChargePercentLSBRaw, 246)
self.assertEqual(events[0].batteryLipoMilliVolts, 14080)
self.assertEqual(events[0].batteryLipoMilliVolts, 3830)
self.assertEqual(events[0].batteryChargePercent, 55)
self.assertEqual(events[0].finalEventForDay, 0)
p = self.process.process(events, time_start=None, time_end=None)
@@ -42,9 +204,9 @@ class TestProcessDeviceStatus(unittest.TestCase):
'pump': {
'clock': '2024-12-03 23:40:23-05:00',
'battery': {
'status': '32%',
'percent': 32,
'voltage': 14.08
'status': '55%',
'percent': 55,
'voltage': 3.83
}
},
'pump_event_id': '1046436'
@@ -58,9 +220,9 @@ class TestProcessDeviceStatus(unittest.TestCase):
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].batteryChargePercentMSBRaw, 14)
self.assertEqual(events[0].batteryChargePercentLSBRaw, 246)
self.assertEqual(events[0].batteryLipoMilliVolts, 14080)
self.assertEqual(events[0].batteryLipoMilliVolts, 3830)
self.assertEqual(events[0].batteryChargePercent, 55)
self.assertEqual(events[0].finalEventForDay, 0)
p = self.process.process(events, time_start=None, time_end=None)
@@ -77,15 +239,15 @@ class TestProcessDeviceStatus(unittest.TestCase):
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
self.assertEqual(events[0].batteryChargePercentMSBRaw, 14)
self.assertEqual(events[0].batteryChargePercentLSBRaw, 246)
self.assertEqual(events[0].batteryLipoMilliVolts, 14080)
self.assertEqual(events[0].batteryLipoMilliVolts, 3830)
self.assertEqual(events[0].batteryChargePercent, 55)
self.assertEqual(events[0].finalEventForDay, 0)
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
self.assertEqual(events[1].batteryChargePercentMSBRaw, 14)
self.assertEqual(events[1].batteryChargePercentLSBRaw, 243)
self.assertEqual(events[1].batteryLipoMilliVolts, 13824)
self.assertEqual(events[1].batteryLipoMilliVolts, 3827)
self.assertEqual(events[1].batteryChargePercent, 54)
self.assertEqual(events[1].finalEventForDay, 0)
p = self.process.process(events, time_start=None, time_end=None)
@@ -96,9 +258,9 @@ class TestProcessDeviceStatus(unittest.TestCase):
'pump': {
'clock': '2024-12-04 02:30:23-05:00',
'battery': {
'status': '31%',
'percent': 31,
'voltage': 13.824
'status': '54%',
'percent': 54,
'voltage': 3.827
}
},
'pump_event_id': '1046875'
@@ -115,21 +277,21 @@ class TestProcessDeviceStatus(unittest.TestCase):
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
self.assertEqual(events[0].batteryChargePercentMSBRaw, 14)
self.assertEqual(events[0].batteryChargePercentLSBRaw, 246)
self.assertEqual(events[0].batteryLipoMilliVolts, 14080)
self.assertEqual(events[0].batteryLipoMilliVolts, 3830)
self.assertEqual(events[0].batteryChargePercent, 55)
self.assertEqual(events[0].finalEventForDay, 0)
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
self.assertEqual(events[1].batteryChargePercentMSBRaw, 14)
self.assertEqual(events[1].batteryChargePercentLSBRaw, 243)
self.assertEqual(events[1].batteryLipoMilliVolts, 13824)
self.assertEqual(events[1].batteryLipoMilliVolts, 3827)
self.assertEqual(events[1].batteryChargePercent, 54)
self.assertEqual(events[1].finalEventForDay, 0)
self.assertEqual(type(events[2]), eventtypes.LidDailyBasal)
self.assertEqual(events[2].raw.timestampRaw, 534145823) # 2024-12-04 05:50:23-05:00
self.assertEqual(events[2].batteryChargePercentMSBRaw, 14)
self.assertEqual(events[2].batteryChargePercentLSBRaw, 238)
self.assertEqual(events[2].batteryLipoMilliVolts, 13568)
self.assertEqual(events[2].batteryLipoMilliVolts, 3822)
self.assertEqual(events[2].batteryChargePercent, 53)
self.assertEqual(events[2].finalEventForDay, 0)
p = self.process.process(events, time_start=None, time_end=None)
@@ -140,9 +302,9 @@ class TestProcessDeviceStatus(unittest.TestCase):
'pump': {
'clock': '2024-12-04 05:50:23-05:00',
'battery': {
'status': '30%',
'percent': 30,
'voltage': 13.568
'status': '53%',
'percent': 53,
'voltage': 3.822
}
},
'pump_event_id': '1047614'
@@ -181,102 +343,99 @@ class TestProcessDeviceStatus(unittest.TestCase):
dailyTotalBasal=None,
lastBasalRate=None,
iob=None,
batteryChargePercentMSBRaw=None,
batteryChargePercentLSBRaw=None,
batteryLipoMilliVolts=None,
batteryChargePercent=None,
finalEventForDay=None,
)
p = self.process.process([event], time_start=None, time_end=None)
self.assertEqual(p, [])
@unittest.skip
def test_device_status_battery_calculation(self):
def test_all_observed_events_parse(self):
for expected in OBSERVED_EVENTS:
with self.subTest(seqNum=expected['seqNum']):
event = Event(expected['raw'])
self.assertEqual(type(event), eventtypes.LidDailyBasal)
self.assertEqual(event.eventId, 81)
self.assertEqual(event.seqNum, expected['seqNum'])
self.assertEqual(event.raw.timestampRaw, expected['timestampRaw'])
self.assertEqual(event.eventTimestamp.format(), expected['timestamp'])
self.assertAlmostEqual(event.dailyTotalBasal, expected['dailyTotalBasal'], places=4)
self.assertAlmostEqual(event.lastBasalRate, expected['lastBasalRate'], places=4)
self.assertAlmostEqual(event.iob, expected['iob'], places=4)
self.assertEqual(event.batteryLipoMilliVolts, expected['batteryLipoMilliVolts'])
self.assertEqual(event.batteryChargePercent, expected['batteryChargePercent'])
self.assertEqual(event.finalEventForDay, expected['finalEventForDay'])
def test_all_observed_events_have_sane_battery_fields(self):
for expected in OBSERVED_EVENTS:
with self.subTest(seqNum=expected['seqNum']):
event = Event(expected['raw'])
self.assertIsInstance(event.batteryChargePercent, int)
self.assertGreaterEqual(event.batteryChargePercent, 0)
self.assertLessEqual(event.batteryChargePercent, 100)
self.assertGreater(event.batteryLipoMilliVolts, 3000)
self.assertLess(event.batteryLipoMilliVolts, 4400)
self.assertIn(event.finalEventForDay, (0, 1))
def test_all_reserialized_ble_events_parse(self):
for expected in RESERIALIZED_BLE_EVENTS:
with self.subTest(seqNum=expected['seqNum']):
event = Event(expected['raw'])
self.assertEqual(type(event), eventtypes.LidDailyBasal)
self.assertEqual(event.eventId, 81)
self.assertEqual(event.seqNum, expected['seqNum'])
self.assertEqual(event.raw.timestampRaw, expected['timestampRaw'])
self.assertEqual(event.eventTimestamp.format(), expected['timestamp'])
self.assertAlmostEqual(event.dailyTotalBasal, expected['dailyTotalBasal'], places=4)
self.assertAlmostEqual(event.lastBasalRate, expected['lastBasalRate'], places=4)
self.assertAlmostEqual(event.iob, expected['iob'], places=4)
self.assertEqual(event.batteryLipoMilliVolts, expected['batteryLipoMilliVolts'])
self.assertEqual(event.batteryChargePercent, expected['batteryChargePercent'])
self.assertEqual(event.finalEventForDay, expected['finalEventForDay'])
def test_observed_events_upload_expected_device_status(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
def test_percentage_about(percent_str, event):
with self.subTest(percent_str, event=event):
# p = self.process.process([event], time_start=None, time_end=None)
# self.assertEqual(len(p), 1)
# self.assertEqual(p[0]['pump']['battery']['status'], percent_str)
msb = event.batteryChargePercentMSBRaw
lsb = event.batteryChargePercentLSBRaw
for expected in OBSERVED_EVENTS:
with self.subTest(seqNum=expected['seqNum']):
p = self.process.process([Event(expected['raw'])], time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertEqual(p[0]['created_at'], expected['timestamp'])
self.assertEqual(p[0]['pump_event_id'], str(expected['seqNum']))
self.assertEqual(p[0]['pump']['battery'], {
'status': '%d%%' % expected['batteryChargePercent'],
'percent': expected['batteryChargePercent'],
'voltage': expected['batteryLipoMilliVolts'] / 1000,
})
MAX = struct.unpack(UINT16, bytearray((16, 128)))[0]
MIN = struct.unpack(UINT16, bytearray((14, 128)))[0]
val = struct.unpack(UINT16, bytearray((msb, lsb)))[0]
pct = (val - MIN)/(MAX-MIN)
# print(pct)
# print(struct.unpack(UINT16, bytearray((msb, lsb))
# (14, 100) =~ 0%
# (15, 100) =~ 50%
# (16, 98) =~ 100%
# msb*256 + lsb - 14*256
# pct = (msb*256 + lsb - 14*256 - 100) / 512
def test_final_event_for_day_is_decoded_but_not_acted_on(self):
# A close-out marker, usually but not only the daily rollover: one
# capture sets it mid-afternoon, a second before PumpingResumed ends an
# alarm suspension, with no daily reset. Either way nothing here acts
# on it, so the device status is the same as for any other record.
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
calc_str = "%.0f%s" % (100*pct, '%')
print(f'comp {calc_str=} {percent_str=}')
self.assertEqual(calc_str, percent_str)
event = Event(b'\x00Q \x19U=\x00\x01\x0f\xa8A\xa5\x04V?L\xcc\xcd?s\x83b\x10Pd\x01')
self.assertEqual(event.finalEventForDay, 1)
test_percentage_about('80%', Event(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'))
test_percentage_about('55%', Event(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00'))
test_percentage_about('45%', Event(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00'))
test_percentage_about('15%', Event(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00'))
test_percentage_about('20%', Event(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00'))
test_percentage_about('10%', Event(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00'))
test_percentage_about('10%', Event(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00'))
test_percentage_about('5%', Event(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00'))
# Mobi @ MAX seen
# bytearray(b'\x00Q \x19U=\x00\x01\x0f\xa8A\xa5\x04V?L\xcc\xcd?s\x83b\x10Pd\x01')
# batteryChargePercentMSBRaw=16, batteryChargePercentLSBRaw=80, batteryLipoMilliVolts=25601
# Mobi @ ~80%
# bytearray(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'):
# batteryChargePercentMSBRaw=15, batteryChargePercentLSBRaw=161, batteryLipoMilliVolts=20480
# calc batteryChargePercent = 0.54296875
# Mobi @ ~55%
# bytearray(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00')
# batteryChargePercentMSBRaw=15, batteryChargePercentLSBRaw=29, batteryLipoMilliVolts=15616
# calc batteryChargePercent = 0.37109375
# Mobi @ ~45%
# bytearray(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00')
# batteryChargePercentMSBRaw=14, batteryChargePercentLSBRaw=250 batteryLipoMilliVolts=14080
# calc batteryChargePercent = 0.3255208333333333
# Mobi @ ~15%
# bytearray(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00')
# batteryChargePercentMSBRaw=14, batteryChargePercentLSBRaw=181 batteryLipoMilliVolts=8192
# calc batteryChargePercent = 0.23567708333333334
# Mobi @ ~20%
# bytearray(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00')
# batteryChargePercentMSBRaw=14, batteryChargePercentLSBRaw=193 batteryLipoMilliVolts=9216
# calc batteryChargePercent = 0.2513020833333333
# Mobi @ ~10%
# bytearray(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00')
# batteryChargePercentMSBRaw=14, batteryChargePercentLSBRaw=161 batteryLipoMilliVolts=6912
# calc batteryChargePercent = 0.20963541666666666
# Mobi @ ~10%
# bytearray(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00')
# batteryChargePercentMSBRaw=14, batteryChargePercentLSBRaw=159 batteryLipoMilliVolts=6656
# calc batteryChargePercent = 0.20703125
# Mobi @ ~5%
# bytearray(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00')
# batteryChargePercentMSBRaw=14, batteryChargePercentLSBRaw=136
# calc batteryChargePercent = 0.17708333333333334 batteryLipoMilliVolts=5376
# Mobi @ ~5%
# bytearray(b'\x00Q \x1e\xb0\xcd\x00\x01C\xd3@L9W@#33@\x8b\x04\xd2\x0e\x88\x15\x00')
# batteryChargePercentMSBRaw=14 batteryChargePercentLSBRaw=136 batteryLipoMilliVolts=5376
# calc 0.17708333333333334
p = self.process.process([event], time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertEqual(p[0]['pump']['battery'], {
'status': '100%',
'percent': 100,
'voltage': 4.176,
})
if __name__ == '__main__':