mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-24 18:24:11 -05:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67731f23ff | ||
|
|
2b8d8ebaf8 | ||
|
|
da58c6fc81 | ||
|
|
196ffc2cfb | ||
|
|
2753b4da20 | ||
|
|
97ad0e7629 | ||
|
|
7c4b2f4ddb | ||
|
|
74576bbb51 | ||
|
|
272a329664 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user