Compare 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
James Woglom 7c4b2f4ddb v3.0.1 2026-07-21 01:32:09 -04:00
ClaudeandJames Woglom 74576bbb51 Make full-package mypy pass on the Python 3.8 CI (older mypy)
The Python 3.8 CI job installs an older mypy that behaves differently from
newer releases, surfacing two things the newer local mypy did not:

- requests (and other stub-less deps) raise `import-untyped`, which older
  mypy does not silence via ignore_missing_imports. Add
  `disable_error_code = import-untyped` so stub-less third-party libs are
  treated as untyped rather than failing the run.
- TandemSourceApi.__init__: older mypy infers secret.TCONNECT_REGION as
  Optional[str], so region.upper() tripped union-attr. Guard against an
  unset region (also avoids an AttributeError at runtime) which narrows it
  to str.

Verified with both mypy 1.19.1 and 2.3.0; tests still green (461 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaQXTakxAKEfeeys5cv3en
2026-07-21 00:58:41 -04:00
ClaudeandJames Woglom 272a329664 Type-check the full tconnectsync package, not just process_alarm
Remove the single-file opt-in in setup.cfg ([mypy] files) so mypy checks
the entire tconnectsync package, and fix everything that surfaced.

Config:
- setup.cfg: mypy `files` now points at the whole `tconnectsync` package.

Event processors (were typed as BaseEvent, which lacks the per-event fields
like seqNum and the event-specific attributes):
- Type each *_to_nsentry / helper with the concrete event type(s) it handles
  (or a Union of them), matching the existing process_alarm.py pattern.
- process_basal_suspension, process_basal_resume, process_cgm_start_join_stop:
  filter out None before appending to ns_entries, matching process_basal /
  process_cgm_alert. Previously a None from a non-matching event would have
  been appended and passed to upload_entry().
- Add explicit `return None` fall-throughs where a helper annotated to return
  a value could implicitly return None.
- process_cgm_reading: give each sensor's GlucosevaluestatusEnum its own local
  variable so the enum types don't clash.

process.py:
- Add an EventProcessor Protocol and annotate event_classes with it, so the
  instantiated handlers are typed instead of `object`.
- Rename the updater-loop variable so it no longer collides with the
  processor-loop variable's type.

Other:
- api/tandemsource.py: narrow the JWKS key to RSAPublicKey before jwt.decode,
  and use arrow.get() (not time.time()) when forcing token expiry so the
  attribute type stays consistent.
- api/common.py: split_days_range returns Arrow tuples, not str tuples.
- parser/nightscout.py: sort profile segments by the typed startTime rather
  than an untyped dict value (same resulting order).
- domain/tandemsource/pump_settings.py: PumpSettings inherits DataClassJsonMixin
  so from_dict is visible to the type checker.
- domain/tandemsource/event_class.py: ignore the set/Enum __hash__ clash.
- nightscout.py: explicit return None when a ConnectionError is swallowed.

No runtime behavior change; full test suite (461 passed, 1 skipped) still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaQXTakxAKEfeeys5cv3en
2026-07-21 00:58:41 -04:00
24 changed files with 582 additions and 238 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
[metadata]
name = tconnectsync
version = 3.0.0
version = 3.0.1
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem Source (formerly t:connect) insulin pump data to Nightscout for the t:slim X2 and Tandem Mobi
@@ -50,6 +50,9 @@ console_scripts =
[mypy]
files =
tconnectsync/sync/tandemsource/process_alarm.py
tconnectsync
follow_imports = silent
ignore_missing_imports = True
# Third-party deps such as requests ship no type stubs; treat them as untyped
# instead of failing (older mypy does not silence this via ignore_missing_imports).
disable_error_code = import-untyped
+1 -1
View File
@@ -112,7 +112,7 @@ def days_between(start, end) -> int:
return diff.days
# both inclusive
def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[str, str]]:
def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[arrow.Arrow, arrow.Arrow]]:
ranges = []
start = arrow.get(start_a)
end = arrow.get(end_a)
+8 -1
View File
@@ -20,6 +20,7 @@ from requests_oidc.plugins import OSCachedPlugin
from requests_oidc.utils import ServerDetails
from requests_oauthlib import OAuth2Session
from jwt.algorithms import RSAAlgorithm
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
from ..util import timeago, cap_length
@@ -220,6 +221,8 @@ class TandemSourceApi:
# US default would send EU accounts to the US endpoints (#152).
if not region:
region = secret.TCONNECT_REGION
if not region:
raise ValueError("No region configured. Set TCONNECT_REGION to 'US' or 'EU'.")
self.region = region.upper()
if self.region not in ['US', 'EU']:
raise ValueError(f"Invalid region '{region}'. Must be 'US' or 'EU'.")
@@ -391,6 +394,10 @@ class TandemSourceApi:
key = public_keys.get(kid)
if not key:
raise ApiException(0, 'Public key not found for JWT: %s' % kid)
# A JWKS endpoint publishes public keys; from_jwk() is typed as possibly
# returning a private key, so narrow it before passing to jwt.decode().
if not isinstance(key, RSAPublicKey):
raise ApiException(0, 'JWK is not an RSA public key for JWT: %s' % kid)
audience = self.TDC_OIDC_CLIENT_ID
issuer = self.TDC_OIDC_ISSUER
@@ -575,7 +582,7 @@ class TandemSourceApi:
# Trigger automatic re-login, and try again once
if e.status_code == 401:
logger.info("Performing automatic re-login after HTTP 401 for TandemSourceApi")
self.accessTokenExpiresAt = time.time()
self.accessTokenExpiresAt = arrow.get()
self.login(self._email, self._password)
return self.get(endpoint, query, tries=tries+1)
@@ -2,7 +2,7 @@ from enum import Enum
from ...eventparser import events
class EventClass(set, Enum):
class EventClass(set, Enum): # type: ignore[misc] # set/Enum both define __hash__; the combination works at runtime
# LidBasalDelivery = every 5min entry
# LidBasalRateChange = only when basal rate changes
BASAL = {events.LidBasalDelivery} # , LidBasalRateChange
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from dataclasses_json import dataclass_json
from dataclasses_json import dataclass_json, DataClassJsonMixin
from typing import List
# These dataclasses model the `settings.details` blob from the Tandem Source
@@ -52,6 +52,6 @@ class PumpCgmSettings:
@dataclass_json
@dataclass
class PumpSettings:
class PumpSettings(DataClassJsonMixin):
profiles: PumpProfiles
cgmSettings: PumpCgmSettings
+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
}
+4
View File
@@ -87,6 +87,7 @@ class NightscoutApi:
except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
return None
else:
raise e
@@ -106,6 +107,7 @@ class NightscoutApi:
except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
return None
else:
raise e
@@ -125,6 +127,7 @@ class NightscoutApi:
except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
return None
else:
raise e
@@ -144,6 +147,7 @@ class NightscoutApi:
except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
return None
else:
raise e
+17 -9
View File
@@ -215,32 +215,40 @@ class NightscoutEntry:
return {
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
"dia": "%s" % (profile.insulinDuration / 60),
"carbratio": list(sorted([
# Sort by the typed segment.startTime (monotonic with timeAsSeconds)
# so the sort key is a well-typed int rather than an untyped dict value.
"carbratio": [
{
"time": minutes_to_ns_time(segment.startTime),
"timeAsSeconds": segment.startTime * 60,
"value": segment.carbRatio / 1000 # milliunits->units
} for segment in profile.tDependentSegs if not segment.skip
], key=lambda x: x["timeAsSeconds"])),
} for segment in sorted(
(s for s in profile.tDependentSegs if not s.skip),
key=lambda s: s.startTime)
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": list(sorted([ # Correction factor / isf
"sens": [ # Correction factor / isf
{
"time": minutes_to_ns_time(segment.startTime),
"timeAsSeconds": segment.startTime * 60,
"value": segment.isf
} for segment in profile.tDependentSegs if not segment.skip
], key=lambda x: x["timeAsSeconds"])),
} for segment in sorted(
(s for s in profile.tDependentSegs if not s.skip),
key=lambda s: s.startTime)
],
"basal": list(sorted([
"basal": [
{
"time": minutes_to_ns_time(segment.startTime),
"timeAsSeconds": segment.startTime * 60,
"value": segment.basalRate / 1000 # milliunits->units
} for segment in profile.tDependentSegs
], key=lambda x: x["timeAsSeconds"])),
} for segment in sorted(
profile.tDependentSegs,
key=lambda s: s.startTime)
],
"target_low": [
{
+16 -6
View File
@@ -3,12 +3,19 @@ import collections
import arrow
from types import ModuleType
from typing import List, Optional, Tuple, TYPE_CHECKING
from typing import Dict, Iterable, List, Optional, Protocol, Tuple, Type, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...api.tandemsource import BffPump
class EventProcessor(Protocol):
"""Structural interface implemented by every Process* event handler."""
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str]) -> None: ...
def enabled(self) -> bool: ...
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]: ...
def write(self, ns_entries: List[dict]) -> int: ...
from ...features import DEVICE_STATUS, DEFAULT_FEATURES
from ...eventparser import events as eventtypes
from ...domain.tandemsource.event_class import EventClass
@@ -37,7 +44,7 @@ class ProcessTimeRange:
self.secret = secret
self.features = features
event_classes = {
event_classes: Dict[str, Type[EventProcessor]] = {
EventClass.BASAL.name: ProcessBasal,
EventClass.BASAL_SUSPENSION.name: ProcessBasalSuspension,
EventClass.BASAL_RESUME.name: ProcessBasalResume,
@@ -93,7 +100,10 @@ class ProcessTimeRange:
# Ensure time_end is timezone-aware for comparison
time_end_aware = arrow.get(time_end)
capped_time_end = min(events_last_time, time_end_aware) if events_last_time else time_end_aware
ns_entries = c.process(events, events_first_time, capped_time_end)
# events_first_time is populated whenever for_eventclass has entries
# (i.e. at least one event was seen); fall back to time_start otherwise.
time_start_for_events = events_first_time if events_first_time else time_start
ns_entries = c.process(events, time_start_for_events, capped_time_end)
w = c.write(ns_entries)
if w:
processed_count += w
@@ -101,10 +111,10 @@ class ProcessTimeRange:
logger.info("Skipping %s, is not enabled from features %s" % (clazz, self.features))
for updater_class in self.updater_classes:
c = updater_class(self.tconnect, self.nightscout, self.tconnect_device_id, self.pretend, self.features)
if c.enabled():
updater = updater_class(self.tconnect, self.nightscout, self.tconnect_device_id, self.pretend, self.features)
if updater.enabled():
logger.info("%s is enabled from features %s" % (updater_class.__name__, self.features))
done = c.update(self.pretend)
done = updater.update(self.pretend)
logger.info("%s completed with update required: %s" % (updater_class.__name__, done))
else:
logger.info("Skipping %s, is not enabled from features %s" % (updater_class.__name__, self.features))
@@ -15,14 +15,15 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, TYPE_CHECKING
from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
logger = logging.getLogger(__name__)
BasalEvent = Union[eventtypes.LidBasalRateChange, eventtypes.LidBasalDelivery]
class ProcessBasal:
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
@@ -81,7 +82,7 @@ class ProcessBasal:
return count
def basal_to_nsentry(self, start: arrow.Arrow, duration: datetime.timedelta, event: "BaseEvent") -> Optional[dict]:
def basal_to_nsentry(self, start: arrow.Arrow, duration: datetime.timedelta, event: BasalEvent) -> Optional[dict]:
if type(event) == eventtypes.LidBasalRateChange:
value = insulin_float_round(event.commandedBasalRate)
if IGNORE_ZERO_UNIT_BASAL and value < 0.01:
@@ -106,3 +107,5 @@ class ProcessBasal:
reason = ', '.join(bitmask_to_list(event.commandedRateSource)),
pump_event_id = "%s" % event.seqNum
)
return None
@@ -5,7 +5,6 @@ from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
from ...features import DEFAULT_FEATURES
from ... import features
@@ -46,7 +45,9 @@ class ProcessBasalResume:
logger.info("Skipping BasalResume event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
ns_entries.append(self.resume_to_nsentry(event))
ns = self.resume_to_nsentry(event)
if ns:
ns_entries.append(ns)
return ns_entries
@@ -64,9 +65,11 @@ class ProcessBasalResume:
return count
def resume_to_nsentry(self, event: "BaseEvent") -> Optional[dict]:
def resume_to_nsentry(self, event: eventtypes.LidPumpingResumed) -> Optional[dict]:
if type(event) == eventtypes.LidPumpingResumed:
return NightscoutEntry.basalresume(
created_at = event.eventTimestamp.format(),
pump_event_id = "%s" % event.seqNum
)
return None
@@ -5,7 +5,6 @@ from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
from ...features import DEFAULT_FEATURES
from ... import features
@@ -46,7 +45,9 @@ class ProcessBasalSuspension:
logger.info("Skipping basalsuspension event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
ns_entries.append(self.suspension_to_nsentry(event))
ns = self.suspension_to_nsentry(event)
if ns:
ns_entries.append(ns)
return ns_entries
@@ -64,10 +65,12 @@ class ProcessBasalSuspension:
return count
def suspension_to_nsentry(self, event: "BaseEvent") -> Optional[dict]:
def suspension_to_nsentry(self, event: eventtypes.LidPumpingSuspended) -> Optional[dict]:
if type(event) == eventtypes.LidPumpingSuspended:
return NightscoutEntry.basalsuspension(
created_at = event.eventTimestamp.format(),
reason = ', '.join(bitmask_to_list(event.suspendReason)),
pump_event_id = "%s" % event.seqNum
)
return None
@@ -17,7 +17,6 @@ from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
logger = logging.getLogger(__name__)
@@ -41,7 +40,7 @@ class ProcessBolus:
logger.info("Last Nightscout bolus upload: %s" % last_upload_time)
# Correlate a bolus's request/completion messages by bolusid.
bolusEventsForId = {}
bolusEventsForId: dict = {}
for event in sorted(events, key=lambda x: x.eventTimestamp):
bolusEventsForId.setdefault(event.bolusId, {})[type(event)] = event
@@ -90,7 +89,7 @@ class ProcessBolus:
return count
def bolus_to_nsentry(self, bolusCompleted: "BaseEvent", bolusRequested1: "BaseEvent", bolusRequested2: "BaseEvent", bolusRequested3: "BaseEvent") -> Optional[dict]:
def bolus_to_nsentry(self, bolusCompleted: eventtypes.LidBolusCompleted, bolusRequested1: Optional[eventtypes.LidBolusRequestedMsg1], bolusRequested2: Optional[eventtypes.LidBolusRequestedMsg2], bolusRequested3: Optional[eventtypes.LidBolusRequestedMsg3]) -> dict:
suffixes = []
if bolusRequested2 and bolusRequested2.userOverride == eventtypes.LidBolusRequestedMsg2.UseroverrideEnum.Yes:
suffixes.append('(Override)')
@@ -119,7 +118,7 @@ class ProcessBolus:
pump_event_id = ",".join(seq_nums)
)
def bolex_to_nsentry(self, bolexCompleted: "BaseEvent") -> Optional[dict]:
def bolex_to_nsentry(self, bolexCompleted: eventtypes.LidBolexCompleted) -> dict:
# The extended portion of a combo bolus, added as its own treatment at
# the time it finished delivering. Insulin only; carbs/bg belong to the
# initial LidBolusCompleted entry and must not be double-counted here.
@@ -12,11 +12,10 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, TYPE_CHECKING
from typing import Iterable, List, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
logger = logging.getLogger(__name__)
@@ -84,7 +83,7 @@ class ProcessCartridge:
return count
def cart_to_nsentry(self, cartFilled: "BaseEvent") -> Optional[dict]:
def cart_to_nsentry(self, cartFilled: eventtypes.LidCartridgeFilled) -> dict:
# insulinVolume is populated on t:slim X2 / Mobi; v2Volume is a legacy fallback.
volume = cartFilled.insulinVolume or cartFilled.v2Volume
return NightscoutEntry.sitechange(
@@ -93,7 +92,7 @@ class ProcessCartridge:
pump_event_id = "%s" % cartFilled.seqNum
)
def cannula_to_nsentry(self, cannulaFilled: "BaseEvent") -> Optional[dict]:
def cannula_to_nsentry(self, cannulaFilled: eventtypes.LidCannulaFilled) -> dict:
# primeSize is fractional (e.g. 0.3u); format with one decimal, not %d.
primed = cannulaFilled.primeSize if cannulaFilled.primeSize and cannulaFilled.primeSize > 0 else None
return NightscoutEntry.sitechange(
@@ -102,7 +101,7 @@ class ProcessCartridge:
pump_event_id = "%s" % cannulaFilled.seqNum
)
def tubing_to_nsentry(self, tubingFilled: "BaseEvent") -> Optional[dict]:
def tubing_to_nsentry(self, tubingFilled: eventtypes.LidTubingFilled) -> dict:
# primeSize is -1 (sentinel, "not recorded") on real tubing fills; only show a real prime volume.
primed = tubingFilled.primeSize if tubingFilled.primeSize and tubingFilled.primeSize > 0 else None
return NightscoutEntry.sitechange(
@@ -12,14 +12,20 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, TYPE_CHECKING
from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
logger = logging.getLogger(__name__)
# The three CGM alert event types all expose dalertId / dalertIdRaw / seqNum.
CgmAlertEvent = Union[
eventtypes.LidCgmAlertActivated,
eventtypes.LidCgmAlertActivatedDex,
eventtypes.LidCgmAlertActivatedFsl2,
]
class ProcessCGMAlert:
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
@@ -70,7 +76,7 @@ class ProcessCGMAlert:
return count
def alert_to_nsentry(self, alert: "BaseEvent") -> Optional[dict]:
def alert_to_nsentry(self, alert: CgmAlertEvent) -> Optional[dict]:
# FSL3 alert codes are defined in eventparser/static_dicts.py:CGM_ALERTS_DICT
# Alert code meanings are documented in comments there.
if not alert.dalertId:
@@ -98,3 +104,5 @@ class ProcessCGMAlert:
reason = ("Libre CGM Alert (%s)" % alert.dalertId.name) if alert.dalertId else "Libre CGM Alert (Unknown)",
pump_event_id = "%s" % alert.seqNum
)
return None
@@ -12,7 +12,6 @@ from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
# The four CGM-reading event types share the glucoseValueStatus /
# currentGlucoseDisplayValue fields determine_glucose_value() reads.
@@ -52,22 +51,22 @@ def determine_glucose_value(event: CgmReadingEvent) -> int:
status = event.glucoseValueStatus
if isinstance(event, eventtypes.LidCgmDataG7):
e = eventtypes.LidCgmDataG7.GlucosevaluestatusEnum
g7 = eventtypes.LidCgmDataG7.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=e.PreciseValue, high=e.SpecialHigh, low=e.SpecialLow)
precise=g7.PreciseValue, high=g7.SpecialHigh, low=g7.SpecialLow)
if isinstance(event, eventtypes.LidCgmDataGxb):
e = eventtypes.LidCgmDataGxb.GlucosevaluestatusEnum
gxb = eventtypes.LidCgmDataGxb.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=e.CurrentglucosedisplayvalueContainsTheGlucoseReading,
high=e.TheGlucoseReadingIsHigh, low=e.TheGlucoseReadingIsLow)
precise=gxb.CurrentglucosedisplayvalueContainsTheGlucoseReading,
high=gxb.TheGlucoseReadingIsHigh, low=gxb.TheGlucoseReadingIsLow)
if isinstance(event, eventtypes.LidCgmDataFsl3):
e = eventtypes.LidCgmDataFsl3.GlucosevaluestatusEnum
fsl3 = eventtypes.LidCgmDataFsl3.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=e.PreciseValue, high=e.SpecialHigh, low=e.SpecialLow)
precise=fsl3.PreciseValue, high=fsl3.SpecialHigh, low=fsl3.SpecialLow)
if isinstance(event, eventtypes.LidCgmDataFsl2):
e = eventtypes.LidCgmDataFsl2.GlucosevaluestatusEnum
fsl2 = eventtypes.LidCgmDataFsl2.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=e.PreciseValue, high=e.SpecialHigh, low=e.SpecialLow)
precise=fsl2.PreciseValue, high=fsl2.SpecialHigh, low=fsl2.SpecialLow)
return display_value
@@ -120,12 +119,12 @@ class ProcessCGMReading:
return count
def timestamp_for(self, event: "BaseEvent") -> arrow.Arrow:
def timestamp_for(self, event: CgmReadingEvent) -> arrow.Arrow:
# For backfills the time the event was added to the pump's event store
# might not be the time it actually occurred, so we use the egvTimestamp
return arrow.get(TANDEM_EPOCH + event.egvTimeStamp, tzinfo='UTC').replace(tzinfo=self.timezone)
def to_nsentry(self, event: "BaseEvent") -> Optional[dict]:
def to_nsentry(self, event: CgmReadingEvent) -> dict:
return NightscoutEntry.entry(
sgv = determine_glucose_value(event),
created_at = self.timestamp_for(event).format(),
@@ -3,6 +3,7 @@ import arrow
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser import events as eventtypes
from ...domain.tandemsource.event_class import EventClass
from ...nightscout import format_datetime
from ...parser.nightscout import (
@@ -12,14 +13,28 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, TYPE_CHECKING
from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
logger = logging.getLogger(__name__)
# The CGM session start/join/stop event types (see EventClass._CGM_START /
# _CGM_JOIN / _CGM_STOP); all expose seqNum and eventTimestamp.
CgmSessionEvent = Union[
eventtypes.LidCgmStartSessionGx,
eventtypes.LidCgmStartSessionFsl2,
eventtypes.LidCgmJoinSessionGx,
eventtypes.LidCgmJoinSessionG7,
eventtypes.LidCgmJoinSessionFsl2,
eventtypes.LidCgmJoinSessionFsl3,
eventtypes.LidCgmStopSessionGx,
eventtypes.LidCgmStopSessionG7,
eventtypes.LidCgmStopSessionFsl2,
eventtypes.LidCgmStopSessionFsl3,
]
class ProcessCGMStartJoinStop:
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
@@ -63,7 +78,9 @@ class ProcessCGMStartJoinStop:
ns_entries = []
for event in allEvents:
ns_entries.append(self.to_nsentry(event))
ns = self.to_nsentry(event)
if ns:
ns_entries.append(ns)
return ns_entries
@@ -80,7 +97,7 @@ class ProcessCGMStartJoinStop:
return count
def to_nsentry(self, event: "BaseEvent") -> Optional[dict]:
def to_nsentry(self, event: CgmSessionEvent) -> Optional[dict]:
if type(event) in EventClass._CGM_START:
return NightscoutEntry.cgm_start(
created_at = format_datetime(event.eventTimestamp),
@@ -99,3 +116,5 @@ class ProcessCGMStartJoinStop:
reason = "CGM Session Stopped",
pump_event_id = "%s" % event.seqNum
)
return None
@@ -17,7 +17,6 @@ from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
logger = logging.getLogger(__name__)
@@ -63,24 +62,24 @@ class ProcessDeviceStatus:
return []
return [entry]
def daily_basal_to_nsentry(self, event: "BaseEvent") -> Optional[dict]:
def daily_basal_to_nsentry(self, event: eventtypes.LidDailyBasal) -> Optional[dict]:
# NOTE: the pump-logs endpoint does not emit event 81 (LID_DAILY_BASAL)
# for either t:slim X2 or Mobi (verified against live accounts), and no
# other returned event carries battery data. DEVICE_STATUS therefore
# 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
)
@@ -5,7 +5,6 @@ from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...eventparser.raw_event import BaseEvent
from ...features import DEFAULT_FEATURES
from ... import features
@@ -90,7 +89,7 @@ class ProcessUserMode:
processed_sleep.append((start_sleep, event))
start_sleep = None
else:
if sleep_not_ended:
if sleep_not_ended and sleep_last_upload:
logger.info("ProcessUserMode: Found StopSleep without StartSleep, with incomplete sleep event in nightscout: %s NS: %s" % (event, sleep_last_upload))
ns_entries.append(self.process_unended_sleep_stop(event, sleep_last_upload))
else:
@@ -102,7 +101,7 @@ class ProcessUserMode:
processed_exercise.append((start_exercise, event))
start_exercise = None
else:
if exercise_not_ended:
if exercise_not_ended and exercise_last_upload:
logger.info("ProcessUserMode: Found StopExercise without StartExercise, with incomplete exercise event in nightscout: %s NS: %s" % (event, exercise_last_upload))
ns_entries.append(self.process_unended_exercise_stop(event, exercise_last_upload))
else:
@@ -118,10 +117,14 @@ class ProcessUserMode:
logger.info("ProcessUserMode: exercise is active")
for items in processed_sleep:
ns_entries.append(self.sleep_to_nsentry(start=items[0], stop=items[1], time_end=time_end))
ns = self.sleep_to_nsentry(start=items[0], stop=items[1], time_end=time_end)
if ns:
ns_entries.append(ns)
for items in processed_exercise:
ns_entries.append(self.exercise_to_nsentry(start=items[0], stop=items[1], time_end=time_end))
ns = self.exercise_to_nsentry(start=items[0], stop=items[1], time_end=time_end)
if ns:
ns_entries.append(ns)
return ns_entries
@@ -137,19 +140,19 @@ class ProcessUserMode:
return count
def is_start_sleep(self, event: "BaseEvent") -> bool:
def is_start_sleep(self, event: eventtypes.LidAaUserModeChange) -> bool:
return event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep
def is_stop_sleep(self, event: "BaseEvent") -> bool:
def is_stop_sleep(self, event: eventtypes.LidAaUserModeChange) -> bool:
return event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep or \
event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
def is_start_exercise(self, event: "BaseEvent") -> bool:
def is_start_exercise(self, event: eventtypes.LidAaUserModeChange) -> bool:
return event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise
def is_stop_exercise(self, event: "BaseEvent") -> bool:
def is_stop_exercise(self, event: eventtypes.LidAaUserModeChange) -> bool:
return event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise or \
event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
def sleep_to_nsentry(self, start: "BaseEvent", stop: Optional["BaseEvent"] = None, time_end: Optional[arrow.Arrow] = None) -> Optional[dict]:
def sleep_to_nsentry(self, start: eventtypes.LidAaUserModeChange, stop: Optional[eventtypes.LidAaUserModeChange] = None, time_end: Optional[arrow.Arrow] = None) -> Optional[dict]:
if start and stop:
reason = None
if start.sleepStartedByGui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
@@ -181,8 +184,10 @@ class ProcessUserMode:
pump_event_id = "%s" % start.seqNum
)
return None
def exercise_to_nsentry(self, start: "BaseEvent", stop: Optional["BaseEvent"] = None, time_end: Optional[arrow.Arrow] = None) -> Optional[dict]:
def exercise_to_nsentry(self, start: eventtypes.LidAaUserModeChange, stop: Optional[eventtypes.LidAaUserModeChange] = None, time_end: Optional[arrow.Arrow] = None) -> Optional[dict]:
if start and stop:
reason = "Exercise"
if start.exerciseChoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
@@ -213,7 +218,9 @@ class ProcessUserMode:
pump_event_id = "%s" % start.seqNum
)
def process_unended_sleep_stop(self, event: "BaseEvent", sleep_last_upload: dict) -> dict:
return None
def process_unended_sleep_stop(self, event: eventtypes.LidAaUserModeChange, sleep_last_upload: dict) -> dict:
logger.info("ProcessUserMode: Deleting old sleep event treatment before pushing update (delete treatments/%s)" % sleep_last_upload["_id"])
if self.pretend:
logger.info("ProcessUserMode: Skipping delete in pretend mode")
@@ -229,7 +236,7 @@ class ProcessUserMode:
pump_event_id="%s,%s" % (sleep_last_upload.get("pump_event_id",""), event.seqNum)
)
def process_unended_exercise_stop(self, event: "BaseEvent", exercise_last_upload: dict) -> dict:
def process_unended_exercise_stop(self, event: eventtypes.LidAaUserModeChange, exercise_last_upload: dict) -> dict:
logger.info("ProcessUserMode: Deleting old exercise event treatment before pushing update (delete treatments/%s)" % exercise_last_upload["_id"])
if self.pretend:
logger.info("ProcessUserMode: Skipping delete in pretend mode")
@@ -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__':