Compare commits

..
97 Commits
Author SHA1 Message Date
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
xannasavinandJames Woglom 758204d4de URL-encode Nightscout date filters instead of retrying with a mangled timestamp
arrow's isoformat() ends a timestamp with its UTC offset, e.g.
'2026-07-16T00:00:00+02:00'. Placed raw into a query string, '+' is a
reserved character that servers decode as a space, so Nightscout receives
'2026-07-16T00:00:00 02:00' and answers 'could not parse as a valid ISO-8601
date'. Any user on a positive UTC offset hits this on every date-filtered
query.

The current workaround retries the request with 'T' replaced by a space
(t_to_space) and treats a failure as 'no previous entry'. That gets a
response, but it works around the symptom: the value is still mangled, every
affected query costs two round-trips, and a genuinely empty result is
indistinguishable from a rejected one.

Percent-encoding the value fixes the cause: the offset survives, the first
request succeeds, and the t_to_space fallback and its retry wrapper are no
longer needed and are removed.

Adds tests for time_range(): that a positive offset is encoded rather than
emitted raw, that the encoded value round-trips back to the original instant,
that negative offsets and 'Z' still parse, and the bounds/no-bounds cases.
There was no coverage of time_range() before. The encoding test fails on
master with '+' present in the query and passes with this change.

Running with this in my EU deployment since May.
2026-07-20 20:50:13 -04:00
ClaudeandJames Woglom ddeaa79ded Fix #156: handle LidMalfunctionActivated in ProcessAlarm; add typed guardrail
ProcessAlarm.skip_event() read event.alarmId on every EventClass.ALARM event,
but LidMalfunctionActivated (a sibling of LidAlarmActivated in that class) has
no alarmId, so a malfunction alarm crashed the sync with AttributeError.

- Narrow with isinstance before reading alarmId; malfunction events now upload
  as "Malfunction" as intended, and sync continues.
- Type the alarm handlers against an explicit AlarmEvent union and add an
  assert_never exhaustiveness guard, so a type checker rejects unguarded
  subtype attribute access and flags any newly added ALARM event type.
- Fix a latent None-leak: alarm_to_nsentry now always returns a dict.
- Add mypy as a gradual-typing beachhead (setup.cfg [mypy], CI step, Pipfile
  typecheck script), scoped to process_alarm.py. This configuration fails on
  exactly the #156 class of bug.
- Add regression tests: malfunction processing, mixed alarm batches, the event
  shape, and an AlarmEvent/EventClass.ALARM sync guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpMTd5LzkqFLZTUKd3H8em
2026-07-20 20:49:43 -04:00
James WoglomandClaude Opus 4.8 c95fe424ab Fix flaky autoupdate tests: use constant mocked clock
Patching autoupdate.time.time patches the global time.time, which
logging calls internally per record; a finite side_effect list gets
exhausted and raises StopIteration on Python 3.11. Use return_value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:46:23 -04:00
James Woglom 4bae860212 tests/sync/tandemsource/test_autoupdate.py 2026-07-20 20:40:02 -04:00
xannasavinandJames Woglom 823bb785ba Harden autoupdate against transient errors instead of exiting
get() retries only HTTP 401 and 500, so any other API error propagates out of
the autoupdate loop and exits the process. When Tandem retired the
reportsfacade endpoints and pumpeventmetadata began returning 404 (#146), a
container with a restart policy would crash-loop. That is the worst possible
response to an API outage: the credentials cache dies with the process, so
every restart performs a full login against sso.tandemdiabetes.com. In my EU
deployment that was a fresh login roughly every two minutes for hours from a
single IP, which seems a good way to earn a WAF ban while already broken.

Transient network errors (DNS failures, timeouts, mid-stream disconnects,
urllib3 retry-budget exhaustion) have the same problem.

This keeps both failure families inside the loop and backs off exponentially:
30s doubling to a cap of AUTOUPDATE_DEFAULT_SLEEP_SECONDS (300s default),
reset on any successful poll. The cap reuses the existing poll interval, so a
failing API is never contacted more often than a healthy one. After three
consecutive failures the log escalates from WARNING to ERROR.

Staying alive forever would make a real outage silent on deployments whose
only alarm is the container dying, so after AUTOUPDATE_API_FAILURE_MINUTES
(default 45) of unbroken failure the process gives up and exits non-zero.
That is roughly one restart per hour during a genuine outage instead of one
every two minutes, while short blips stay silent. Set 0 to disable.

This is deliberately not gated on AUTOUPDATE_RESTART_ON_FAILURE, which covers
the pump-not-uploading watchdog where restarting achieves nothing (as the
existing TODO notes) and which many users therefore disable. An unreachable
API is a different failure and gets its own knob.

ApiLoginException stays fatal: bad credentials are not transient, and
retrying them in-process would hammer the login endpoint with attempts that
cannot succeed.

Also included:

- A defensive clamp so a negative rolling-average entry can never reach
  time.sleep() and crash with ValueError.
- Tests covering the backoff sequence, reset-on-success, the sustained-failure
  exit, the opt-out, and that login failures and programming errors still
  propagate.
- README documentation for all nine AUTOUPDATE_* variables, none of which were
  documented outside secret.py.
2026-07-20 19:12:24 -04:00
ClaudeandJames Woglom 984487da44 Trim over-explanatory comments in EU integration tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012DEvvZSWHo2dki5h1HikUU
2026-07-17 19:13:07 -04:00
ClaudeandJames Woglom 4655e31561 Fix hardcoded US region default; add EU region integration tests (#152)
TConnectApi and TandemSourceApi defaulted their region parameter to a
hardcoded 'US', so callers that construct them without an explicit
region (e.g. tconnectsync-heroku's check_login path) sent EU accounts
to the US login endpoint, which rejected them with HTTP 401
account/invalid_credentials even when TCONNECT_REGION=EU was set.

Both classes (and fetch_oneshot) now fall back to the configured
TCONNECT_REGION when no region argument is given, so downstream
consumers honor the .env/environment configuration.

Adds top-level integration tests that configure the EU region via each
supported mechanism (TCONNECT_REGION environment variable, .env file,
and --region CLI flag) and drive the real downstream code -- including
the full main(['--check-login']) entrypoint -- against a mocked HTTP
layer with a real RS256-signed OIDC id_token. Only the EU endpoints are
registered, and the tests assert the EU login/token/jwks/pumper/
pump-logs endpoints are actually invoked and that no US host is ever
contacted.

Fixes #152

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012DEvvZSWHo2dki5h1HikUU
2026-07-17 19:13:07 -04:00
James Woglom 7f88d88ea4 Fix PyPI publish workflow: use setup-python@v5 with Python 3.11
setup-python@v1 could not find Python 3.9 on current GitHub runners.
Bump checkout to v4 and pin an available Python version.
2026-07-01 07:26:32 +00:00
James Woglom afbc99010c v3.0.0 - uses new Tandem Source APIs 2026-07-01 07:16:51 +00:00
James Woglom 6f987989b8 Add real-data unit tests for cartridge, basal, basal suspend/resume, and CGM alert handlers
One test file per handler, built from real captured pump-log events (verbatim,
deviceAssignmentId redacted) embedded inline. Assert exact Nightscout output:
- cartridge: site-change reason strings (insulinVolume fill, -1 tubing sentinel, %.1f cannula)
- basal: rate scaling, commandedRateSource reason, inter-event + capped durations (locks total_seconds), zero-rate suspend
- basal suspension/resume: exact treatments + dedup
- cgm alert: Dexcom-prefixed dalertId names, out-of-range + unmapped skips, cleared/ack not synced
2026-07-01 07:15:58 +00:00
James Woglom 1500438c1b Add end-to-end integration tests for the Tandem Source -> Nightscout flow
Drive the real ProcessTimeRange + process_* handlers and the real
TandemSourceApi / NightscoutApi clients, mocking only the HTTP transport
(base_session / requests). Tandem responses are a small representative slice of
real captured pump-log events (verbatim, deviceAssignmentId redacted); tests
assert the exact Nightscout operations produced: full multi-type sync, api-secret
header, resume-alarm skip, dedup, pretend mode, and empty window.
2026-07-01 07:09:43 +00:00
James Woglom ee7384cc31 Sync events.json to upstream schema; preserve camelCase attribute names
Copy the exact eventSchema.json to events.json (camelCase keys) and fix
build_events.py's fieldNameFormat to preserve camelCase instead of collapsing
it via .title() (which turned schema keys like commandedRate into commandedrate).
Regenerate events.py so attributes are clean camelCase (commandedRate,
currentGlucoseDisplayValue, egvTimeStamp, bolusId, ...); acronyms follow the
schema's own casing (bg, iob, rssi). Update the battery transform and all
attribute references in the process handlers and tests to match.
2026-07-01 06:50:30 +00:00
James Woglom e5304dc605 Add G6 JSON-path tests to ProcessCGMReading from real early-2023 data
Real Dexcom G6 (LidCgmDataGxb, eventCode 256) pump-logs JSON readings captured
from a live t:slim X2 account, exercising the production Events -> ProcessCGMReading
path (the existing G6 coverage used only the binary decoder). Covers steady/rising/
falling/high readings plus a SpecialLow (raw display 0 -> LOW sentinel 39).
2026-07-01 06:27:57 +00:00
James Woglom cb045796e5 Add ProcessBolus tests from real captured pump-log data
Covers regular, extended (combo), and canceled boluses using complete event
groups captured verbatim from the live Tandem Source API (all messages of each
bolusId; deviceAssignmentId redacted), embedded inline as test class variables.
2026-07-01 06:22:06 +00:00
James Woglom ef87d18469 Fix event-processing correctness bugs found during test audit
- process_basal / process_user_mode: use timedelta.total_seconds() instead of
  .seconds so durations spanning >=24h (and negative deltas) are correct.
- process_cartridge: report cartridge fill from insulinVolume (v2Volume is 0 on
  real pumps); treat tubing primeSize -1 as 'not recorded'; format cannula
  primeSize with %.1f instead of %d.
- process_bolus: no longer drop the extended portion of a combo bolus. The
  initial portion is emitted as before; the extended portion (LidBolexCompleted)
  is added as a separate treatment at its completion time.
- check.py: return after a config ImportError instead of falling through to an
  unbound-name NameError.
2026-07-01 06:22:00 +00:00
James Woglom 9d8ab283a0 Add per-event-type parser tests from real captured pump-log data
One test file per event type observed in captured Tandem Source
pump-logs responses (30 types), each asserting parse/dispatch,
field round-trip, and enum/bitmask/ratio resolution against real
capture fixtures.
2026-07-01 05:26:46 +00:00
James Woglom a28e0f1ee0 Add pump_clock_changes() to fetch parsed clock-change events
Mirrors pump_events(): pages get_pump_logs across date windows and dedupes
clockChanges (LID_TIME_CHANGED/LID_DATE_CHANGED, codes 13/14) by
(sequenceGroup, sequenceNumber), returning them parsed via Events().
2026-07-01 04:40:53 +00:00
James Woglom ddca912eea Remove PumpMetadata transform; callers use raw BffPump, normalize at call sites
Delete the PumpMetadata TypedDict, _bff_pump_to_metadata and pump_metadata
transform layer. Callers now consume the raw BffPump dicts from get_pumper()
directly. The pump-local -> UTC date conversion is kept as a shared
naive_local_to_utc() helper, applied only at the call sites that compare a
pump date against real UTC (choose_device staleness/selection, autoupdate
timing).

Also expand pump_events JSON parse coverage: drive bolus (20), basal (279),
CGM (399) and alarm (5) events through pump_events(), asserting decoded
fields and enum members (previously only eventCode 16 was covered).
2026-07-01 04:22:57 +00:00
James Woglom 46a5a28baf Normalize BFF pump dates to UTC and fix BffPump optional-field typing
maxDateOfEvents and availableDataRange.start are pump-local naive
wall-clock strings, but consumers parse them with arrow.get() (assumes
UTC) and compare against arrow.utcnow()/time.time(). Normalize both to
true UTC at the adapter boundary via _naive_local_to_utc (interpreting
the naive value in TIMEZONE_NAME), fixing the off-by-offset staleness
warning and update-timing telemetry.

Split BffPump into a required base plus a total=False extension so the
always-present fields are typed required, and read algorithm (canonically
optional) via .get() to avoid a KeyError.
2026-07-01 03:39:07 +00:00
James Woglom b7375d11da Test get() 401 re-login and 500 retry paths 2026-07-01 03:05:36 +00:00
James Woglom 6030022476 Pass CGM glucose status enum members as keyword args 2026-07-01 03:04:10 +00:00
James Woglom a977bd65f9 Report CGM out-of-range readings as LOW/HIGH sentinels
The bff pump-logs endpoint gives glucoseValueStatus + a raw display value; a
below/above-range reading (e.g. status SpecialLow with displayValue 38) is a
boundary indicator, not a measurement. Mirror the Tandem Source frontend
(CgmBuilder.determineGlucoseValue): map SpecialLow/precise<40 -> 39 and
SpecialHigh/precise>400 -> 401. Each sensor (G7/G6/FSL2/FSL3) is resolved
against its own glucoseValueStatus enum members rather than assuming the enums
are consistent across sensor types.

Also widen the real-JSON CGM tests to span glucose 38..361 (incl. the LOW
sentinel) and add a ProcessTimeRange basal JSON integration test.
2026-07-01 03:03:26 +00:00
James Woglom 69637015e2 Fold pump-logs JSON handling into Event()/Events()
Event(x) and Events(x) now accept either a raw binary event/stream or a
pump-logs JSON event dict / iterable of dicts, dispatching on input type,
replacing the separate Event_from_json/Events_from_json functions. Point
pump_events() and all tests at the unified entry points, and add real-JSON
sync tests for CGM readings, user-mode sleep/exercise, and alarms alongside
the existing binary-fixture tests.
2026-07-01 02:52:16 +00:00
James Woglom d1d67b5042 Add real-JSON CGM reading tests via the production path
Exercise Events_from_json -> ProcessCGMReading with real LID_CGM_DATA_G7
pump-logs events captured from a live account (device id redacted), asserting
sgv, egv-derived dateString, pump_event_id, and last-upload skip behavior.
The existing binary-fixture tests are kept alongside.
2026-07-01 02:45:18 +00:00
James Woglom 7516226bd3 Verify UpdateProfiles builds Nightscout profiles from the new settings
UpdateProfiles already sources settings.details via pump_metadata(); add
end-to-end compare_profiles tests over a real-shape PumpSettings confirming
the per-segment basal/carbratio/sens schedule, flat-cgm target_low/high, and
defaultProfile are translated correctly, and that a matching Nightscout
profile yields no change.
2026-07-01 02:40:04 +00:00
James Woglom 1b89a4ba4d Parse the new bff/pumper settings.details schema in PumpSettings
The settings.details blob differs from the old lastUpload.settings: profile
segments live under timeDependentSegments (was tDependentSegs), cgmSettings
is flat (highGlucoseAlertMgPerDl/lowGlucoseAlertMgPerDl, not nested per-alert
objects), and carbEntry is a string enum. Rework the dataclasses to the new
shape (verified against live t:slim X2 and Mobi accounts), keep a
tDependentSegs alias for segment consumers, and point tandemsource_profile_store
at the flat cgm fields. Values remain milliunit-scaled, so the NS translation
math is unchanged.
2026-07-01 02:39:12 +00:00
James Woglom 02e3910928 Document that the new API drops DEVICE_STATUS and ignores eventIds
Live probes against t:slim X2 and Mobi accounts confirm the pump-logs
endpoint never returns event 81 (LID_DAILY_BASAL) and no returned event
carries battery data, so DEVICE_STATUS yields nothing on the new API (it
degrades gracefully). The endpoint also ignores the eventIds filter and
returns every event in the window; filtering is effectively client-side.
2026-07-01 02:34:19 +00:00
James Woglom c5854ac17c Degrade DEVICE_STATUS gracefully when event 81 is missing or partial
Event 81 (LidDailyBasal, battery) is not in Tandem's default id list; the
pump-logs endpoint may not return it. DEVICE_STATUS already fetches all
event types, and no-daily-basal-event already returns nothing — add a test
pinning that, plus a guard so an event 81 that arrives without battery
fields is skipped with a warning instead of raising on the percent math.
2026-07-01 02:29:57 +00:00
James Woglom b362bbbf3d Rewire pump_events() onto the pump-logs endpoint with date windowing
Fetch pre-parsed JSON events from get_pump_logs instead of decoding the
retired reportsfacade binary stream:
- page the requested range into inclusive windows of at most 28 days
  (the endpoint caps each request at ~4 weeks), covering short ranges and
  single days correctly
- dedupe events that span windows by (sequenceGroup, sequenceNumber)
- count but skip clockChanges (LID_TIME_CHANGED/LID_DATE_CHANGED are not
  consumed by any processor)
- parse via Events_from_json

Remove the dead reportsfacade pump_events_raw() and the now-unused
pump_event_metadata()/PumpEventMetadata/LastUpload types.
2026-07-01 02:27:20 +00:00
James Woglom 408366fe6b Test enum/ratio, bitmask array, and raw-field shims on the JSON adapter
Verify against real captured pump-logs events that build_from_json:
- resolves enum/dictionary fields from their raw ints (commandedRateSource,
  alarmId, requestedAction, previousUserMode, glucoseValueStatus incl. 0)
- scales ratio fields (rate x0.1)
- folds bitmask arrays of set-bit indices back to the raw int the IntFlag
  expects (activeSleepSchedule [0]->1, cgmDataType [0]->1,
  egvInfoBitmask [0,5,6,7,8,11,12]->6625), and empty array->0
- keeps the raw.timestamp shim used by process_device_status and the raw
  egvTimeStamp seconds used by ProcessCGMReading
2026-07-01 02:19:54 +00:00
James Woglom 0779803de2 Generate JSON->event adapter via the events codemod
Extend build_events.py (the codemod) so events.json stays the source of
truth: each generated event class gains build_from_json(event), mapping a
pump-logs JSON event's eventProperties onto its {field}[Raw] attrs
(matched by normalized field name), plus RawEvent.build_from_json() which
derives timestampRaw from pumpDateTime so eventTimestamp/seqNum/eventId
keep working. Add generic.Event_from_json()/Events_from_json() dispatchers
mirroring Event()/Events(); unknown eventCode -> bare RawEvent.

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

Tests: tests/eventparser/test_from_json.py.
2026-07-01 02:04:24 +00:00
James Woglom d2e947ffa3 Add get_pump_logs() BFF events fetch with typed response
Implement TandemSourceApi.get_pump_logs(device_id, ...) for GET
api/reports/bff/pump-logs/{assignmentId}?pumperId&startDate&endDate&eventIds,
the JSON events endpoint that replaces the base64 reportsfacade/pumpevents.
startDate/endDate are sent as {ymd}T00:00:00Z / {ymd}T23:59:59Z per the
captured request. Adds typed PumpLogsResponse/PumpLogEvent TypedDicts,
verified to cover every key across all captured pump-logs responses.

Tests: URL/path/param construction (order-independent parse_qs), default
vs custom vs empty eventIds, empty query dict passthrough, return
passthrough, and None-dates-default-to-today.
2026-07-01 01:38:39 +00:00
James Woglom 27ddf0cb1d Rename misleading check.py local var to deviceId
The device-id flow now carries the UUID assignmentId (as deviceId) end to
end; rename the leftover tconnectDeviceId local in check.py to match. The
deviceId flow is exercised by the existing choose_device, process and
update_profiles tests.
2026-07-01 01:27:09 +00:00
James Woglom b60bfb94ee Add Phase-2 backfill tests (update_profiles, adapter, event ids)
- tests/sync/tandemsource/test_update_profiles.py: settings sourcing from
  pump_metadata() — matching deviceId with settings reaches
  PumpSettings.from_dict (proven via sentinel, using the real BFF
  settings.details shape); settings=None / no-match / empty return False
  without parsing.
- test_tandemsource.py: adapter edge cases (availableDataRange/settings
  keys absent, missing required key raises KeyError, Mobi Control-IQ+
  passthrough) and DEFAULT_EVENT_IDS regression (55 ids, no dupes,
  477/480/486 present).

Fixtures are inline and trimmed from the real captured responses.
2026-07-01 01:26:22 +00:00
James Woglom 4c548203a5 Migrate metadata consumers to BFF pump_metadata()
Switch choose_device, check, update_profiles, process.py and cli_helpers
from the old pump_event_metadata() (reportsfacade) to the normalized
pump_metadata() (BFF): tconnectDeviceId -> deviceId (UUID), and
lastUpload.settings -> settings.

Handle the BFF returning never-uploaded pumps (maxDateWithEvents=None):
skip them in the most-recent auto-select and fall back to the first
pump; raise a clear NoDevicesFound on an empty account instead of an
opaque TypeError. Clean stale comments/docstrings in tandemsource.py.

Tests: add tests/sync/tandemsource/test_choose_device.py (11 cases:
explicit/auto/never-uploaded/empty/InvalidSerialNumber/stale-warning)
and tests/api/test_tandemsource.py (pump_metadata adapter mapping),
and update the test_process fixture to the new keys.
2026-07-01 01:18:52 +00:00
James Woglom 5c5449d5bd Add normalized PumpMetadata model + BFF adapter
Introduce a typed PumpMetadata TypedDict and pump_metadata()/
_bff_pump_to_metadata() that adapt get_pumper().pumps[] into the stable
shape the sync code needs. Maps the new BFF fields to normalized names:
assignmentId -> deviceId (UUID), maxDateOfEvents -> maxDateWithEvents,
availableDataRange.start -> minDateWithEvents, settings.details ->
settings. Verified against the captured account response (7 pumps).

Additive only; consumers are migrated off pump_event_metadata() in a
follow-up commit.
2026-07-01 01:04:16 +00:00
James Woglom 8761e70b18 Add get_pumper() BFF metadata method with typed response
Add TandemSourceApi.get_pumper() for GET api/reports/bff/pumper/{pumperId},
the new device-list endpoint that replaces pumpeventmetadata. Adds
strictly-typed TypedDicts (BffPumper, BffPump, AvailableDataRange,
PumpSettingsEnvelope) derived from the captured account response; verified
they exactly cover the real JSON keys. pumps[].assignmentId is the UUID
device id for the pump-logs endpoint; settings.details (typed as dict for
now) will be modeled by PumpSettings in a later step. Nullable/absent
fields use total=False + Optional.
2026-07-01 01:02:23 +00:00
James Woglom f124b884b2 Harden id_token audience validation in extract_jwt
After the client_id change, extract_jwt still validates the id_token
audience against TDC_OIDC_CLIENT_ID (the OIDC-standard case). But since
the token exchange wasn't captured, we can't be certain Tandem sets
aud=client_id on the id_token. If it doesn't, fall back to decoding with
verify_aud disabled (signature + issuer still verified) and log a
warning, rather than failing login outright.
2026-07-01 01:00:30 +00:00
James Woglom d568a5be85 Update US Tandem Source OIDC client_id
The Tandem Source web app now authenticates with client_id
0oa4wnbvtladeyVZX4h7 (US); the old 0oa27ho9tpZE9Arjy4h7 no longer
appears in the current build. This value is used both for the OIDC
authorize/token requests and the id_token audience check, which stay
consistent. EU client_id left unchanged (no EU capture to verify).
2026-07-01 00:59:14 +00:00
James Woglom 654ee7ead3 Update DEFAULT_EVENT_IDS to the live 55-ID pump-logs list
Match the Tandem Source web app's getLogIDList() as observed in the live
GET api/reports/bff/pump-logs request (from the captured HAR). Adds the
FSL3 event ids 477 (join), 480 (data), 486 (stop); reorders to match the
frontend. No removals (was 52 ids, now 55).
2026-07-01 00:53:11 +00:00
James Woglom 9619174958 CI: run on dev branch (replaces develop)
The develop branch was replaced by dev, so point the push/pull_request
triggers at dev instead of the now-removed develop branch.
2026-07-01 00:27:19 +00:00
James Woglom a9c9a7ebd9 Add type annotations to TandemSource sync processors
Annotate the 11 event processors, the ProcessTimeRange orchestrator, and
UpdateProfiles. The shared processor interface is now typed:
__init__(tconnect, nightscout, tconnect_device_id, pretend, features),
enabled() -> bool, process(events, time_start, time_end) -> List[dict],
write(ns_entries) -> int. Converter helpers return Optional[dict] since
they fall through to None on type mismatch.

ProcessTimeRange.process() returns Tuple[int, Optional[int]] and its
tconnectDevice param reuses the PumpEventMetadata TypedDict. Client
params (TConnectApi/NightscoutApi/BaseEvent) use TYPE_CHECKING-guarded
imports with string forward refs to avoid import cycles at runtime.
2026-06-30 23:29:17 +00:00
James Woglom 8fd9bfcf72 Add type annotations to NightscoutApi
Annotate NightscoutApi methods and the module-level date helpers. Adds a
DateLike alias (str | datetime | arrow.Arrow) for the timestamp/filter
params. Writers return None; the last_uploaded_* getters return
Optional[dict]; api_status/current_profile return dict.

Response shapes are kept as loose dict/Optional[dict] rather than
TypedDicts since the Nightscout API shape varies across versions; only
inputs (which we control) are tightly typed.
2026-06-30 23:20:35 +00:00
James Woglom 000bae38b6 Add precise type annotations to TandemSourceApi
Annotate method signatures and dynamic JSON response types. Adds
TypedDicts for the responses whose shapes are confirmed from call sites
and logs:
- PumpEventMetadata (+ nested LastUpload) for pump_event_metadata()
- JwtClaims for the decoded id_token stored on jwtData; pumperId and
  accountId are UUID strings, not ints

pump_events_raw() returns a base64 str (not Any). pumper_info() stays
Any since it has no callers and its shape is never logged. TypedDict is
imported with a typing_extensions fallback for Python 3.7.
2026-06-30 23:14:06 +00:00
ClaudeandJames Woglom ea7cc8f4ec Remove dead code for legacy pre-Tandem Source APIs
Since the 2.0 migration to Tandem Source, the live sync path
(api.tandemsource + sync/tandemsource/*) no longer references the
legacy t:connect APIs. This removes that now-unreachable code.

Removed modules:
- api/controliq.py, api/ws2.py, api/android.py, api/webui.py
  (the legacy controliq / tconnectws2 / android / webui clients)
- process.py (old process_time_range; already broken since it
  imported sync submodules that no longer exist)
- parser/ciq_therapy_events.py, parser/tconnect.py (TConnectEntry)
- domain/therapy_event.py, domain/bolus.py, domain/device_settings.py,
  domain/utility.py

Trimmed dead wiring from live modules:
- api/__init__.py: dropped the controliq/ws2/android/webui properties,
  keeping only the tandemsource accessor
- check.py: removed the unused TConnectEntry import
- parser/nightscout.py: removed the unused legacy profile_store() plus
  the now-orphaned tandem_to_ns_time / tandem_to_ns_time_seconds helpers
  and InvalidTimeException (the live path uses tandemsource_profile_store)

Tests: removed suites covering the deleted modules; pared tests/api/fake.py
down to the TConnectApi fake still used by the tandemsource tests. README
"Tandem APIs" sections updated to reflect the single Tandem Source API.

Full test suite passes (48 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQNn3mBG1kXTAdQb9c2jfW
2026-06-30 19:12:44 -04:00
James Woglom e5195b2613 Fix WAF 403 by sending same-origin Origin/Referer to Source API
api_headers() hardcoded Origin/Referer to tconnect.tandemdiabetes.com,
but requests target SOURCE_URL (source.tandemdiabetes.com /
source.eu.tandemdiabetes.com). The WAF enforces same-origin and
returned HTTP 403 ("The request is blocked"). Derive Origin/Referer
from SOURCE_URL so both US and EU regions match.
2026-06-30 22:56:26 +00:00
Beshoy GirgisandJames Woglom f0f94baf02 revert nightscout.py 2026-03-27 11:08:23 -07:00
Beshoy GirgisandJames Woglom bf894bce02 Add script to update events.json from minified url 2026-03-27 11:08:23 -07:00
Beshoy GirgisandJames Woglom 594ee19450 Add Freestyle Libre 3 (FSL3) CGM sensor support
Implement FSL3 sensor integration alongside G6, G7, and FSL2 with expanded
CGM alert codes, improved datetime handling, and test coverage.

**FSL3 Event Integration:**
- Add FSL3 events to CGM_READING, _CGM_JOIN, and _CGM_STOP event classes
- Enable unified processing of FSL3 alongside existing sensor variants:
  - Event 480: LID_CGM_DATA_FSL3
  - Event 477: LID_CGM_JOIN_SESSION_FSL3
  - Event 486: LID_CGM_STOP_SESSION_FSL3

**CGM Alert Enumeration:**
- Expand CGM alert codes from 8 to 18 mapped codes
- Add verified alert codes: 1, 2, 3, 8, 12, 22, 25, 45, 46, 48
- Rename alert 51 to CONTROL_IQ_LOW (was DEFAULT_ALERT_51)
- Add alert descriptions based on pump history verification

**Code Quality Improvements:**
- Fix duplicate enum keys in events.json (Sensor Type codes 12, 13)
- Add logging infrastructure to generic.py for event diagnostics
- Add conftest.py test configuration

**DateTime and API Improvements:**
- Fix format_datetime() for proper UTC conversion with Z suffix
- Simplify Nightscout API methods by removing redundant retry logic
- Improve error reporting in last_uploaded_entry() and last_uploaded_bg_entry()

**Test Coverage:**
- Add 3 FSL3 test cases with real pump data
- Test single reading processing, multiple readings, and JOIN event parsing
- All 105 tests passing (102 existing + 3 new FSL3 tests)
2026-03-27 11:08:23 -07:00
James Woglom d78d70adf5 fix #136 2026-03-26 03:56:47 -04:00
James Woglom 8381e50fe6 fix duplicate enum keys 2026-03-15 13:53:03 -04:00
James Woglom 93b0901aae Update events.json blob 2026-03-15 13:35:43 -04:00
Alessio RosiandJames Woglom 82ccaa5a88 fix: upgrade Python 3.9 → 3.11 to fix missing python-dotenv 2026-03-15 02:22:10 -04:00
James Woglom b1f63f8858 v2.3.5 2026-03-05 19:22:32 -05:00
James Woglom 9878e83755 exclude venv from flake8 2026-03-05 19:17:12 -05:00
James Woglom d8b57fe788 remove duplicate pipfile definitions also in setup.cfg, fix actions 2026-03-05 19:13:21 -05:00
James Woglom 664f97be35 pin pyjwt in setup.cfg 2026-03-05 18:54:08 -05:00
James Woglom 9094976085 update for py3.8 2026-03-05 18:50:11 -05:00
James Woglom b592f6b464 ci 3.8: PyO3 modules compiled for CPython 3.8 or older may only be initialized once per interpreter process 2026-03-05 03:00:20 -05:00
James Woglom 4f2affe70f switch from pkg_resources to importlib_metadata 2026-03-05 02:54:45 -05:00
James Woglom 0733531f67 add setuptools dep 2026-03-05 02:50:33 -05:00
James Woglom 68dcb25911 v2.3.4 2025-12-14 23:59:06 -05:00
James Woglom 795361c6ad timezone tests 2025-12-14 23:58:35 -05:00
James Woglom c9190df50a timestamp override in tests 2025-12-14 23:56:30 -05:00
James Woglom 15160537f5 add process_cgm_reading tests 2025-12-14 23:52:55 -05:00
Andy LowandJames Woglom 1f1e755503 Add timezone support to timestamp calculation
Update timestamp handling to include timezone information.
2025-12-14 23:51:35 -05:00
James Woglom aea3f6bd6b v2.3.3 2025-11-23 00:03:34 -05:00
James Woglom 2bf40cac28 fix TypeError: can't compare offset-naive and offset-aware datetimes 2025-11-23 00:03:16 -05:00
James Woglom 73dabe5ead v2.3.2 2025-11-18 14:01:18 -05:00
James Woglom e848a8482d add new process test for tz change 2025-11-18 14:00:54 -05:00
James Woglom 9c06efb045 when pump TZ changes moves date into the future, do not add "current" basal to extend to that timestamp 2025-11-18 13:59:17 -05:00
James Woglom 64aeb4d752 v2.3.1 2025-06-08 17:17:13 -04:00
James Woglom 6a9a426b97 explicit time_start/time_end for autoupdate 2025-06-08 11:07:57 -04:00
samfundevandJames Woglom 5a76a4cfd1 Fix auto-update only running for 24 hours
Fixes #11
2025-06-08 11:06:24 -04:00
James Woglom 4837ac1e86 v2.3.0 2025-06-07 23:34:42 -04:00
James Woglom 8b15ec5c90 add test_profile_data 2025-06-07 23:31:10 -04:00
James Woglom 37b008f6c3 fix test path 2025-06-07 23:29:09 -04:00
James Woglom 8c40fdb533 fix test import 2025-06-07 23:27:42 -04:00
James Woglom d51080f6d0 Fix profile test 2025-06-07 23:24:41 -04:00
James Woglom b32415aaac Delete dead non-tandem source tests 2025-06-07 23:21:38 -04:00
James Woglom ffd3c5b1ea Delete dead non-tandem source code 2025-06-07 23:21:18 -04:00
James Woglom 0b4674bae9 update check script 2025-06-07 23:19:23 -04:00
James Woglom a1202decbe ignore name never assigned in scope flake8 2025-06-07 22:53:18 -04:00
Adam BovillandJames Woglom 4888426034 feat: Add EU region support for Tandem t:connect API
Add comprehensive support for European Tandem t:connect servers alongside existing US support.

Features:
- Region parameter in TandemSourceApi (default: US, supports: US/EU)
- EU-specific API endpoints and client ID configuration
- Region-aware credential caching to prevent cross-region conflicts
- Command line --region flag and TCONNECT_REGION environment variable
- Full backward compatibility (defaults to US region)

Technical Changes:
- Separated common SSO URLs from region-specific service URLs
- Added region validation and URL property methods
- Enhanced credential cache with region isolation
- Updated CLI argument parsing and configuration system
- Added comprehensive logging for region selection

Testing:
- Verified US region backward compatibility
- Successfully tested EU authentication and data retrieval
- Processed real EU pump data (1500+ events, 39KB)
- Validated all event types: basal, bolus, CGM, user modes, alarms
- Confirmed Nightscout integration compatibility

This enables EU Tandem pump users to sync their data using:
  --region EU or TCONNECT_REGION=EU
2025-06-07 22:48:43 -04:00
James Woglom 803b15b886 v2.2.4 2025-05-06 13:14:01 -04:00
JoshandJames Woglom 9e78fe770f chore: add main py for module finding in vscode 2025-05-06 13:13:19 -04:00
JoshandJames Woglom dfb39e07ef warn: add warning if selected pump serial has no recent events 2025-05-06 13:13:19 -04:00
JoshandJames Woglom 9fad0d71ef chore: update gitignore with venv and vscode dirs 2025-05-06 13:13:19 -04:00
James Woglom f5cd7c0151 v2.2.3 2025-03-29 23:37:15 -04:00
James Woglom 13bb106924 defaults: failure time to 75 min, turn off auto restart on failure 2025-03-29 23:36:53 -04:00
James Woglom fd96b08aab stop running pipenv check 2025-01-15 19:14:20 -05:00
James Woglom 4a8da39b44 process: automatically fetch all event types for devicestatus feature 2025-01-15 18:56:36 -05:00
James Woglom 7a0f9477f2 eventparser: add todict function on all event classes 2025-01-15 18:56:08 -05:00
James Woglom 934d90e3b9 log found ProcessDeviceStatus 2025-01-02 20:05:31 -05:00
134 changed files with 14043 additions and 10545 deletions
+3 -3
View File
@@ -38,13 +38,13 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
@@ -55,7 +55,7 @@ jobs:
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
+4 -4
View File
@@ -7,11 +7,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Set up Python 3.9
uses: actions/setup-python@v1
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.9
python-version: '3.11'
- name: Install pypa/build
run: >-
+32 -30
View File
@@ -5,9 +5,9 @@ name: Python package
on:
push:
branches: [ master, develop ]
branches: [ master, dev ]
pull_request:
branches: [ master, develop ]
branches: [ master, dev ]
jobs:
build:
@@ -25,40 +25,43 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest pipenv
pipenv install --system
- name: Run pipenv check
run: |
# DDoS attacks in wheel and setuptools packages, not relevant
# root certificate store, not relevant
pipenv check \
--ignore 51499 \
--ignore 52495 \
--ignore 52365 \
--ignore 59956 \
--ignore 58755 \
--ignore 67895 \
--ignore 61893 \
--ignore 61601 \
--ignore 62044 \
--ignore 67599 \
--ignore 72083 \
--ignore 71064 \
--ignore 71608 \
--ignore 72236
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -e . flake8 pytest coverage mypy
# - name: Run pipenv check
# run: |
# # DDoS attacks in wheel and setuptools packages, not relevant
# # root certificate store, not relevant
# pipenv check \
# --ignore 51499 \
# --ignore 52495 \
# --ignore 52365 \
# --ignore 59956 \
# --ignore 58755 \
# --ignore 67895 \
# --ignore 61893 \
# --ignore 61601 \
# --ignore 62044 \
# --ignore 67599 \
# --ignore 72083 \
# --ignore 71064 \
# --ignore 71608 \
# --ignore 72236
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
.venv/bin/flake8 . --exclude=.venv --count --select=E9,F63,F7,F82 --ignore=F824 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
.venv/bin/flake8 . --exclude=.venv --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Type-check annotated modules with mypy
run: |
.venv/bin/mypy
- name: Run tconnectsync --help
run: |
tconnectsync --help
.venv/bin/tconnectsync --help
- name: Test with pytest
run: |
pytest
.venv/bin/pytest
- name: Check codecov configuration
run: |
curl -X POST --data-binary @.codecov.yml https://codecov.io/validate
@@ -69,8 +72,7 @@ jobs:
fi
- name: Generate Coverage Report
run: |
pip install coverage
coverage run -m unittest
.venv/bin/coverage run -m unittest
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v1
with:
+3 -1
View File
@@ -8,4 +8,6 @@ build
*.egg-info
.env
tconnectsync-check-output.log
ignore_*
ignore_*
.venv/
.vscode/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.9-slim as base
FROM python:3.11-slim as base
# The following is adapted from:
# https://sourcery.ai/blog/python-docker/
+6 -15
View File
@@ -5,26 +5,17 @@ verify_ssl = true
[dev-packages]
ptpython = "*"
flake8 = "*"
pytest = "*"
coverage = "*"
mypy = "*"
[packages]
tconnectsync = {path = "."}
bs4 = "*"
arrow = "==1.2.3"
lxml = "*"
python-dotenv = "==0.21.1"
requests-mock = "*"
pysocks = "*"
urllib3 = "==1.26.6"
requests = {extras = ["socks"], version = "==2.31.0"}
requests-oidc = "*"
PyJWT = "==2.8.0"
cryptography = "*"
dataclasses-json = "*"
cffi = ">=1.15.1"
typing-extensions = "*"
[scripts]
tconnectsync = "python3 main.py"
test = "python3 -m unittest discover -vv"
build_events = "bash -c 'cd tconnectsync/eventparser && python3 build_events.py > events.py'"
lint = "bash -c 'flake8 . --count --select=E9,F63,F7,F82 && flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 && echo PASS'"
lint = "bash -c 'flake8 . --count --select=E9,F63,F7,F82 && flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 && echo PASS'"
typecheck = "mypy"
Generated
+667 -431
View File
File diff suppressed because it is too large Load Diff
+40 -8
View File
@@ -89,6 +89,9 @@ You should specify the following parameters:
TCONNECT_EMAIL='email@email.com'
TCONNECT_PASSWORD='password'
# OPTIONAL: Your region (US or EU)
TCONNECT_REGION=US
# URL of your Nightscout site
NS_URL='https://yournightscouturl/'
# Your Nightscout API_SECRET value
@@ -99,6 +102,7 @@ TIMEZONE_NAME='America/New_York'
# OPTIONAL: Your pump's serial number (numeric)
PUMP_SERIAL_NUMBER=11111111
```
This file contains your t:connect username and password, Tandem pump serial number (which is utilized in API calls to t:connect), your Nightscout URL and secret token (for uploading data to Nightscout), and local timezone (the timezone used in t:connect). When specifying the timezone, enter a [TZ database name value](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
@@ -377,6 +381,36 @@ An example `run.sh` if you built tconnectsync locally:
docker run tconnectsync --auto-update
```
#### Tuning Auto-Update
These optional environment variables control how `--auto-update` polls and how
it behaves when things go wrong. The defaults are sensible; you generally only
need these if you are seeing too many (or too few) restarts.
| Variable | Default | What it does |
| --- | --- | --- |
| `AUTOUPDATE_DEFAULT_SLEEP_SECONDS` | `300` | Poll interval when no better estimate is available. Also the ceiling for the retry backoff below. |
| `AUTOUPDATE_MAX_SLEEP_SECONDS` | `1500` | Upper bound on the adaptive poll interval, regardless of how rarely new data appears. |
| `AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS` | `60` | How long to wait when new data is overdue based on the pump's previous cadence. |
| `AUTOUPDATE_USE_FIXED_SLEEP` | `false` | Set true to always sleep `AUTOUPDATE_DEFAULT_SLEEP_SECONDS` instead of adapting to the pump's observed upload cadence. |
| `AUTOUPDATE_API_FAILURE_MINUTES` | `45` | Exit with a non-zero code after this many minutes of unbroken API/network failure, so your container platform restarts tconnectsync and can alert you. Set `0` to never exit. |
| `AUTOUPDATE_NO_DATA_FAILURE_MINUTES` | `180` | Log an error if the pump has not reported new events for this long. Usually means the pump simply is not uploading. |
| `AUTOUPDATE_FAILURE_MINUTES` | `75` | Log an error if events are appearing but no data has synced successfully for this long. |
| `AUTOUPDATE_RESTART_ON_FAILURE` | `false` | Whether the two watchdogs above also exit non-zero. Independent of `AUTOUPDATE_API_FAILURE_MINUTES`. |
| `AUTOUPDATE_MAX_LOOP_INVOCATIONS` | `-1` | Stop after this many poll cycles. `-1` means run forever; mainly useful for testing. |
**On failures and restarts.** Transient errors (DNS blips, timeouts, HTTP 404/502/503
from Tandem) do not crash tconnectsync. It retries with a growing backoff — 30s,
60s, 120s, 240s, then holding at `AUTOUPDATE_DEFAULT_SLEEP_SECONDS` — and resets
as soon as a poll succeeds. Staying in-process matters: an exit discards the
cached credentials, so a restart loop means a fresh login on every attempt,
which risks tripping Tandem's rate limiting.
Only once the API has been failing continuously for `AUTOUPDATE_API_FAILURE_MINUTES`
does tconnectsync give up and exit, so that a genuine outage surfaces (roughly one
restart per hour) instead of disappearing into an endless quiet retry. Invalid
credentials are never retried — they exit immediately, since retrying cannot help.
### Running with Cron
If you choose not to run tconnectsync with `--auto-update` continuously,
@@ -415,13 +449,11 @@ If main.py doesn't exist in `C:\Users\<USERNAME>\AppData\Local\Programs\Python\<
## Tandem APIs
This application utilizes three separate Tandem APIs for obtaining t:connect data, referenced here by the identifying part of their URLs:
As of version 2.0, tconnectsync retrieves all of its data from a single Tandem API, [**tandemsource**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/tandemsource.py), which powers [Tandem Source](https://source.tandemdiabetes.com/). After logging in, tconnectsync fetches the list of pumps on the account along with a stream of raw pump event data, which is decoded locally (see [`tconnectsync/eventparser`](https://github.com/jwoglom/tconnectsync/tree/master/tconnectsync/eventparser)) to extract basal, bolus, CGM, and other pump events.
* [**controliq**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/controliq.py) - Contains Control:IQ related data, namely a timeline of all Basal events uploaded by the pump, separated by type (temp basals, algorithmically-updated basals, or profile-updated basals). Additionally includes CGM and Bolus data.
* [**android**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/android.py) - Used internally by the t:connect Android app, these API endpoints were discovered by reverse-engineering the Android app. Most of the API endpoints are used for uploading pump data, and tconnectsync uses one endpoint which returns the most recent event ID uploaded by the pump, so we know when more data has been uploaded.
* [**tconnectws2**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/ws2.py) - More legacy than the others, this seems to power the bulk of the main t:connect website. It is used as a last resort due to severe performance issues with this API (see https://github.com/jwoglom/tconnectsync/issues/43). We can use it to retrieve a CSV export of non-ControlIQ basal data, as well as bolus and IOB data. It is only used for bolus data as a fallback, and for pump-reported IOB data if requested. Full tracking of pump events also uses a limited version of this API.
> Earlier versions of tconnectsync (1.x) instead used three separate legacy t:connect APIs (`controliq`, `android`, and `tconnectws2`). Those APIs — and the code supporting them — were removed once t:connect was shut down in favor of Tandem Source.
I have only tested tconnectsync with a Tandem pump set in the US Eastern timezone. Tandem's (to us, undocumented) APIs are [a bit loose with timezones](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/parser.py#L15), so please let me know if you notice any timezone-related bugs.
I have only tested tconnectsync with a Tandem pump set in the US Eastern timezone. Tandem's (to us, undocumented) APIs are a bit loose with timezones, so please let me know if you notice any timezone-related bugs.
## Backfilling t:connect Data
To backfill existing t:connect data in to Nightscout, you can use the `--start-date` and `--end-date` options. For example, the following will upload all t:connect data between January 1st and March 1st, 2020 to Nightscout:
@@ -434,14 +466,14 @@ In order to bulk-import a lot of data, you may need to use shorter intervals, an
One oddity when backfilling data is that the Control:IQ specific API endpoints return errors if they are queried before you updated your pump to utilize Control:IQ. This is [partially worked around in tconnectsync's code](https://github.com/jwoglom/tconnectsync/blob/d841c3811aeff3671d941a7d3ff4b80cce6a219e/main.py#L238), but you might need to update the logic if you did not switch to a Control:IQ enabled pump immediately after launch.
## t:connect API Testing
## Tandem Source API Testing
To test t:connect API endpoints in a Python shell, you can do something like the following:
To test Tandem Source API endpoints in a Python shell, you can do something like the following:
```python
import tconnectsync
tconnectsync.util.cli.enable_logging()
api = tconnectsync.util.cli.get_api()
# Make API calls, e.g.
therapy_timeline = api.controliq.therapy_timeline('2022-08-01', '2022-08-10')
pumps = api.tandemsource.pump_event_metadata()
```
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Sync event definitions from Tandem's official webapp and regenerate event classes.
Extracts the complete events.json from the JSON.parse() statement embedded in
Tandem's reports module JavaScript, updates local events.json, and regenerates
events.py with all event class definitions.
Usage:
python3 scripts/sync_tandem_events.py [--output FILE] <URL>
Example:
python3 scripts/sync_tandem_events.py \\
https://modules.us.tandemdiabetes.com/webapp/modules/reports-module/v1.8.0/2451.97042bc1.chunk.js
"""
import sys
import json
import subprocess
import requests
from pathlib import Path
DEFAULT_EVENTS_FILE = "tconnectsync/eventparser/events.json"
DEFAULT_GENERATOR = "build_events.py"
def fetch_module(url):
"""Fetch the minified JavaScript module from Tandem."""
print(f"Fetching Tandem module...", file=sys.stderr)
response = requests.get(url, timeout=30)
response.raise_for_status()
content = response.text
print(f"Fetched {len(content):,} bytes", file=sys.stderr)
return content
def extract_events_json_from_parse(js_content):
"""
Extract the complete events.json from the JSON.parse() statement.
Finds: JSON.parse('{"events":{...}}')
And returns the parsed events dictionary.
"""
start = js_content.find("JSON.parse('")
if start < 0:
return None
start += len("JSON.parse('")
# Find the matching closing brace
brace_count = 0
end = start
escape_next = False
for i in range(start, len(js_content)):
char = js_content[i]
if escape_next:
escape_next = False
continue
if char == '\\':
escape_next = True
continue
if char == '{':
brace_count += 1
elif char == '}':
brace_count -= 1
if brace_count == 0:
end = i + 1
break
if brace_count != 0:
return None
json_str = js_content[start:end]
# Unescape the string
json_str = json_str.replace('\\"', '"')
try:
return json.loads(json_str)
except json.JSONDecodeError:
return None
def load_existing_events(filepath):
"""Load the existing events.json file."""
if not Path(filepath).exists():
return {"events": {}}
try:
with open(filepath, 'r') as f:
data = json.load(f)
print(f"Loaded {len(data.get('events', {}))} existing events", file=sys.stderr)
return data
except Exception as e:
print(f"Warning: Could not load existing events.json: {e}", file=sys.stderr)
return {"events": {}}
def merge_events(existing_data, extracted_data):
"""
Merge extracted events with existing events.
Keeps all existing events and updates/adds with extracted ones.
"""
existing = existing_data.get('events', {})
extracted = extracted_data.get('events', {})
before_count = len(existing)
# Add/update extracted events
existing.update(extracted)
after_count = len(existing)
added = after_count - before_count
if added > 0:
print(f"Added {added} new events from Tandem module", file=sys.stderr)
else:
print(f"Updated {len(extracted)} events from Tandem module", file=sys.stderr)
return existing_data
def write_events_file(filepath, data):
"""Write events.json with proper formatting."""
if 'events' in data:
data['events'] = {
k: data['events'][k]
for k in sorted(data['events'].keys(), key=lambda x: int(x))
}
filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
print(f"Wrote {len(data.get('events', {}))} events to {filepath}", file=sys.stderr)
def regenerate_events_py(events_json_path, generator_name):
"""Regenerate events.py from updated events.json."""
events_dir = Path(events_json_path).parent
generator_path = events_dir / generator_name
if not generator_path.exists():
print(f"⚠ Generator not found at {generator_path}", file=sys.stderr)
return False
print(f"Regenerating events.py...", file=sys.stderr)
try:
result = subprocess.run(
[sys.executable, generator_name],
cwd=str(events_dir),
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
# Write generated code to events.py
events_py = events_dir / 'events.py'
events_py.write_text(result.stdout)
print(f"✓ events.py regenerated ({len(result.stdout):,} bytes)", file=sys.stderr)
return True
else:
print(f"✗ Generator failed: {result.stderr}", file=sys.stderr)
return False
except subprocess.TimeoutExpired:
print("✗ Generator timed out", file=sys.stderr)
return False
except Exception as e:
print(f"✗ Error running generator: {e}", file=sys.stderr)
return False
def main():
import argparse
parser = argparse.ArgumentParser(
description='Sync event definitions from Tandem and regenerate events.py'
)
parser.add_argument('url', help='URL to Tandem JavaScript module')
parser.add_argument('--output', default=DEFAULT_EVENTS_FILE, help='Output events.json path')
parser.add_argument('--no-generate', action='store_true', help='Skip events.py regeneration')
args = parser.parse_args()
try:
js_content = fetch_module(args.url)
extracted_data = extract_events_json_from_parse(js_content)
if not extracted_data:
print("✗ Could not extract events.json from Tandem module", file=sys.stderr)
sys.exit(1)
extracted_events = extracted_data.get('events', {})
print(f"✓ Extracted {len(extracted_events)} events from Tandem module", file=sys.stderr)
existing_data = load_existing_events(args.output)
merged_data = merge_events(existing_data, extracted_data)
write_events_file(args.output, merged_data)
if not args.no_generate:
regenerate_events_py(args.output, DEFAULT_GENERATOR)
print(f"\n✓ Done", file=sys.stderr)
except requests.exceptions.RequestException as e:
print(f"✗ Network error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"✗ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
+15 -4
View File
@@ -1,9 +1,9 @@
[metadata]
name = tconnectsync
version = 2.2.2
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
description = Syncs Tandem Source (formerly t:connect) insulin pump data to Nightscout for the t:slim X2 and Tandem Mobi
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/jwoglom/tconnectsync
@@ -30,11 +30,13 @@ install_requires =
urllib3
requests
requests-oidc
PyJWT
cryptography
PyJWT==2.8.0
cryptography==43.0.1; python_version < "3.9"
cryptography; python_version >= "3.9"
dataclasses-json
cffi
typing-extensions
importlib-metadata; python_version < "3.8"
[options.packages.find]
where = .
@@ -45,3 +47,12 @@ exclude =
[options.entry_points]
console_scripts =
tconnectsync = tconnectsync:main
[mypy]
files =
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
+15 -12
View File
@@ -3,17 +3,17 @@ import datetime
import arrow
import argparse
import logging
import pkg_resources
import typing
# Required for cryptography lib in python 3.7
if sys.version_info < (3, 8):
import typing_extensions
typing.Protocol = typing_extensions.Protocol
from importlib_metadata import PackageNotFoundError, version
else:
from importlib.metadata import PackageNotFoundError, version
from .api import TConnectApi
from .process import process_time_range
from .autoupdate import Autoupdate
from .sync.tandemsource.autoupdate import TandemSourceAutoupdate
from .sync.tandemsource.choose_device import ChooseDevice as TandemSourceChooseDevice
from .sync.tandemsource.process import ProcessTimeRange as TandemSourceProcessTimeRange
@@ -25,6 +25,7 @@ try:
from .secret import (
TCONNECT_EMAIL,
TCONNECT_PASSWORD,
TCONNECT_REGION,
NS_URL,
NS_SECRET,
NS_SKIP_TLS_VERIFY,
@@ -38,8 +39,8 @@ except Exception as e:
try:
__version__ = pkg_resources.require("tconnectsync")[0].version
except Exception:
__version__ = version("tconnectsync")
except PackageNotFoundError:
__version__ = "UNKNOWN"
def parse_args(*args, **kwargs):
@@ -53,7 +54,8 @@ def parse_args(*args, **kwargs):
parser.add_argument('--auto-update', dest='auto_update', action='store_const', const=True, default=False, help='If set, continuously checks for updates from t:connect and syncs with Nightscout.')
parser.add_argument('--check-login', dest='check_login', action='store_const', const=True, default=False, help='If set, checks that the provided t:connect credentials can be used to log in.')
parser.add_argument('--features', dest='features', nargs='+', default=DEFAULT_FEATURES, choices=ALL_FEATURES, help='Specifies what data should be synchronized between tconnect and Nightscout.')
parser.add_argument('--tandem-source', dest='tandem_source', action='store_const', const=True, default=False, help='FOR TESTING: Use Tandem Source')
parser.add_argument('--tandem-source', dest='tandem_source', action='store_const', const=True, default=True, help=argparse.SUPPRESS) # no longer used
parser.add_argument('--region', dest='region', type=str, choices=['US', 'EU'], default=None, help='Tandem t:connect server region (US or EU). If not specified, uses TCONNECT_REGION from configuration or defaults to US.')
return parser.parse_args(*args, **kwargs)
@@ -85,6 +87,8 @@ def main(*args, **kwargs):
if time_end < time_start:
raise Exception('time_start must be before time_end')
# Determine region: command line arg takes precedence, then config, then default to US
region = args.region if args.region else TCONNECT_REGION
if TCONNECT_EMAIL == 'email@email.com':
logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.')
@@ -98,18 +102,18 @@ def main(*args, **kwargs):
else:
logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.')
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, region)
nightscout = NightscoutApi(NS_URL, NS_SECRET, skip_verify=NS_SKIP_TLS_VERIFY, ignore_conn_errors=NS_IGNORE_CONN_ERRORS)
# NOT YET MIGRATED
# if args.check_login:
# return check_login(tconnect, time_start, time_end)
if args.check_login:
return check_login(tconnect, time_start, time_end)
logging.warning("THIS VERSION OF TCONNECTSYNC READS DATA FROM TANDEM SOURCE, AND MAY CONTAIN BUGS!")
logging.info("You may notice different behavior compared to older versions which utilized t:connect data sources.")
logging.info("To report a bug or to get help, see https://github.com/jwoglom/tconnectsync/issues")
logging.info(f"Using Tandem t:connect region: {region}")
logging.info("Enabled features: " + ", ".join(args.features))
if args.check_login:
@@ -117,11 +121,10 @@ def main(*args, **kwargs):
if args.auto_update:
u = TandemSourceAutoupdate(secret)
sys.exit(u.process(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features))
sys.exit(u.process(tconnect, nightscout, args.pretend, features=args.features))
else:
tconnectDevice = TandemSourceChooseDevice(secret, tconnect).choose()
added, last_event_id = TandemSourceProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend=args.pretend, secret=secret, features=args.features).process(time_start, time_end)
# return exit code 0 if processed events
sys.exit(0 if added>0 else 1)
+4
View File
@@ -0,0 +1,4 @@
from . import main
if __name__ == "__main__":
main()
+9 -58
View File
@@ -1,25 +1,22 @@
import logging
from .android import AndroidApi
from .controliq import ControlIQApi
from .ws2 import WS2Api
from .webui import WebUIScraper
from .tandemsource import TandemSourceApi
from .. import secret
logger = logging.getLogger(__name__)
"""A wrapper for the three different t:connect API types."""
"""A wrapper for the Tandem Source API."""
class TConnectApi:
email = None
password = None
def __init__(self, email, password):
def __init__(self, email, password, region=None):
self.email = email
self.password = password
self._ciq = None
self._ws2 = None
self._android = None
self._webui = None
# A caller which does not pass a region (e.g. tconnectsync-heroku)
# must get the configured TCONNECT_REGION, not a hardcoded US
# default which would send EU accounts to the US endpoints (#152).
self.region = region or secret.TCONNECT_REGION
self._tandemsource = None
@property
@@ -27,53 +24,7 @@ class TConnectApi:
if self._tandemsource and not self._tandemsource.needs_relogin():
return self._tandemsource
logger.debug("Instantiating new TandemSourceApi")
logger.debug(f"Instantiating new TandemSourceApi for region {self.region}")
self._tandemsource = TandemSourceApi(self.email, self.password)
self._tandemsource = TandemSourceApi(self.email, self.password, self.region)
return self._tandemsource
@property
def controliq(self):
if self._ciq and not self._ciq.needs_relogin():
return self._ciq
logger.debug("Instantiating new ControlIQApi")
self._ciq = ControlIQApi(self.email, self.password)
return self._ciq
@property
def ws2(self):
if self._ws2:
return self._ws2
logger.debug("Instantiating new WS2Api")
# Trigger login or re-login via controliq api if necessary
# so userGuid can be accessed from it
self.controliq
self._ws2 = WS2Api(self._ciq.userGuid)
return self._ws2
@property
def android(self):
if self._android and not self._android.needs_relogin():
return self._android
logger.debug("Instantiating new AndroidApi")
self._android = AndroidApi(self.email, self.password)
return self._android
@property
def webui(self):
if self._webui and not self._webui.needs_relogin():
return self._webui
logger.debug("Instantiating new WebUIScraper")
self._webui = WebUIScraper(self.controliq)
return self._webui
-175
View File
@@ -1,175 +0,0 @@
import requests
import json
import urllib
import datetime
import csv
import base64
import arrow
import time
import logging
from bs4 import BeautifulSoup
from ..util import timeago
from .common import ApiException, ApiLoginException, parse_date, base_session
logger = logging.getLogger(__name__)
"""
The AndroidApi class contains methods which are queried in the t:connect
Android application. These methods are a part of the tdc API which require
Android specific credentials.
"""
class AndroidApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
OAUTH_TOKEN_PATH = 'cloud/oauth2/token'
OAUTH_SCOPES = 'cloud.account cloud.upload cloud.accepttcpp cloud.email cloud.password'
# These credentials are found in source code
ANDROID_API_USERNAME = base64.b64decode('QzIzMzFDRDYtRDQ1MC00OTVFLTlDMTktNjcyMTUyMzBDODVD').decode()
ANDROID_API_PASSWORD = base64.b64decode('dHo0MzNLVzVRREM5VjdmIXo2QF4ybyZZNlNHR1lo').decode()
# These credentials are used by tconnect web
TCONNECT_WEB_USERNAME = base64.b64decode('M0U2MzU3QkEtRjYyNS00REQyLUI2NUYtNEI1RTgxNDRBQTZG').decode()
TCONNECT_WEB_PASSWORD = base64.b64decode('cUMyaXFIc2w3OFFoR0RYdCpMenFwb1pxZTl3eHN6').decode()
ANDROID_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 12; Pixel 4a Build/SP2A.220305.012)'
# These tokens are separate from the "standard" tdcservices API
accessToken = None
accessTokenExpiresAt = None
refreshToken = None
refreshTokenExpiresAt = None
userId = None
patientObjectId = None
def __init__(self, email, password):
self.session = base_session()
self.login(email, password)
self._email = email
self._password = password
def login(self, email, password):
r = self.session.post(
self.BASE_URL + self.OAUTH_TOKEN_PATH,
{
'username': email,
'password': password,
'grant_type': 'password',
'scope': self.OAUTH_SCOPES
},
headers={
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent': self.ANDROID_USER_AGENT
},
auth=requests.auth.HTTPBasicAuth(self.ANDROID_API_USERNAME, self.ANDROID_API_PASSWORD)
)
if r.status_code != 200:
raise ApiLoginException(r.status_code, 'Received HTTP %s during login: %s' % (r.status_code, r.text))
j = r.json()
# tconnect web returns a null user
# if "user" not in j or not j["user"]:
# raise ApiException(r.status_code, 'No user details present in AndroidApi oauth response: %s' % r.text)
self.accessToken = j["accessToken"]
self.accessTokenExpiresAt = j["accessTokenExpiresAt"]
# NOTE: the refresh token is currently unused, instead a new access
# token is obtained from scratch by re-logging in when it expires.
if "refreshToken" in j and "refreshTokenExpiresAt" in j:
self.refreshToken = j["refreshToken"]
self.refreshTokenExpiresAt = j["refreshTokenExpiresAt"]
self.userId = j["user"]["id"]
logger.info("Logged in to AndroidApi successfully (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
def needs_relogin(self):
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
if not self.accessToken:
raise Exception('No access token')
return {'Authorization': 'Bearer %s' % self.accessToken}
def _get(self, endpoint, query={}, **kwargs):
r = self.session.get(self.BASE_URL + endpoint, data=query, headers={
'User-Agent': self.ANDROID_USER_AGENT,
'Content-Type': 'application/json',
**self.api_headers()
}, **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "Android API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
def get(self, endpoint, query={}, tries=0, **kwargs):
try:
return self._get(endpoint, query, **kwargs)
except ApiException as e:
if tries > 0:
raise ApiException(e.status_code, "Android API HTTP %s on retry #%d: %s" % (e.status_code, tries, e))
# Trigger automatic re-login, and try again once
if e.status_code == 401:
self.accessTokenExpiresAt = time.time()
self.login(self._email, self._password)
return self.get(endpoint, query, tries=tries+1, **kwargs)
if e.status_code == 500:
return self.get(endpoint, query, tries=tries+1, **kwargs)
raise e
def post(self, endpoint, query={}, **kwargs):
r = self.session.post(self.BASE_URL + endpoint, query, headers=self.api_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "Internal API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
"""
Returns the most recent event ID that was uploaded for the given pump.
{'maxPumpEventIndex': <integer>, 'processingStatus': 1}
"""
def last_event_uploaded(self, pump_serial_number):
return self.get('cloud/upload/getlasteventuploaded?sn=%d' % pump_serial_number)
"""
Returns user login information about a tconnect account.
{'firstName': <string>, 'lastName': <string>, 'birthDate': 'YYYY-MM-DDT00:00:00.000Z',
'emailAddress': <string>, 'secretQuestion': <string>, 'secretAnswer': <string>,
'secretQuestionId': <integer>}
"""
def patient_info(self):
return self.get('cloud/account/patient_info')
# TODO: these methods are used in the web app, not the Android app,
# but support the same auth tokens and are on this domain. They should
# be moved to a new Api class.
# 3/17/2022: the API appears to be more stringently checking scopes,
# and some of these endpoints no longer work with the API token scoped
# to the Android app.
"""
Returns BG and pump threshold values.
{'targetBGHigh': <integer>, 'targetBGLow': <integer>, 'hypoThreshold': <integer>,
'hyperThreshold': <integer>, 'siteChangeThreshold': <integer>,
'cartridgeChangeThreshold': <integer>, 'tubingChangeThreshold': <integer>}
"""
def therapy_thresholds(self):
return self.get('cloud/usersettings/api/therapythresholds?userId=%s' % self.userId)
"""
Returns therapy-related user information about a tconnect account.
{'userID': <string>, 'targetBgHigh': <integer>, 'targetBgLow': <integer>,
'hypoThreshold': <integer>, 'hyperThreshold': <integer>,
'dateOfBirth': 'YYYY-MM-DDT00:00:00', 'age': <integer>,
'patientFullName': <string>, 'caregiverDateOfBirth': <string>,
'hasCGM': <bool>, 'hasBASALIQ': <bool>, 'hasControlIQ': <bool>}
"""
def user_profile(self):
return self.get('cloud/usersettings/api/UserProfile?userId=%s' % self.userId)
+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)
-206
View File
@@ -1,206 +0,0 @@
import urllib
import arrow
import time
import logging
from bs4 import BeautifulSoup
from ..util import timeago, cap_length
from .common import parse_date, base_headers, base_session, ApiException, ApiLoginException
logger = logging.getLogger(__name__)
class ControlIQApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.17.2.3'
userGuid = None
accessToken = None
accessTokenExpiresAt = None
tconnect_software_ver = None
def __init__(self, email, password):
self.login(email, password)
self._email = email
self._password = password
def login(self, email, password):
logger.info("Logging in to ControlIQApi...")
with base_session() as s:
initial = s.get(self.LOGIN_URL, headers=base_headers())
soup = BeautifulSoup(initial.content, features='lxml')
data = self._build_login_data(email, password, soup)
req = s.post(self.LOGIN_URL, data=data, headers={'Referer': self.LOGIN_URL, **base_headers()}, allow_redirects=False)
# HTTP 200 is reported when credentials are incorrect
if req.status_code == 200:
login_error = self._find_login_error(req.text)
if not login_error:
login_error = 'Check your login credentials.'
raise ApiLoginException(None, 'Error logging in to t:connect: %s' % login_error)
if req.status_code != 302:
raise ApiLoginException(req.status_code, 'Error logging in to t:connect')
fwd = s.post(urllib.parse.urljoin(self.LOGIN_URL, req.headers['Location']), cookies=req.cookies, headers=base_headers())
if fwd.status_code != 200:
logger.warn("Received non-HTTP 200: %s" % req.text)
raise ApiException(fwd.status_code, 'Error retrieving t:connect login cookies.')
self.userGuid = req.cookies['UserGUID']
if 'accessToken' in req.cookies and 'accessTokenExpiresAt' in req.cookies:
self.accessToken = req.cookies['accessToken']
self.accessTokenExpiresAt = req.cookies['accessTokenExpiresAt']
logger.info("Logged in to ControlIQApi successfully via accessToken cookie (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
else:
logger.info("No accessToken cookie found when logging in to ControlIQApi. Triggering AndroidApi auth")
from .android import AndroidApi
android = AndroidApi(email, password)
self.accessToken = android.accessToken
self.accessTokenExpiresAt = android.accessTokenExpiresAt
logger.info("Logged in to AndroidApi successfully via accessToken param (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
self.loginSession = s
return True
def _build_login_data(self, email, password, soup):
try:
version = soup.select_one("#footer_version").text.strip()
self.tconnect_software_ver = version
logger.info("Reported tconnect software version: %s" % version)
if version != self.LAST_CONFIRMED_SOFTWARE_VERSION:
logger.warning("Newer API version than last confirmed working. Saw %s and expected %s" % (version, self.LAST_CONFIRMED_SOFTWARE_VERSION))
logger.warning("If you experience any issues, please report them to https://github.com/jwoglom/tconnectsync")
except Exception:
logger.warning("Unable to find tconnect software version.")
contents = "<unknown>"
if soup:
contents = "%s" % soup.encode_contents()
if len(contents) > 1000:
contents = "%s[SNIP]%s" % (contents[:500], contents[-500:])
logger.info("BeautifulSoup parsed contents: %s" % contents)
pass
if not soup.select_one("#__VIEWSTATE"):
enc_contents = str(soup.encode_contents())
if "Web Page Blocked!" in enc_contents or "Attack ID:" in enc_contents:
logger.warn("Being ratelimited/blocked by web application firewall. Sleeping for 30 minutes before retrying.")
logger.info("BeautifulSoup parsed contents: %s" % enc_contents)
time.sleep(1800)
exit(1)
return {
"__LASTFOCUS": "",
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
"__EVENTARGUMENT": "",
"__VIEWSTATE": soup.select_one("#__VIEWSTATE")["value"],
"__VIEWSTATEGENERATOR": soup.select_one("#__VIEWSTATEGENERATOR")["value"],
"__EVENTVALIDATION": soup.select_one("#__EVENTVALIDATION")["value"],
"ctl00$ContentBody$LoginControl$txtLoginEmailAddress": email,
"txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (email, email, email),
"ctl00$ContentBody$LoginControl$txtLoginPassword": password,
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password)
}
def _find_login_error(self, text):
try:
soup = BeautifulSoup(text, features='lxml')
notice_error = soup.select_one(".notice_error").text.strip()
return notice_error
except Exception:
return None
def needs_relogin(self):
if not self.accessTokenExpiresAt:
return False
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
if not self.accessToken:
raise Exception('No access token provided')
return {
'Authorization': 'Bearer %s' % self.accessToken,
'Origin': 'https://tconnect.tandemdiabetes.com',
'Referer': 'https://tconnect.tandemdiabetes.com/',
**base_headers()
}
def _get(self, endpoint, query):
r = base_session().get(self.BASE_URL + endpoint, data=query, headers=self.api_headers())
if r.status_code != 200:
raise ApiException(r.status_code, "ControlIQ API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
def get(self, endpoint, query, tries=0):
try:
return self._get(endpoint, query)
except ApiException as e:
logger.warning("Received ApiException in ControlIQApi with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
if tries > 0:
raise ApiException(e.status_code, "ControlIQ API HTTP %d on retry #%d: %s", e.status_code, tries, e)
# Trigger automatic re-login, and try again once
if e.status_code == 401:
logger.info("Performing automatic re-login after HTTP 401 for ControlIQApi")
self.accessTokenExpiresAt = time.time()
self.login(self._email, self._password)
return self.get(endpoint, query, tries=tries+1)
if e.status_code == 500:
return self.get(endpoint, query, tries=tries+1)
raise e
"""
Returns detailed basal event information and reasons for delivery suspension.
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
"""
def therapy_timeline(self, start=None, end=None):
startDate = parse_date(start)
endDate = parse_date(end)
# Microsoft-Azure-Application-Gateway/v2 WAF error message appears
# if startDate and endDate are not specified in exactly this order.
return self.get('tconnect/controliq/api/therapytimeline/users/%s?startDate=%s&endDate=%s' % (self.userGuid, startDate, endDate), {})
"""
Returns a summary of pump and cgm activity.
{'averageReading': <integer>, 'timeInUseMinutes': <integer>, 'controlIqSetToOffMinutes': <integer>,
'cgmInactiveMinutes': <integer>, 'pumpInactiveMinutes': <integer>, 'averageDailySleepMinutes': <integer>,
'weeklyExerciseEvents': <integer>, 'timeInUsePercent': <integer>, 'controlIqOffPercent': <integer>,
'cgmInactivePercent': <integer>, 'pumpInactivePercent': <integer>, 'totalDays': <integer>}
"""
def dashboard_summary(self, start, end):
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('tconnect/controliq/api/summary/users/%s?startDate=%s&endDate=%s' % (self.userGuid, startDate, endDate), {})
"""
Returns active account features, including the date when ControlIQ was enabled.
[{"serialNumber": "11111111", "features": {"controlIQ": {"feature": 1, "dateTimeFirstDetected": "YYYY-MM-DD:THH:MM:SS", "unixTimestamp": 1111111111}}}]
"""
def pumpfeatures(self):
return self.get('tconnect/controliq/api/pumpfeatures/users/%s' % self.userGuid, {})
"""
Returns therapy events, used by the webui Therapy Timeline.
{'event': [
{'type': 'Basal', 'basalRate': ...},
{'type': 'Bolus', 'standard': ...},
{'type': 'CGM', 'egv': ...}
]}
"""
def therapy_events(self, start_date=None, end_date=None):
startDate = parse_date(start_date)
endDate = parse_date(end_date)
return self.get('tconnect/therapyevents/api/TherapyEvents/%s/%s/false?userId=%s' % (startDate, endDate, self.userGuid), {})
+386 -78
View File
@@ -9,38 +9,256 @@ import os
import jwt
import pickle
from typing import Any, Dict, Iterator, List, Optional, Tuple
try:
from typing import TypedDict
except ImportError: # Python 3.7
from typing_extensions import TypedDict
from requests_oidc import make_auth_code_session
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
from .common import parse_ymd_date, base_headers, base_session, ApiException, ApiLoginException
from ..secret import CACHE_CREDENTIALS, CACHE_CREDENTIALS_PATH
from ..eventparser.generic import Events, decode_raw_events, EVENT_LEN
from .. import secret
from ..secret import CACHE_CREDENTIALS, CACHE_CREDENTIALS_PATH, TIMEZONE_NAME
from ..eventparser.generic import Events
logger = logging.getLogger(__name__)
def naive_local_to_utc(value: Optional[str]) -> Optional[str]:
"""Normalize a BFF pump-local naive wall-clock timestamp to a true UTC
ISO-8601 string.
The BFF sends maxDateOfEvents / availableDataRange.start with no tz
(e.g. "2022-02-16T22:45:58") even though they are the pump's local
wall-clock time. Downstream consumers parse them with arrow.get(...),
which assumes UTC, and compare against arrow.utcnow() / time.time()
(real UTC), so we shift them here by interpreting the naive value in the
configured TIMEZONE_NAME and converting to UTC. Values that already
carry a tz (defensive; not seen for these two fields) are passed
through unchanged so we never double-shift. None passes through as None
(never-uploaded pumps).
"""
if not value:
return value
# If the string already carries a tz (a trailing 'Z' or a +HH:MM /
# -HH:MM offset after the time portion), trust it and never double-shift.
# Otherwise it's a naive pump-local wall-clock value: interpret it in the
# configured TIMEZONE_NAME. (Per the BFF data these two fields are always
# naive; the has-tz branch is purely defensive.)
time_part = value.split('T', 1)[-1]
has_tz = value.endswith('Z') or '+' in time_part or '-' in time_part
if has_tz:
parsed = arrow.get(value)
else:
parsed = arrow.get(value, tzinfo=TIMEZONE_NAME)
return parsed.to('UTC').isoformat()
class JwtClaims(TypedDict, total=False):
"""Decoded OIDC id_token claims stored on TandemSourceApi.jwtData.
pumperId and accountId are UUID strings (not ints); the *time/iat/exp/nbf
fields are unix timestamps.
"""
iss: str
nbf: int
iat: int
exp: int
aud: str
amr: List[str]
at_hash: str
sid: str
sub: str
auth_time: int
idp: str
email: str
tandem_roles: List[str]
roles: List[str]
accountId: str
pumperId: str
countrySubdivision: str
preferredLanguage: str
family_name: str
given_name: str
preferred_username: str
name: str
email_verified: bool
class AvailableDataRange(TypedDict):
"""`availableDataRange` on a BffPump. start/end are ISO-8601 datetime
strings, or null for a pump that has never uploaded."""
start: Optional[str]
end: Optional[str]
class PumpSettingsEnvelope(TypedDict):
"""`settings` on a BffPump. `details` is the full pump settings blob,
parsed by tconnectsync.domain.tandemsource.pump_settings.PumpSettings."""
id: str
deviceAssignmentId: str
uploadedTimeStamp: str
settingsHash: str
uploadId: str
details: dict
class BffPumpRequired(TypedDict):
"""Fields always present on a BffPump, even for a never-uploaded pump
(verified against a real captured GET api/reports/bff/pumper/{pumperId}
response).
`assignmentId` is the pump's UUID device id used as the path segment for
the pump-logs endpoint (replaces the old numeric tconnectDeviceId).
"""
assignmentId: str
serialNumber: str
modelNumber: str
modelName: str
softwareVersion: str
class BffPump(BffPumpRequired, total=False):
"""One element of BffPumper.pumps, from GET api/reports/bff/pumper/{pumperId}.
Extends BffPumpRequired with fields that are null or absent for
never-uploaded or retired pumps (settings, *Date*, lastUploadClientType,
glucoseUnit, availableDataRange.start/end), hence total=False. `algorithm`
is optional in the canonical BFF source (PumpAlgorithm | undefined) and so
must be accessed defensively.
"""
algorithm: Optional[str]
availableDataRange: AvailableDataRange
glucoseUnit: Optional[str]
lastUploadDate: Optional[str]
maxDateOfEvents: Optional[str]
partNumber: str
lastUploadClientType: Optional[str]
settings: Optional[PumpSettingsEnvelope]
class BffPumper(TypedDict, total=False):
"""Response of GET api/reports/bff/pumper/{pumperId} (the BFF device list
that replaces pumpeventmetadata)."""
firstName: str
lastName: str
name: str
dateOfBirth: str
lowGlucoseThreshold: int
highGlucoseThreshold: int
country: str
pumps: List[BffPump]
class PumpLogEvent(TypedDict):
"""One entry in a PumpLogsResponse (events[] or clockChanges[]) from
GET api/reports/bff/pump-logs/{deviceAssignmentId}. The server pre-decodes
each event, so eventProperties holds already-decoded per-event fields
(values are int/float/list/str keyed by camelCase field name).
pumpDateTime is the pump's local wall-clock time (ISO-8601, no tz);
estimatedDateTime is the same value with a 'Z' suffix. eventCode matches
the numeric event id in EVENT_IDS; sequenceNumber is the old seqNum.
"""
deviceAssignmentId: str
eventCode: int
sequenceGroup: int
sequenceNumber: int
pumpDateTime: str
eventProperties: Dict[str, Any]
estimatedDateTime: str
class PumpLogsResponse(TypedDict):
"""Response of GET api/reports/bff/pump-logs/{deviceAssignmentId}. Replaces
the old base64 reportsfacade/pumpevents payload. clockChanges (eventCodes
13/14) are returned separately and span the device's full history."""
events: List[PumpLogEvent]
clockChanges: List[PumpLogEvent]
class TandemSourceApi:
# Common URLs that are shared between regions
LOGIN_PAGE_URL = 'https://sso.tandemdiabetes.com/'
LOGIN_API_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/login'
TDC_AUTH_CALLBACK_URL = 'https://sso.tandemdiabetes.com/auth/callback'
TDC_OAUTH_AUTHORIZE_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/oauth2/v1/authorize'
TDC_OIDC_JWKS_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks'
TDC_OIDC_ISSUER = 'https://tdcservices.tandemdiabetes.com/accounts/api' # openid_config['issuer']
TDC_OIDC_CLIENT_ID = '0oa27ho9tpZE9Arjy4h7'
SOURCE_URL = 'https://source.tandemdiabetes.com/'
# US Region URLs (default)
_US_URLS = {
'LOGIN_API_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/login',
'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/oauth2/v1/authorize',
'TDC_OIDC_JWKS_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks',
'TDC_OIDC_ISSUER': 'https://tdcservices.tandemdiabetes.com/accounts/api',
'TDC_OIDC_CLIENT_ID': '0oa4wnbvtladeyVZX4h7',
'SOURCE_URL': 'https://source.tandemdiabetes.com/',
'REDIRECT_URI': 'https://sso.tandemdiabetes.com/auth/callback',
'TOKEN_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/token',
'AUTHORIZATION_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/authorize'
}
# EU Region URLs
_EU_URLS = {
'LOGIN_API_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/login',
'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/oauth2/v1/authorize',
'TDC_OIDC_JWKS_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks',
'TDC_OIDC_ISSUER': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api',
'TDC_OIDC_CLIENT_ID': '1519e414-eeec-492e-8c5e-97bea4815a10',
'SOURCE_URL': 'https://source.eu.tandemdiabetes.com/',
'REDIRECT_URI': 'https://source.eu.tandemdiabetes.com/authorize/callback',
'TOKEN_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/token',
'AUTHORIZATION_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/authorize'
}
def __init__(self, email, password):
def __init__(self, email: str, password: str, region: Optional[str] = None) -> None:
# No region means "use the configured TCONNECT_REGION": a hardcoded
# 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'.")
self._region_urls = self._US_URLS if self.region == 'US' else self._EU_URLS
self.login(email, password)
self._email = email
self._password = password
def login(self, email, password):
logger.info("Logging in to TandemSourceApi...")
@property
def LOGIN_API_URL(self) -> str:
return self._region_urls['LOGIN_API_URL']
@property
def TDC_OAUTH_AUTHORIZE_URL(self) -> str:
return self._region_urls['TDC_OAUTH_AUTHORIZE_URL']
@property
def TDC_OIDC_JWKS_URL(self) -> str:
return self._region_urls['TDC_OIDC_JWKS_URL']
@property
def TDC_OIDC_ISSUER(self) -> str:
return self._region_urls['TDC_OIDC_ISSUER']
@property
def TDC_OIDC_CLIENT_ID(self) -> str:
return self._region_urls['TDC_OIDC_CLIENT_ID']
@property
def SOURCE_URL(self) -> str:
return self._region_urls['SOURCE_URL']
def login(self, email: str, password: str) -> bool:
logger.info(f"Logging in to TandemSourceApi ({self.region} region)...")
if self.try_load_cached_creds(email):
logger.info("Successfully used cached credentials")
return True
@@ -70,18 +288,17 @@ class TandemSourceApi:
# oidc
client_id = self.TDC_OIDC_CLIENT_ID
redirect_uri = 'https://sso.tandemdiabetes.com/auth/callback' # must be an allowlisted URI
redirect_uri = self._region_urls['REDIRECT_URI']
scope = 'openid profile email'
token_endpoint = 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/token' #openid_config['token_endpoint']
token_endpoint = self._region_urls['TOKEN_ENDPOINT']
def generate_code_verifier():
def generate_code_verifier() -> str:
"""Generates a high-entropy code verifier."""
code_verifier = base64.urlsafe_b64encode(os.urandom(64)).decode('utf-8').rstrip('=')
return code_verifier
def generate_code_challenge(verifier):
def generate_code_challenge(verifier: str) -> str:
"""Generates a code challenge from the code verifier."""
sha256_digest = hashlib.sha256(verifier.encode('utf-8')).digest()
code_challenge = base64.urlsafe_b64encode(sha256_digest).decode('utf-8').rstrip('=')
@@ -91,7 +308,7 @@ class TandemSourceApi:
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
authorization_endpoint = 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/authorize' #openid_config['authorization_endpoint']
authorization_endpoint = self._region_urls['AUTHORIZATION_ENDPOINT']
oidc_step1_params = {
'client_id': client_id,
@@ -159,7 +376,7 @@ class TandemSourceApi:
return True
def extract_jwt(self):
def extract_jwt(self) -> None:
logger.debug("6. extracting JWT from %s" % self.idToken)
id_token = self.idToken
@@ -177,26 +394,48 @@ 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
# Decode and verify the ID Token
id_token_claims = jwt.decode(
id_token,
key=key,
algorithms=['RS256'],
audience=audience,
issuer=issuer,
)
# Decode and verify the ID Token. Per OIDC the id_token's `aud` equals
# the client_id, so validate it. But if Tandem ever issues an id_token
# with a different audience, fall back to skipping only the audience
# check (signature + issuer are still verified) rather than failing
# login outright.
id_token_claims: JwtClaims
try:
id_token_claims = jwt.decode(
id_token,
key=key,
algorithms=['RS256'],
audience=audience,
issuer=issuer,
)
except jwt.InvalidAudienceError:
logger.warning(
"id_token audience did not match client_id %s; decoding without audience verification",
audience,
)
id_token_claims = jwt.decode(
id_token,
key=key,
algorithms=['RS256'],
issuer=issuer,
options={"verify_aud": False},
)
logger.info("Decoded JWT: %s" % json.dumps(id_token_claims))
self.jwtData = id_token_claims
self.pumperId = id_token_claims['pumperId']
self.accountId = id_token_claims['accountId']
self.jwtData: JwtClaims = id_token_claims
self.pumperId: str = id_token_claims['pumperId']
self.accountId: str = id_token_claims['accountId']
def try_load_cached_creds(self, email):
def try_load_cached_creds(self, email: str) -> bool:
if not CACHE_CREDENTIALS:
return False
@@ -224,6 +463,12 @@ class TandemSourceApi:
logger.warning(f"Cached credentials are for a different email ({_saved_blob['cache_creds_email']} in cache, but using {email}), skipping")
return False
# Check if cached region matches current region
cached_region = _saved_blob.get('cache_creds_region', 'US') # Default to US for backward compatibility
if cached_region != self.region:
logger.warning(f"Cached credentials are for a different region ({cached_region} in cache, but using {self.region}), skipping")
return False
at_expiry = _saved_blob['accessTokenExpiresAt']
if arrow.get().int_timestamp >= arrow.get(at_expiry).int_timestamp:
logger.info(f"Cached credentials have expired ({_saved_blob['accessTokenExpiresAt']}), skipping")
@@ -237,7 +482,7 @@ class TandemSourceApi:
self.accessTokenExpiresAt = _saved_blob['accessTokenExpiresAt']
self.loginSession = _saved_blob['loginSession']
def est_time(t):
def est_time(t: arrow.Arrow) -> str:
now = arrow.get()
if now < t:
sec = (t - now).seconds
@@ -269,7 +514,7 @@ class TandemSourceApi:
return True
def cache_creds(self, email):
def cache_creds(self, email: str) -> None:
if not CACHE_CREDENTIALS:
logger.info("Credentials caching is disabled, skipping save")
return
@@ -278,6 +523,7 @@ class TandemSourceApi:
'cache_creds_version': 1.0,
'cache_creds_saved_at': arrow.get(),
'cache_creds_email': email,
'cache_creds_region': self.region, # Store the region in cache
'jwtData': self.jwtData,
'pumperId': self.pumperId,
'accountId': self.accountId,
@@ -297,24 +543,27 @@ class TandemSourceApi:
logger.info(f"Saved cached credentials to {CACHE_CREDENTIALS_PATH}")
def needs_relogin(self):
def needs_relogin(self) -> bool:
if not self.accessTokenExpiresAt:
return False
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
def api_headers(self) -> Dict[str, str]:
if not self.accessToken:
raise Exception('No access token provided')
return {
'Authorization': 'Bearer %s' % self.accessToken,
'Origin': 'https://tconnect.tandemdiabetes.com',
'Referer': 'https://tconnect.tandemdiabetes.com/',
# The WAF enforces same-origin: Origin/Referer must match SOURCE_URL
# (source.tandemdiabetes.com / source.eu.tandemdiabetes.com), otherwise
# it returns HTTP 403 ("The request is blocked").
'Origin': self.SOURCE_URL.rstrip('/'),
'Referer': self.SOURCE_URL,
**base_headers()
}
def _get(self, endpoint, query):
def _get(self, endpoint: str, query: dict) -> Any:
r = base_session().get(self.SOURCE_URL + endpoint, data=query, headers=self.api_headers())
if r.status_code != 200:
@@ -322,7 +571,7 @@ class TandemSourceApi:
return r.json()
def get(self, endpoint, query, tries=0):
def get(self, endpoint: str, query: dict, tries: int = 0) -> Any:
try:
return self._get(endpoint, query)
except ApiException as e:
@@ -333,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)
@@ -346,54 +595,113 @@ class TandemSourceApi:
"""
Returns information about the user and available pumps.
"""
def pumper_info(self):
# Response shape is undocumented and unused by callers, so it stays Any.
def pumper_info(self) -> Any:
return self.get('api/pumpers/pumpers/%s' % (self.pumperId), {})
"""
Returns metadata for pump events. Returns a list of dict's per-pump on the account.
[
{'tconnectDeviceId', 'serialNumber', 'modelNumber', 'minDateWithEvents', 'maxDateWithEvents', 'lastUpload', 'patientName', 'patientDateOfBirth', 'patientCareGiver', 'softwareVersion', 'partNumber'},
]
"""
def pump_event_metadata(self):
return self.get('api/reports/reportsfacade/%s/pumpeventmetadata' % (self.pumperId), {})
def get_pumper(self) -> BffPumper:
"""Returns the pumper's profile plus the list of pumps on the account
(BffPumper.pumps) from the new BFF endpoint. Replaces the old
reportsfacade pump-event-metadata endpoint: pumps[].assignmentId is the
UUID device id used by the pump-logs endpoint, and
pumps[].settings.details carries the pump settings blob."""
return self.get('api/reports/bff/pumper/%s' % (self.pumperId), {})
DEFAULT_EVENT_IDS = [229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,399,256,213,406,394,212,404,214,405,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191]
# Matches the Tandem Source web app's getLogIDList() (55 IDs) as observed in
# the live GET api/reports/bff/pump-logs request. Includes FSL3 ids 477/480/486.
DEFAULT_EVENT_IDS: List[int] = [229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,480,399,256,213,406,477,394,212,404,214,405,486,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191]
"""
Returns raw unparsed string for pump events
tconnect_device_id is "tconnectDeviceId" from pump_event_metadata()
"""
def pump_events_raw(self, tconnect_device_id, min_date=None, max_date=None, event_ids_filter=DEFAULT_EVENT_IDS):
def get_pump_logs(self, device_id: str, min_date: Optional[str] = None, max_date: Optional[str] = None, event_ids_filter: Optional[List[int]] = DEFAULT_EVENT_IDS) -> PumpLogsResponse:
"""Fetch pre-decoded pump events for a single date window from the BFF
endpoint GET api/reports/bff/pump-logs/{device_id}. device_id is the
UUID assignmentId (BffPump.assignmentId from get_pumper()). Returns
{events, clockChanges}.
The server caps the window at ~4 weeks; callers needing a longer range
must page by date window (see pump_events).
Note: the server currently ignores eventIds and returns every event in
the window regardless of the filter (verified against live accounts), so
the effective filtering happens client-side via EventClass dispatch. We
still send eventIds to mirror the web app and stay forward-compatible."""
minDate = parse_ymd_date(min_date)
maxDate = parse_ymd_date(max_date)
logger.debug(f'pump_events_raw({tconnect_device_id}, {minDate}, {maxDate})')
logger.debug(f'get_pump_logs({device_id}, {minDate}, {maxDate})')
# default: 229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,399,256,213,406,394,212,404,214,405,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191
eventIdsFilter = '%2C'.join(map(str, event_ids_filter)) if event_ids_filter else None
return self.get('api/reports/reportsfacade/pumpevents/%s/%s?minDate=%s&maxDate=%s%s' % (
self.pumperId,
tconnect_device_id,
minDate,
maxDate,
'&eventIds=%s' % eventIdsFilter if eventIdsFilter else ''
), {})
query = urllib.parse.urlencode({
'pumperId': self.pumperId,
'startDate': '%sT00:00:00Z' % minDate,
'endDate': '%sT23:59:59Z' % maxDate,
'eventIds': ','.join(map(str, event_ids_filter)) if event_ids_filter else '',
})
return self.get('api/reports/bff/pump-logs/%s?%s' % (device_id, query), {})
# The pump-logs endpoint caps each request at roughly four weeks, so a
# longer range is paged in windows no larger than this.
PUMP_LOGS_WINDOW_DAYS = 28
@classmethod
def _pump_log_windows(cls, min_date: Optional[str], max_date: Optional[str]) -> List[Tuple[str, str]]:
"""Split the (min_date, max_date) range into inclusive date windows no
larger than PUMP_LOGS_WINDOW_DAYS. A None bound defaults to today (via
parse_ymd_date), so an unset range yields a single one-day window."""
start = arrow.get(parse_ymd_date(min_date))
end = arrow.get(parse_ymd_date(max_date))
if end < start:
start, end = end, start
windows = []
cur = start
while cur <= end:
win_end = min(cur.shift(days=cls.PUMP_LOGS_WINDOW_DAYS - 1), end)
windows.append((cur.format('YYYY-MM-DD'), win_end.format('YYYY-MM-DD')))
cur = win_end.shift(days=1)
return windows
"""
Fetch and decode pump events using eventparser.
Default of fetch_all_events=False will filter to the same eventids used in the Tandem Source backend.
If fetch_all_events=True, then all event types from the history log will be returned.
Fetch and parse pump events from the pump-logs endpoint.
Default of fetch_all_event_types=False will filter to the same event ids used in the Tandem Source backend.
If fetch_all_event_types=True, then all event types from the history log will be returned.
tconnect_device_id is the UUID assignmentId from get_pumper() pumps (BffPump.assignmentId).
"""
def pump_events(self, tconnect_device_id, min_date=None, max_date=None, fetch_all_event_types=False):
pump_events_raw = self.pump_events_raw(
tconnect_device_id,
min_date,
max_date,
event_ids_filter=None if fetch_all_event_types else self.DEFAULT_EVENT_IDS
)
def pump_events(self, tconnect_device_id: str, min_date: Optional[str] = None, max_date: Optional[str] = None, fetch_all_event_types: bool = False) -> Iterator:
event_ids_filter = None if fetch_all_event_types else self.DEFAULT_EVENT_IDS
pump_events_decoded = decode_raw_events(pump_events_raw)
logger.info(f"Read {len(pump_events_decoded)} bytes (est. {len(pump_events_decoded)/EVENT_LEN} events)")
return Events(pump_events_decoded)
# Page across date windows, deduplicating events that appear in more
# than one window by their (sequenceGroup, sequenceNumber) identity.
seen = set()
events = []
clock_change_count = 0
for window_start, window_end in self._pump_log_windows(min_date, max_date):
resp = self.get_pump_logs(tconnect_device_id, window_start, window_end, event_ids_filter)
clock_change_count += len(resp.get('clockChanges') or [])
for event in resp.get('events') or []:
key = (event.get('sequenceGroup'), event.get('sequenceNumber'))
if key in seen:
continue
seen.add(key)
events.append(event)
# clockChanges (LID_TIME_CHANGED/LID_DATE_CHANGED) are not consumed by any
# processor, so they are counted for visibility but not parsed.
logger.info(f"Read {len(events)} events ({clock_change_count} clock changes skipped)")
return Events(events)
def pump_clock_changes(self, tconnect_device_id: str, min_date: Optional[str] = None, max_date: Optional[str] = None) -> Iterator:
"""Fetch the pump-logs clockChanges (LID_TIME_CHANGED/LID_DATE_CHANGED)
across the date range, deduplicated by (sequenceGroup, sequenceNumber).
tconnect_device_id is the UUID assignmentId from get_pumper() pumps."""
seen = set()
clock_changes = []
for window_start, window_end in self._pump_log_windows(min_date, max_date):
resp = self.get_pump_logs(tconnect_device_id, window_start, window_end)
for event in resp.get('clockChanges') or []:
key = (event.get('sequenceGroup'), event.get('sequenceNumber'))
if key in seen:
continue
seen.add(key)
clock_changes.append(event)
logger.info(f"Read {len(clock_changes)} clock changes")
return Events(clock_changes)
-296
View File
@@ -1,296 +0,0 @@
from typing import Dict, List, Tuple
import requests
import urllib
import datetime
import arrow
import time
import logging
from bs4 import BeautifulSoup
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment, DeviceSettings
from tconnectsync.util import removesuffix, removeprefix
from tconnectsync.util.constants import MMOLL_TO_MGDL
from .common import base_headers, ApiException
logger = logging.getLogger(__name__)
"""
WebUIScraper contains data that is scraped from the t:connect Web UI and is
not accessible via any known API.
"""
class WebUIScraper:
BASE_URL = "https://tconnect.tandemdiabetes.com/"
def __init__(self, controliq):
self.controliq = controliq
def needs_relogin(self):
return self.controliq.needs_relogin()
def _get(self, endpoint):
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers(), allow_redirects=True)
if r.status_code != 200:
raise ApiException(r.status_code, "WebUIScraper HTTP %s response: %s" % (str(r.status_code), r.text))
if 'login.aspx' in r.url:
raise ApiException(401, "WebUIScraper HTTP %s response for login page, returning 401: %s" % (str(r.status_code), r.url))
return r
def get(self, endpoint, tries=0):
try:
return self._get(endpoint)
except ApiException as e:
logger.warning("Received ApiException in WebUIScraper with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
if tries > 0:
raise ApiException(e.status_code, "WebUIScraper HTTP %d on retry #%d: %s", e.status_code, tries, e)
# Trigger automatic re-login, and try again once
if e.status_code == 401:
logger.info("Performing automatic re-login to ControlIQApi after HTTP 401 for ControlIQApi")
self.controliq.accessTokenExpiresAt = time.time()
self.controliq.login(self.controliq._email, self.controliq._password)
return self.get(endpoint, tries=tries+1)
if e.status_code == 500:
return self.get(endpoint, tries=tries+1)
raise e
def strip(self, txt):
# Remove errant whitespace between litearl newlines (and literal &nbsp;)
sep = '\r\n'
if sep not in txt and '\n' in txt:
sep = '\n'
return ' '.join([i.replace('\xa0',' ').strip() for i in txt.strip().split(sep)])
"""
Returns a mapping between pump/device IDs and information about that device,
including the GUID used for obtaining pump settings.
"""
def my_devices(self) -> Dict[str, DeviceSettings]:
devices = {}
r = self.get('myaccount/my_devices.aspx')
soup = BeautifulSoup(r.content, features='lxml')
for device in soup.select('#content > div.box'):
device_name = self.strip(device.select_one('.subTitle').text)
def find_label_value(lbl):
label = device.find(text=lbl)
if label:
tds = label.parent.parent.parent.select('td')
if len(tds) > 1:
return self.strip(tds[1].text)
return None
serial_number = find_label_value('Serial #')
model_number = find_label_value('Model #')
status = find_label_value('Status')
settings_span = device.find(text='View Settings')
settings_guid = None
if settings_span:
settings_a = settings_span.parent.parent
settings_guid = settings_a.attrs['href'].split('?guid=')[1]
if serial_number:
devices[serial_number] = Device(
name=device_name,
model_number=model_number,
status=status,
guid=settings_guid)
return devices
"""
Returns a parsed representation of a pump's settings.
Note that pump_guid is NOT the serial number of the pump, and
should be obtained from my_devices()[str(serial_number)].guid
"""
def device_settings_from_guid(self, pump_guid: str) -> Tuple[List[Profile], DeviceSettings]:
profiles = []
settings = {}
r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid)
soup = BeautifulSoup(r.content, features='lxml')
settings["upload_date"] = self.strip(soup.select_one('#lblUploadDate').text)
divxml = soup.select_one('#divXML')
divxmlDiv = divxml.findChild('div')
for tbl in divxmlDiv.findChildren('table', recursive=False):
setting_bg = tbl.select_one('.setting_bg')
if setting_bg and self.strip(setting_bg.text) == 'Profile':
profiles.append(self._parse_profile_tbl(tbl))
else:
settings.update(self._parse_settings_tbl(tbl))
low_bg_threshold, high_bg_threshold = self._extract_bg_thresholds(settings)
dev_settings = DeviceSettings(
low_bg_threshold=low_bg_threshold,
high_bg_threshold=high_bg_threshold,
raw_settings=settings
)
return profiles, dev_settings
def _parse_profile_tbl(self, tbl) -> Profile:
profile = {}
profile["title"] = self.strip(tbl.select_one('.setting_title').text)
profile["active"] = bool(tbl.find(text='Active at the time of upload'))
profile["segments"] = []
def parse_basal_rate(rate) -> float:
return float(removesuffix(rate, ' u/hr'))
def parse_factor(ratio) -> int:
return parse_bg_mgdl(removeprefix(ratio, '1u:'))
def parse_ratio(ratio) -> float:
return float(removesuffix(removeprefix(ratio, '1u:'), ' g'))
def parse_bg_mgdl(bg) -> int:
if bg.endswith(' mg/dL'):
return float(removesuffix(bg, ' mg/dL'))
elif bg.endswith(' mmol/L'):
return float(removesuffix(bg, ' mmol/L')) * MMOLL_TO_MGDL
raise ValueError(bg)
def hours_to_mins(text) -> int:
hrmin = removesuffix(text, " hours")
hr, min = hrmin.split(":", 1)
return int(min) + int(hr)*60
for tr in tbl.select('tr'):
# Skip header rows
if tr.select_one('.setting_bg'):
continue
if tr.find(text='Start Time'):
continue
tds = tr.select('td')
def is_time_row(td):
txt = self.strip(td.select_one('strong').text)
return " AM" in txt or " PM" in txt or txt in ("Midnight", "Noon")
if len(tds) > 0 and is_time_row(tds[0]):
display_time = self.strip(tds[0].text)
t = display_time
if display_time == "Midnight":
t = "12:00 AM"
elif display_time == "Noon":
t = "12:00 PM"
segment = {
"display_time": display_time,
"time": t,
"basal_rate": parse_basal_rate(self.strip(tds[1].text)),
"correction_factor": parse_factor(self.strip(tds[2].text)),
"carb_ratio": parse_ratio(self.strip(tds[3].text)),
"target_bg_mgdl": parse_bg_mgdl(self.strip(tds[4].text))
}
profile["segments"].append(ProfileSegment(**segment))
continue
if tr.find(text='Calculated Total Daily Basal'):
profile["calculated_total_daily_basal"] = float(removesuffix(self.strip(tds[1].text), " units"))
continue
# Last row
if tr.find(text='Duration of Insulin:'):
lastrow = self.strip(tr.text)
for part in lastrow.split(' |'):
if len(part) < 1:
continue
key, val = part.split(': ')
key = self.strip(key)
val = self.strip(val)
if key == 'Duration of Insulin':
profile["insulin_duration_min"] = hours_to_mins(val)
elif key == 'Carbohydrates':
profile["carbs_enabled"] = self.strip(val.lower()) == "on"
return Profile(**profile)
def _parse_settings_tbl(self, tbl):
outer_tr = tbl.select('tr')[2]
settings = {}
def loop(td, subhead):
settings[subhead] = {}
for tr in td.select('.settingstable > tr'):
if not tr.select_one('strong'):
continue
key = self.strip(tr.select_one('strong').text)
tds = tr.select('td')
if len(tds) == 1:
subhead = key
settings[subhead] = {}
continue
val_text = self.strip(tds[1].text)
val = {}
if tds[1].find(text=' - '):
val['value'] = False
elif tds[1].find(text='Off'):
val['value'] = False
val_text = self.strip(val_text.split('Off', 1)[1])
elif tds[1].find(text='On'):
val['value'] = True
val_text = self.strip(val_text.split('On', 1)[1])
val['text'] = val_text
settings[subhead][key] = val
children = outer_tr.findChildren('td', recursive=False)
loop(children[0], 'Alerts')
loop(children[1], 'Pump Settings')
return settings
def _extract_bg_thresholds(self, settings):
# Nightscout needs default values
low_bg_threshold = 70
high_bg_threshold = 180
if 'CGM Alerts' in settings:
if 'Low Alert' in settings['CGM Alerts']:
low = settings['CGM Alerts']['Low Alert']
if low['value']:
low_bg_threshold = int(low['text'].split(' mg/dL')[0])
if 'High Alert' in settings['CGM Alerts']:
high = settings['CGM Alerts']['High Alert']
if high['value']:
high_bg_threshold = int(high['text'].split(' mg/dL')[0])
return low_bg_threshold, high_bg_threshold
"""
Wraps a call to my_devices to identify the device GUID from the
given pump serial, and then returns device_settings_from_guid.
"""
def device_settings(self, pump_serial: str) -> Tuple[List[Profile], DeviceSettings]:
devices = self.my_devices()
if str(pump_serial) in devices.keys():
dev = devices[str(pump_serial)]
return self.device_settings_from_guid(dev['guid'])
raise RuntimeError('Unable to find pump with serial number: %s. Known devices: %s' % (pump_serial, devices))
-174
View File
@@ -1,174 +0,0 @@
import requests
import datetime
import csv
import logging
import time
import json
from .common import base_session, parse_date, parsed_date_to_arrow, base_headers, days_between, split_days_range, ApiException
logger = logging.getLogger(__name__)
class WS2Api:
BASE_URL = 'https://tconnectws2.tandemdiabetes.com/'
MAX_RETRIES = 2
SLEEP_SECONDS_INCREMENT = 60
userGuid = None
def __init__(self, userGuid):
self.userGuid = userGuid
self.session = base_session()
def get(self, endpoint, **kwargs):
r = self.session.get(self.BASE_URL + endpoint, headers=base_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.text
def get_jsonp(self, endpoint, **kwargs):
r = self.session.get(self.BASE_URL + endpoint + '?callback=cb', headers=base_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
t = r.text.strip()
if t.startswith('cb('):
t = t[3:]
if t.endswith(')'):
t = t[:-1]
return json.loads(t)
def _split_empty_sections(self, text):
sections = [[]]
sectionIndex = 0
for line in text.splitlines():
if len(line.strip()) > 0:
sections[sectionIndex].append(line)
else:
sections.append([])
sectionIndex += 1
return sections + [None] * (4 - len(sections))
def _csv_to_dict(self, rawdata):
data = []
if not rawdata or len(rawdata) == 0:
return data
headers = rawdata[0].split(",")
for row in csv.reader(rawdata[1:]):
data.append({headers[i]: row[i] for i in range(len(row)) if i < len(headers)})
return data
"""
Returns information on therapy, displayed in the therapy timeline on the
t:connect website.
Contains BG reading (CGM), IOB, basal, and bolus data.
Basal data does NOT appear for the specified time range if using Control-IQ.
The ControlIQ API endpoints must be used for basal data instead.
However, all other fields are still accessed via this endpoint.
This has its own built-in retry logic because Tandem's frontend serving
the API returns 500s when its backend times out.
"""
MAX_THERAPY_TIMELINE_DAYS = 2
def therapy_timeline_csv(self, start=None, end=None, tries=0):
startDate = parse_date(start)
endDate = parse_date(end)
pStart = parsed_date_to_arrow(startDate)
pEnd = parsed_date_to_arrow(endDate)
if days_between(pStart, pEnd) > self.MAX_THERAPY_TIMELINE_DAYS:
ranges = split_days_range(pStart, pEnd, self.MAX_THERAPY_TIMELINE_DAYS)
logger.debug("Splitting call to therapy_timeline_csv(%s, %s) into: %s", start, end, ranges)
outputs = []
for rng in ranges:
rStart, rEnd = rng
logger.debug("split therapy_timeline_csv(%s, %s)", rStart, rEnd)
output = self.therapy_timeline_csv(rStart, rEnd, tries=tries)
logger.debug("split therapy_timeline_csv(%s, %s) = %s", rStart, rEnd, ["%s: %s items" % (key, len(val)) for key, val in output.items()])
outputs.append(output)
full = {}
for o in outputs:
for key, val in o.items():
if key not in full:
full[key] = val
elif val is not None:
full[key] += val
logger.debug("therapy_timeline_csv merge: %s", ["%s: %s items" % (key, len(val)) for key, val in full.items()])
return full
try:
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), timeout=10)
except ApiException as e:
# This seems to occur as some kind of soft rate-limit.
logger.warning("Received ApiException in therapy_timeline_csv: (retry count %d) %s" % (tries, e))
if e.status_code == 500:
sleep_seconds = (tries+1) * self.SLEEP_SECONDS_INCREMENT
logger.error("Retrying in %d seconds after HTTP 500 in therapy_timeline_csv (retry count %d): %s" % (sleep_seconds, tries, e))
time.sleep(sleep_seconds)
if tries < self.MAX_RETRIES:
return self.therapy_timeline_csv(start, end, tries+1)
raise e
logger.debug('req_text: %s', req_text)
sections = self._split_empty_sections(req_text)
readingData = None
iobData = None
basalData = None
bolusData = None
for s in sections:
if s and len(s) > 2:
firstrow = s[1].replace('"', '').strip()
if firstrow.startswith("t:slim X2 Insulin Pump"):
readingData = s
elif firstrow.startswith("IOB"):
iobData = s
elif firstrow.startswith("Basal"):
basalData = s
elif firstrow.startswith("Bolus"):
bolusData = s
return {
"readingData": self._csv_to_dict(readingData),
"iobData": self._csv_to_dict(iobData),
"basalData": self._csv_to_dict(basalData),
"bolusData": self._csv_to_dict(bolusData)
}
"""
Returns information on basal suspension. The filterbasal option only returns site/cartridge changes.
SuspendReason values are:
- "site-cart"
- "basal-profile"
- "manual"
- "previous"
- "alarm"
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
{"BasalSuspension":[{"EventDateTime":"/Date(EPOCH_MS-0000)/", "SuspendReason": "reason"}]}
"""
def basalsuspension(self, start=None, end=None, filterbasal=False):
startDate = parse_date(start)
endDate = parse_date(end)
arg = "filterbasal/1" if filterbasal else ""
return self.get_jsonp('basalsuspension/%s/%s/%s/%s' % (self.userGuid, startDate, endDate, arg), timeout=10)
"""
Returns info on BasalIQ in JSONP format.
"""
def basaliqtech(self, start=None, end=None):
startDate = parse_date(start)
endDate = parse_date(end)
return self.get_jsonp('basaliqtech/%s/%s/%s' % (self.userGuid, startDate, endDate), timeout=10)
-207
View File
@@ -1,207 +0,0 @@
import time
import logging
import datetime
import sys
from .process import process_time_range
from .features import DEFAULT_FEATURES
from . import secret
logger = logging.getLogger(__name__)
class Autoupdate:
"""Wrap access to secrets for easier testing."""
def __init__(self, secret):
self.secret = secret
self.autoupdate_invocations = 0
self.last_event_index = None
self.last_event_time = None
self.last_successful_process_time_range = None
self.time_diffs_between_updates = []
self.last_attempt_time = None
self.time_diffs_between_attempts = []
"""
Performs the auto-update functionality. Runs indefinitely in a loop
until stopped (ctrl+c), or a maximum of AUTOUPDATE_MAX_LOOP_INVOCATIONS times.
Stops if AUTOUPDATE_RESTART_ON_FAILURE is set and an error occurs.
"""
def process(self, tconnect, nightscout, time_start, time_end, pretend, features=None):
if features is None:
features = DEFAULT_FEATURES
# Read from android api, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
self.autoupdate_start = time.time()
while True:
logger.debug("autoupdate loop")
now = time.time()
last_event = tconnect.android.last_event_uploaded(self.secret.PUMP_SERIAL_NUMBER)
if not self.last_event_index or last_event['maxPumpEventIndex'] > self.last_event_index:
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], self.last_event_index))
if pretend:
logger.info('Would update now if not in pretend mode')
else:
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
logger.info('Added %d items from process_time_range' % added)
if added == 0:
# If we've been unable to find new events, but the last_event_index is increasing,
# suggesting there are more events being added, we might be in a bugged
# situation where we can't get any more data without restarting.
# We skip this check on the first process cycle, since we might
# just already be in sync with tconnect's pump data.
if self.last_event_index:
# Find the timestamp of the last time we've successfully obtained data,
# or the time when the autoupdate run started, if we haven't at all.
last_action_or_start = self.last_successful_process_time_range
if not last_action_or_start:
last_action_or_start = self.autoupdate_start
# If it's been AUTOUPDATE_FAILURE_MINUTES in the state of not seeing
# event index changes reflected in the tconnect data we're pulling,
# raise an error and potentially restart.
# This is likely a tconnectsync problem, not a problem with the pump or app
# (we can see the indexes increasing, so we know something's happening!)
if (now - last_action_or_start) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateFailureError(
("%s: An event index change was recorded, but no new data was found via the API. " % datetime.datetime.now()) +
"The %s was %d minutes ago. This is a problem with tconnectsync." %
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
return 1
else:
logger.warning(AutoupdateFailureWarning(("%s: An event index change was recorded, but no new data was found via the API. " % datetime.datetime.now()) +
"The %s was %d minutes ago. Resetting TConnectApi to attempt to solve this problem." %
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
# As a stop-gap, try to re-initialize TConnectApi (triggering a re-login)
# Use __class__ instead of direct TConnectApi invocation to avoid initializing a real TConnectApi over a fake
tconnect = tconnect.__class__(self.secret.TCONNECT_EMAIL, self.secret.TCONNECT_PASSWORD)
else:
# Mark the last successful time we got data from tconnect
self.last_successful_process_time_range = now
# Track the time it took to find a new event between runs,
# but skip this calculation the first process cycle (since
# we don't know at what exact point the event index changed)
if self.last_event_index:
self.time_diffs_between_updates.append(now - self.last_event_time)
logger.debug('Updating tracking of time since last update: %s' % self.time_diffs_between_updates)
# Mark the last event index uploaded from the pump and timestamp
self.last_event_index = last_event['maxPumpEventIndex']
self.last_event_time = now
self.last_attempt_time = now
self.time_diffs_between_attempts = []
else:
logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex'])
# If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# then trigger an error and potentially restart.
# The most likely case here is that the pump isn't uploading right now.
if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES:
logger.error(AutoupdateNoEventIndexesDetectedError(
"%s: No new data event indexes have been detected for %d minutes. " % (datetime.datetime.now(), (now - self.last_event_time)//60) +
"The t:connect app might no longer be functioning."))
# TODO: restarting doesn't really help anything here.
# Should we notify the user?
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
return 1
# Similarly, if we HAVE seen pump event indexes update but have not successfully
# found any associated data updates from the tconnect API for AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# trigger an error and potentially restart. This could either be a tconnectsync problem,
# where we can see the indexes increasing, but it takes us until a period of no index
# update to reach our AUTOUPDATE_FAILURE_MINUTES threshold; or, a side effect of the
# above no indexes warning.
elif self.last_successful_process_time_range and (now - self.last_successful_process_time_range) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateNoNewDataDetectedError(
"%s: No new data has been detected via the API for %d minutes. " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60) +
"tconnectsync might not be functioning properly."))
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("%s: Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE" % datetime.datetime.now())
return 1
# Track how long we've been retrying
if self.last_attempt_time:
self.time_diffs_between_attempts.append(now - self.last_attempt_time)
self.last_attempt_time = now
# If it's been 3 loops since the last time we found new data,
# then we're not in sync with the rate at which pump data is being
# uploaded, so
if len(self.time_diffs_between_attempts) >= 3:
# The pump hasn't sent us data that, based on previous cadence, we were expecting
logger.warning(AutoupdateNoIndexChangeWarning("Sleeping %d seconds after unexpected no index change based on previous cadence. (New data might be delayed.)" %
int(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)))
logger.debug("Last event time: %s, time diffs between attempts: %s" % (self.last_event_time, self.time_diffs_between_attempts))
time.sleep(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)
# Since we bail early, update the invocations count and potentially exit after sleeping.
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
continue
sleep_secs = self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS
# Sleep for a rolling average of time between updates
if self.secret.AUTOUPDATE_USE_FIXED_SLEEP != 1:
logger.debug("Time diffs between updates: %s" % self.time_diffs_between_updates)
# Only keep the 10 latest time diffs
if len(self.time_diffs_between_updates) > 10:
self.time_diffs_between_updates = self.time_diffs_between_updates[1:]
# If we have less than 3 data points,
if len(self.time_diffs_between_updates) > 2:
sleep_secs = sum(self.time_diffs_between_updates) / len(self.time_diffs_between_updates)
# At minimum, update every AUTOUPDATE_MAX_SLEEP_SECONDS regardless
# of how often we're seeing new data appear
if sleep_secs > self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS:
sleep_secs = self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS
logger.info('Sleeping for %0.01f sec' % sleep_secs)
time.sleep(sleep_secs)
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
class AutoupdateError(RuntimeError):
def __str__(self):
return "%s: %s" % (self.__class__.__name__, super().__str__())
class AutoupdateWarning(RuntimeWarning):
def __str__(self):
return "%s: %s" % (self.__class__.__name__, super().__str__())
class AutoupdateFailureError(AutoupdateError):
pass
class AutoupdateFailureWarning(AutoupdateWarning):
pass
class AutoupdateNoEventIndexesDetectedError(AutoupdateError):
pass
class AutoupdateNoNewDataDetectedError(AutoupdateError):
pass
class AutoupdateNoIndexChangeWarning(AutoupdateWarning):
pass
+78 -103
View File
@@ -3,18 +3,23 @@ import time
import arrow
import logging
import traceback
import pkg_resources
import collections
from datetime import datetime
from pprint import pformat as pformat_base
if sys.version_info < (3, 8):
from importlib_metadata import PackageNotFoundError, version
else:
from importlib.metadata import PackageNotFoundError, version
from .nightscout import NightscoutApi
from .parser.nightscout import BASAL_EVENTTYPE, BOLUS_EVENTTYPE
from .parser.tconnect import TConnectEntry
from .sync.basal import process_ciq_basal_events
from .domain.tandemsource.event_class import EventClass
from .sync.tandemsource.choose_device import ChooseDevice
try:
__version__ = pkg_resources.require("tconnectsync")[0].version
except Exception:
__version__ = version("tconnectsync")
except PackageNotFoundError:
__version__ = "UNKNOWN"
"""
@@ -51,27 +56,32 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
log("Loading secrets...")
try:
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
from . import secret
except ImportError as e:
log("Error: Unable to load config file. Please check your .env file or environment variables")
log_err(e)
# Config never loaded; the names below are unbound, so stop here instead
# of crashing with a NameError.
return
log(f"Using {TCONNECT_REGION=}")
if not TCONNECT_EMAIL or TCONNECT_EMAIL == 'email@email.com':
log("Error: You have not specified a TCONNECT_EMAIL")
errors += 1
if not TCONNECT_PASSWORD or TCONNECT_PASSWORD == 'password':
log("Error: You have not specified a TCONNECT_PASSWORD")
errors += 1
if not PUMP_SERIAL_NUMBER or PUMP_SERIAL_NUMBER == '11111111':
log("Error: You have not specified a PUMP_SERIAL_NUMBER")
errors += 1
log("Warning: You have not specified a PUMP_SERIAL_NUMBER, so the pump with most recent activity will be automatically used.")
if not NS_URL or NS_URL == 'https://yournightscouturl/':
log("Error: You have not specified a NS_URL")
errors += 1
if not NS_SECRET or NS_SECRET == 'apisecret':
log("Error: You have not specified a NS_SECRET")
errors += 1
@@ -80,89 +90,62 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
log("-----")
log("Logging in to t:connect ControlIQ API...")
serialNumberToPump = None
try:
summary = tconnect.controliq.dashboard_summary(time_start, time_end)
debug("ControlIQ dashboard summary: \n%s" % pformat(summary))
log("tconnect_software_ver: %s" % tconnect.controliq.tconnect_software_ver)
except Exception as e:
log("Error occurred querying ControlIQ API for dashboard_summary:")
log_err(e)
if e and "HTTP 404" in str(e):
log("<!> The API returns a 404 error if there is no data for the provided dates (currently %s - %s). Try re-running with an earlier start date using --start-date and --end-date arguments." % (time_start, time_end))
errors += 1
log("Querying ControlIQ therapy_timeline...")
lastBasalTime = None
lastBasalDuration = None
try:
tt = tconnect.controliq.therapy_timeline(time_start, time_end)
debug("ControlIQ therapy_timeline: \n%s" % pformat(tt))
if tt:
processed_tt = process_ciq_basal_events(tt)
debug("ControlIQ processed therapy_timeline: \n%s" % pformat(processed_tt))
if processed_tt:
log("Last ControlIQ processed therapy_timeline event: \n%s" % pformat(processed_tt[-1]))
lastBasalTime = processed_tt[-1]['time']
lastBasalDuration = processed_tt[-1]['duration_mins']
except Exception as e:
log("Error occurred querying ControlIQ therapy_timeline:")
log_err(e)
errors += 1
log("Querying ControlIQ therapy_events...")
try:
androidevents = tconnect.controliq.therapy_events(time_start, time_end)
debug("controliq therapy_events: \n%s" % pformat(androidevents))
except Exception as e:
log("Error occurred querying ControlIQ therapy_events:")
log_err(e)
errors += 1
log("-----")
log("Fetching pump metadata...")
pumpEventMetadata = tconnect.tandemsource.get_pumper().get('pumps', [])
log("Initializing t:connect WS2 API...")
ws2_loggedin = False
try:
summary = tconnect.ws2.basaliqtech(time_start, time_end)
debug("WS2 basaliq status: \n%s" % pformat(summary))
ws2_loggedin = True
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
log(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
for pumpSerial, pumpDetails in serialNumberToPump.items():
log(f'Pump {pumpSerial=}: {pumpDetails=}')
log("Running ChooseDevice...")
tconnectDevice = ChooseDevice(secret, tconnect).choose()
log(f'ChooseDevice selected: {tconnectDevice}')
deviceId = tconnectDevice['assignmentId']
log(f'Fetching pump events for {deviceId=} {time_start=} {time_end=} fetch_all_event_types=False')
events = tconnect.tandemsource.pump_events(deviceId, time_start, time_end, fetch_all_event_types=False)
events = list(events)
log(f"Found raw events count: {len(events)}")
events_first_time = None
events_last_time = None
last_event_seqnum = None
for_eventclass = collections.defaultdict(list)
for event in events:
if not events_first_time:
events_first_time = event.eventTimestamp
if not events_last_time:
events_last_time = event.eventTimestamp
if not last_event_seqnum:
last_event_seqnum = event.seqNum
events_first_time = min(events_first_time, event.eventTimestamp)
events_last_time = max(events_last_time, event.eventTimestamp)
last_event_seqnum = max(event.seqNum, last_event_seqnum)
clazz = EventClass.for_event(event)
if clazz:
for_eventclass[clazz.name].append(event)
count_by_eventclass = {k: len(v) for k,v in for_eventclass.items()}
log(f"Found events count: {count_by_eventclass}")
log(f"Found first event time: {events_first_time}")
log(f"Found last event time: {events_last_time}")
log(f"Found last event sequence number: {last_event_seqnum}")
except Exception as e:
log("Error occurred querying WS2 API. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
log("Error occurred querying Tandem Source:")
log_err(e)
errors += 1
lastReadingTime = None
if ws2_loggedin:
log("Querying WS2 therapy_timeline_csv...")
try:
ttcsv = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
debug("therapy_timeline_csv: \n%s" % pformat(ttcsv))
if ttcsv and "readingData" in ttcsv and len(ttcsv["readingData"]) > 0:
log("Last therapy_timeline_csv reading: \n%s" % pformat(ttcsv["readingData"][-1]))
lastReadingTime = TConnectEntry._datetime_parse(ttcsv["readingData"][-1]['EventDateTime'])
except Exception as e:
log("Error occurred querying WS2 therapy_timeline_csv. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
log_err(e)
errors += 1
else:
log("Not able to log in to WS2 API, so skipping therapy_timeline_csv")
log("-----")
log("Logging in to t:connect Android API...")
summary = None
try:
summary = tconnect.android.user_profile()
debug("Android user profile: \n%s" % pformat(summary))
event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
debug("Android last uploaded event: \n%s" % pformat(event))
except Exception as e:
log("Error occurred querying Android API:")
log_err(e)
errors += 1
log("-----")
log("Logging in to Nightscout...")
@@ -186,17 +169,13 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
def time_ago(t):
return '%s ago' % (arrow.now() - arrow.get(t)) if t else 'n/a'
log("Last basal start time: %s (%s)" % (lastBasalTime, time_ago(lastBasalTime)))
log("Last basal duration: %s" % lastBasalDuration)
log("Last reading time: %s (%s)" % (lastReadingTime, time_ago(lastReadingTime)))
log("-----")
if errors == 0:
log("No API errors returned!")
else:
log("API errors occurred. Please check the errors above.")
with open('tconnectsync-check-output.log', 'w') as f:
@@ -208,14 +187,10 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
'NS_URL': NS_URL,
'NS_SECRET': NS_SECRET
}
if summary:
sanitizedData.update({
'ANDROID_PROFILE_USERID': summary.get('userID'),
'ANDROID_PROFILE_PATIENT_FULLNAME': summary.get('patientFullName'),
'ANDROID_PROFILE_CAREGIVER_FULLNAME': summary.get('caregiverFullName')
})
if serialNumberToPump:
for i, (pumpSerial, pumpDetails) in enumerate(serialNumberToPump.items()):
sanitizedData[f'PUMP_SERIAL_{i}'] = pumpSerial
sanitizedData[f'TCONNECT_DEVICE_ID_{i}'] = pumpDetails['assignmentId']
loglines = [run_sanitize(i, sanitizedData) for i in loglines]
f.writelines(loglines)
@@ -236,4 +211,4 @@ def run_sanitize(s, sanitizedData):
def pformat(*args, **kwargs):
kwargs['width'] = 160
return pformat_base(*args, **kwargs)
return pformat_base(*args, **kwargs)
-25
View File
@@ -1,25 +0,0 @@
from dataclasses import dataclass, asdict
@dataclass
class Bolus:
description: str
complete: str # "1" / "0"
completion: str
request_time: str # _datetime_parse timestamp
completion_time: str # _datetime_parse timestamp
insulin: str
requested_insulin: str
carbs: str
bg: str # potentially ""
user_override: str
extended_bolus: str # "1" / "0"
bolex_completion_time: str
bolex_start_time: str
def to_dict(self):
return asdict(self)
@property
def is_extended_bolus(self):
return self.extended_bolus == "1"
-44
View File
@@ -1,44 +0,0 @@
from dataclasses import dataclass, replace
from typing import List, Optional
@dataclass
class Device:
name: str
model_number: str
status: str
guid: Optional[str]
@dataclass
class ProfileSegment:
display_time: str # Identical to time except written out as Midnight or Noon
time: str
basal_rate: float # _ u/hr
correction_factor: int # 1u: _ mg/dL
carb_ratio: float # 1u: _ g
target_bg_mgdl: int
@dataclass
class Profile:
title: str
active: bool
segments: List[ProfileSegment]
calculated_total_daily_basal: float # in units
insulin_duration_min: int
carbs_enabled: bool
def activeProfile(self):
p = self.copy()
p.active = True
return p
def copy(self):
p = replace(self)
p.segments = [replace(s) for s in p.segments]
return p
# Settings stored globally in the pump that are stored per-profile in Nightscout
@dataclass
class DeviceSettings:
low_bg_threshold: int
high_bg_threshold: int
raw_settings: dict
@@ -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
@@ -19,10 +19,10 @@ class EventClass(set, Enum):
CARTRIDGE = {events.LidCartridgeFilled, events.LidCannulaFilled, events.LidTubingFilled}
CGM_ALERT = {events.LidCgmAlertActivated, events.LidCgmAlertActivatedDex, events.LidCgmAlertActivatedFsl2}
_CGM_START = {events.LidCgmStartSessionGx, events.LidCgmStartSessionFsl2}
_CGM_JOIN = {events.LidCgmJoinSessionGx, events.LidCgmJoinSessionG7, events.LidCgmJoinSessionFsl2}
_CGM_STOP = {events.LidCgmStopSessionGx, events.LidCgmStopSessionG7, events.LidCgmStopSessionFsl2}
_CGM_JOIN = {events.LidCgmJoinSessionGx, events.LidCgmJoinSessionG7, events.LidCgmJoinSessionFsl2, events.LidCgmJoinSessionFsl3}
_CGM_STOP = {events.LidCgmStopSessionGx, events.LidCgmStopSessionG7, events.LidCgmStopSessionFsl2, events.LidCgmStopSessionFsl3}
CGM_START_JOIN_STOP = {*_CGM_START, *_CGM_JOIN, *_CGM_STOP}
CGM_READING = {events.LidCgmDataGxb, events.LidCgmDataG7, events.LidCgmDataFsl2}
CGM_READING = {events.LidCgmDataGxb, events.LidCgmDataG7, events.LidCgmDataFsl2, events.LidCgmDataFsl3}
USER_MODE = {events.LidAaUserModeChange}
DEVICE_STATUS = {events.LidDailyBasal}
@@ -1,14 +1,18 @@
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
# bff/pumper endpoint (BffPump.settings.details). Only the fields the
# profile sync consumes are declared; dataclasses_json ignores the rest.
@dataclass_json
@dataclass
class PumpProfileSegment:
startTime: int # minutes
basalRate: int # milliunits
isf: int
carbRatio: int
carbRatio: int # milliunits
targetBg: int
@property
@@ -20,13 +24,18 @@ class PumpProfileSegment:
class PumpProfile:
name: str
idp: int
tDependentSegs: List[PumpProfileSegment]
timeDependentSegments: List[PumpProfileSegment]
insulinDuration: int # minutes
carbEntry: int # 1 / 0
carbEntry: str # e.g. "UnitsAsCarbs"
maxBolus: int # milliunits
def __post_init__(self):
self.tDependentSegs = [i for i in self.tDependentSegs if not i.skip]
self.timeDependentSegments = [i for i in self.timeDependentSegments if not i.skip]
@property
def tDependentSegs(self) -> List[PumpProfileSegment]:
# Back-compat alias for the pre-BFF field name.
return self.timeDependentSegments
@dataclass_json
@dataclass
@@ -34,22 +43,15 @@ class PumpProfiles:
activeIdp: int
profile: List[PumpProfile]
@dataclass_json
@dataclass
class PumpGlucoseAlertSettings:
mgPerDl: int
enabled: int # 1 / 0
duration: int # minutes
status: int # unknown
@dataclass_json
@dataclass
class PumpCgmSettings:
highGlucoseAlert: PumpGlucoseAlertSettings
lowGlucoseAlert: PumpGlucoseAlertSettings
# The bff/pumper cgmSettings block is flat (no nested per-alert object).
highGlucoseAlertMgPerDl: int
lowGlucoseAlertMgPerDl: int
@dataclass_json
@dataclass
class PumpSettings:
class PumpSettings(DataClassJsonMixin):
profiles: PumpProfiles
cgmSettings: PumpCgmSettings
cgmSettings: PumpCgmSettings
-483
View File
@@ -1,483 +0,0 @@
import arrow
from tconnectsync.domain.bolus import Bolus
from ..secret import TIMEZONE_NAME
def _datetime_parse(date):
# consistent format with ws2 endpoint
return arrow.get(date, tzinfo=TIMEZONE_NAME).format("YYYY-MM-DD HH:mm:ssZZ")
class TherapyEvent:
type = None
eventDateTime = None
sourceRecId = None
def parse(self, json):
self.type = json['type']
self.eventDateTime = json['eventDateTime']
self.sourceRecId = json['sourceRecId']
self.rawJson = json
def __str__(self):
return "%s(%s)" % (self.type, self.rawJson)
class CGMTherapyEvent(TherapyEvent):
eventID = None
egv = None
"""
{
"eventDateTime": "2022-07-21T00:00:08",
"eventID": 256,
"requestDateTime": "0001-01-01T00:00:00",
"type": "CGM",
"description": "EGV",
"sourceRecId": 0,
"eventTypeId": 0,
"deviceType": "t:slim X2 Insulin Pump",
"serialNumber": "xxx",
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0,
"egv": {
"estimatedGlucoseValue": 174,
"hypo": 0,
"belowTarget": 0,
"withinTarget": 1,
"aboveTarget": 0,
"hyper": 0
}
},
"""
@classmethod
def parse(_, json):
self = CGMTherapyEvent()
TherapyEvent.parse(self, json)
self.eventID = json['eventID']
self.egv = json['egv']['estimatedGlucoseValue']
return self
class BGTherapyEvent(TherapyEvent):
eventID = None
egv = None
"""
{
'bg': 160, # note in EGV
'cgmCalibration': 1, # not in EGV
'description': 'BG',
'deviceType': 't:slim X2 Insulin Pump',
'eventDateTime': '2022-08-20T07:25:24',
'eventTypeId': 16,
'indexId': 844955,
'interactive': 0,
'iob': 0.75,
'note': { 'active': False,
'eventId': 0, # different location than EGV
'eventTypeId': 16,
'id': 0,
'indexId': '',
'sourceRecordId': 0},
'requestDateTime': '0001-01-01T00:00:00',
'serialNumber': 'xxx',
'sourceRecId': 793549667,
'tempRateActivated': 0,
'tempRateCompleted': 0,
'tempRateId': 0,
'type': 'BG',
'uploadId': 748700213}
"""
@classmethod
def parse(_, json):
self = BGTherapyEvent()
TherapyEvent.parse(self, json)
self.eventID = json['note']['eventId']
# This is probably not how we want to provide CGM calibrations to Nightscout,
# but will just include it as egv data for now to keep the thing from crashing :)
self.egv = json['bg']
return self
class BolusTherapyEvent(TherapyEvent):
bolusRequestOptions = None
REQUEST_AUTOMATIC = "Automatic Bolus/Correction"
REQUEST_STANDARD = "Standard"
bolusType = None
TYPE_AUTOMATIC = "Automatic Correction"
TYPE_CARB = "Carb"
carbSize = None
correctionBolusSize = None
foodBolusSize = None
insulinDelivered = None
insulinRequested = None
completionDateTime = None
completionStatus = None
STATUS_COMPLETED = "Completed"
eventHistoryReportDetails = None
standardPercent = None
sourceRecId = None
@classmethod
def parse(_, json):
self = BolusTherapyEvent()
TherapyEvent.parse(self, json)
self.description = json.get("description")
self.complete = json.get("standard", {}).get("bolusIsComplete")
self.completion = json.get("standard", {}).get("completionStatusDesc")
self.request_time = json.get("requestDateTime")
self.completion_time = json.get("standard", {}).get("insulinDelivered", {}).get("completionDateTime")
# TODO: separate extended vs standard bolus into separate fields
self.insulin = json.get("standard", {}).get("insulinDelivered", {}).get("value")
self.requested_insulin = json.get("standard", {}).get("insulinRequested")
self.carbs = json.get("carbSize")
self.bg = json.get("bg")
self.user_override = json.get("userOverride")
self.extended_bolus = json.get("bolusRequestOptions") == "Extended"
if self.extended_bolus and self.complete:
# TODO(https://github.com/jwoglom/tconnectsync/issues/19): read more extended bolus info
self.complete = json.get("bolex", {}).get("extendedBolusIsComplete")
self.completion = json.get("bolex", {}).get("completionStatusDesc")
self.bolex_completion_time = json.get("bolex", {}).get("insulinDelivered", {}).get("completionDateTime")
self.bolex_start_time = json.get("bolex", {}).get("bolexStartDateTime")
else:
self.bolex_completion_time = ""
self.bolex_start_time = ""
return self
def to_bolus(self):
return Bolus(
description=self.description,
complete="1" if self.complete else "0",
completion=self.completion or "",
request_time=_datetime_parse(self.request_time),
completion_time=_datetime_parse(self.completion_time),
insulin=str(self.insulin),
requested_insulin=str(self.requested_insulin),
carbs=str(self.carbs or "0"), # Nightscout expects non-empty carbs
bg=str(self.bg or ""),
user_override=str(self.user_override),
extended_bolus="1" if self.extended_bolus else "0",
bolex_completion_time=_datetime_parse(self.bolex_completion_time) if self.bolex_completion_time else "",
bolex_start_time=_datetime_parse(self.bolex_start_time) if self.bolex_start_time else ""
)
"""
Correction:
{
"actualTotalBolusRequested": 2.9,
"bg": 254,
"bolusRequestOptions": "Automatic Bolus/Correction",
"bolusType": "Automatic Correction",
"carbSize": 0,
"correctionBolusSize": 2.9,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T11:53:08",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
"eventHistoryReportEventDesc": "Correction Bolus",
"foodBolusSize": 0,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "572946",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-07-21T11:53:08",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T11:55:24",
"value": 2.9
},
"foodDelivered": 0,
"correctionDelivered": 2.9,
"insulinRequested": 2.9,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3361,
"bolusCompletionId": 3361
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Automatic Bolus/Correction",
"sourceRecId": 1171791787,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
},
Standard:
{
"actualTotalBolusRequested": 4.17,
"bolusRequestOptions": "Standard",
"bolusType": "Carb",
"carbSize": 25,
"correctionBolusSize": 0,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T12:27:36",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"eventHistoryReportEventDesc": "Food Bolus",
"foodBolusSize": 4.17,
"iob": 2.62,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "573042",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-07-21T12:27:36",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T12:29:21",
"value": 4.17
},
"foodDelivered": 4.17,
"correctionDelivered": 0,
"insulinRequested": 4.17,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3362,
"bolusCompletionId": 3362
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Standard",
"sourceRecId": 1171853319,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
},
Extended bolus incomplete:
{
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"iob": 0,
"completionStatusId": 0,
"extendedBolusIsComplete": 0,
"insulinRequested": 0,
"bolexCompletionId": 0
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
Extended bolus (complete):
{
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:35:03",
"value": 0.2
},
"iob": 5.7,
"completionStatusId": 3.0,
"completionStatusDesc": "Completed",
"extendedBolusIsComplete": 1,
"insulinRequested": 0.2,
"bolexCompletionId": 16757133
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
CGM Calibration (Therapy Event Type BG):
{ 'bg': 160,
'cgmCalibration': 1,
'description': 'BG',
'deviceType': 't:slim X2 Insulin Pump',
'eventDateTime': '2022-08-20T07:25:24',
'eventTypeId': 16,
'indexId': 844955,
'interactive': 0,
'iob': 0.75,
'note': { 'active': False,
'eventId': 0,
'eventTypeId': 16,
'id': 0,
'indexId': '',
'sourceRecordId': 0},
'requestDateTime': '0001-01-01T00:00:00',
'serialNumber': 'xxx',
'sourceRecId': 793549667,
'tempRateActivated': 0,
'tempRateCompleted': 0,
'tempRateId': 0,
'type': 'BG',
'uploadId': 0}
"""
class BasalTherapyEvent(TherapyEvent):
"""
{
'basalRate': {
'duration': 0,
'percent': 0,
'value': 0.0
},
'displayInHistory': 0,
'eventDateTime': '2022-12-02T00:00:00',
'note': {
'id': 0,
'indexId': '16403',
'eventTypeId': 90,
'sourceRecordId': 0,
'eventId': 0,
'active': False
},
'noteDate': {},
'requestDateTime': '0001-01-01T00:00:00',
'type': 'Basal',
'description': 'NDE',
'sourceRecId': xxx,
'eventTypeId': 0,
'indexId': 0,
'uploadId': 0,
'interactive': 1,
'tempRateId': 0,
'tempRateCompleted': 0,
'tempRateActivated': 0
}
"""
basalRateValue = None
basalRatePercent = None
basalRateDuration = None
eventTime = None
@classmethod
def parse(_, json):
self = CGMTherapyEvent()
TherapyEvent.parse(self, json)
if 'basalRate' in json:
self.basalRateValue = json['basalRate']['value']
self.basalRatePercent = json['basalRate']['percent']
self.basalRateDuration = json['basalRate']['duration']
self.eventTime = json['eventDateTime']
return self
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env python3
class Time:
def __init__(self, hour: int, min: int):
self.hour = hour
self.min = min
@classmethod
def parse(cls, input):
if ' ' not in input:
raise ValueError('unable to parse time: %s' % input)
hrmin, ampm = input.split(' ')
hr, min = hrmin.split(':')
hr = int(hr)
min = int(min)
if ampm.lower() == 'pm':
hr += 12
elif ampm.lower() != 'am':
raise ValueError('unable to parse time: %s' % input)
return cls(hr, min)
+66 -1
View File
@@ -1,6 +1,12 @@
import re
def _norm(s):
return re.sub(r'[^a-z0-9]', '', s.lower())
header = '''# THIS FILE IS AUTOGENERATED. DO NOT EDIT.
import struct
import logging
import re
from dataclasses import dataclass
from enum import Enum, IntFlag
from .raw_event import RawEvent, BaseEvent
@@ -9,6 +15,19 @@ logger = logging.getLogger(__name__)
EVENT_LEN = 26
def _norm(s):
return re.sub(r'[^a-z0-9]', '', s.lower())
def _bitmask_arr_to_int(v):
# pump-logs bitmask fields arrive as arrays of set-bit indices; convert to the
# int the generated IntFlag / bitmask_to_list expects. Tolerate an int too.
if isinstance(v, (list, tuple)):
r = 0
for i in v:
r |= (1 << int(i))
return r
return int(v) if v is not None else 0
'''
TYPE_TO_STRUCT = {
@@ -56,6 +75,14 @@ class {name}(BaseEvent):
{build_p2}
)
@staticmethod
def build_from_json(event):
props = {{_norm(k): v for k, v in event.get("eventProperties", {{}}).items()}}
return {name}(
raw = RawEvent.build_from_json(event),
{build_json}
)
@property
def eventTimestamp(self):
return self.raw.timestamp
@@ -68,6 +95,15 @@ class {name}(BaseEvent):
def eventId(self):
return self.ID
def todict(self):
return dict(
id=self.ID,
name=self.NAME,
seqNum=self.seqNum,
eventTimestamp=str(self.eventTimestamp),
{fields_dict}
)
'''
def firstLower(text):
@@ -83,7 +119,12 @@ def eventNameFormat(text):
def fieldNameFormat(text):
if not text or all([i.isupper() for i in text]):
return text
return firstLower(text.replace('_', ' ').title().replace(' ', '')).replace('raw', 'Raw')
if '_' in text or ' ' in text:
# snake_case / space-separated -> CamelCase, then lowercase first char
return firstLower(text.replace('_', ' ').title().replace(' ', '')).replace('raw', 'Raw')
# already camelCase (schema keys): preserve internal capitalization, only
# lowercase the first character (don't .title() it away)
return firstLower(text)
def build_fields(event_def):
@@ -97,6 +138,14 @@ def build_fields(event_def):
return '\n'.join([f'{" "*4}{f}' for f in ret])
def build_fields_dict(event_def):
ret = []
for name, field in event_def["data"].items():
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
f = f'{fieldNameFormat(name)}{suffix}=self.{fieldNameFormat(name)}{suffix},'
ret.append(f)
return '\n'.join([f'{" "*12}{f}' for f in ret])
def build_decode(event_def):
p1s = []
@@ -113,6 +162,20 @@ def build_decode(event_def):
return '\n'.join([f'{" "*8}{f}' for f in p1s]), '\n'.join([f'{" "*12}{f}' for f in p2s])
def build_json_kwargs(event_def):
lines = []
for name, field in event_def["data"].items():
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
attr = f'{fieldNameFormat(name)}{suffix}'
key = _norm(name)
is_bitmask = "transform" in field and any(tx[0] == 'bitmask' for tx in field["transform"])
if is_bitmask:
lines.append(f'{attr} = _bitmask_arr_to_int(props.get("{key}", 0)),')
else:
lines.append(f'{attr} = props.get("{key}", None),')
return '\n'.join([f'{" "*12}{l}' for l in lines])
def build_transform_funcs(event_def):
try:
from transforms import TRANSFORMS
@@ -134,8 +197,10 @@ def build_event(event_id, event_def):
return TEMPLATE.format(
name = eventNameFormat(event_def["name"]),
fields = build_fields(event_def),
fields_dict = build_fields_dict(event_def),
build_p1 = build_decode(event_def)[0],
build_p2 = build_decode(event_def)[1],
build_json = build_json_kwargs(event_def),
transform_funcs = build_transform_funcs(event_def),
id = event_id,
raw_name = event_def["name"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -1,5 +1,6 @@
import struct
import base64
import logging
from dataclasses import dataclass
@@ -7,15 +8,34 @@ from .raw_event import RawEvent, EVENT_LEN
from .events import EVENT_IDS
from .utils import batched
logger = logging.getLogger(__name__)
def Event(x):
# Accepts either a 26-byte binary event or a pump-logs JSON event (dict).
if isinstance(x, dict):
raw_event = RawEvent.build_from_json(x)
if not raw_event.id in EVENT_IDS:
# Log unknown events with their property keys for reverse-engineering
props = ' '.join(x['eventProperties'].keys())
logger.debug(f"UNKNOWN_JSON_EVENT | id={raw_event.id} | seqNum={raw_event.seqNum} | timestamp={raw_event.timestamp.isoformat()} | props={props}")
return raw_event
return EVENT_IDS[raw_event.id].build_from_json(x)
raw_event = RawEvent.build(x)
if not raw_event.id in EVENT_IDS:
# Log unknown events with full hex dump for reverse-engineering
hex_dump = ' '.join(f'{b:02x}' for b in x[:EVENT_LEN])
logger.debug(f"UNKNOWN_EVENT | id={raw_event.id} | seqNum={raw_event.seqNum} | timestamp={raw_event.timestamp.isoformat()} | bytes={hex_dump}")
return raw_event
return EVENT_IDS[raw_event.id].build(x)
Events = lambda x: (Event(bytearray(e)) for e in batched(x, EVENT_LEN))
def Events(x):
# Accepts either a raw binary event stream or an iterable of pump-logs JSON events.
if isinstance(x, (bytes, bytearray)):
return (Event(bytearray(e)) for e in batched(x, EVENT_LEN))
return (Event(e) for e in x)
def decode_raw_events(raw):
return base64.b64decode(raw)
return base64.b64decode(raw)
+25 -1
View File
@@ -6,6 +6,7 @@ from ..secret import TIMEZONE_NAME
from dataclasses import dataclass
EVENT_LEN = 26
# Big endian
UINT16 = '>H'
UINT32 = '>I'
TANDEM_EPOCH = 1199145600
@@ -47,6 +48,22 @@ class RawEvent:
raw = raw
)
@staticmethod
def build_from_json(event):
# pump-logs JSON events carry pumpDateTime (naive local wall-clock,
# no tz). Reproduce the byte path: store timestampRaw as seconds since
# TANDEM_EPOCH parsed AS IF UTC, so the .timestamp property re-forces
# the same wall-clock into TIMEZONE_NAME. source is unused; raw bytes
# are absent on the JSON path.
timestampRaw = arrow.get(event["pumpDateTime"]).int_timestamp - TANDEM_EPOCH
return RawEvent(
source = 0,
id = event["eventCode"],
timestampRaw = timestampRaw,
seqNum = event["sequenceNumber"],
raw = b''
)
@property
def timestamp(self):
# Event timestamps do not have TZ data attached to them when parsed,
@@ -63,4 +80,11 @@ class RawEvent:
def eventTimestamp(self):
return self.timestamp
def todict(self):
return dict(
id=self.id,
name="RawEvent",
seqNum=self.seqNum,
eventTimestamp=str(self.eventTimestamp),
raw=''.join('{:02x}'.format(x) for x in self.raw),
)
+13 -2
View File
@@ -50,7 +50,7 @@ ALERTS_DICT = {
"48": "CGM_UNAVAILABLE",
"49": "DEFAULT_ALERT_49",
"50": "DEFAULT_ALERT_50",
"51": "DEFAULT_ALERT_51",
"51": "CONTROL_IQ_LOW",
"52": "DEFAULT_ALERT_52",
"53": "DEFAULT_ALERT_53",
"54": "DEVICE_PAIRED",
@@ -132,13 +132,24 @@ ALARMS_DICT = {
"63": "DEFAULT_ALARM_63",
}
# CGM alert codes from pump history verification (verified from pump display)
CGM_ALERTS_DICT = {
"1": "CGM Fixed Low",
"2": "CGM High",
"3": "CGM Low",
"8": "CGM Rapid Fall",
"11": "CGM Sensor Fail",
"12": "CGM Sensor Expiring Soon",
"13": "CGM Sensor Expired",
"14": "CGM Out Of Range",
"20": "CGM Transmitter Error",
"22": "CGM Sensor Expiring 2",
"25": "CGM Replace Sensor",
"26": "CGM Temperature",
"27": "CGM Failed Connection",
"39": "CGM Transmitter Expired",
"40": "Pump Bluetooth Error"
"40": "Pump Bluetooth Error",
"45": "CGM Transmitter Expiring Soon",
"46": "CGM Transmitter Expiring 2",
"48": "CGM Unavailable"
}
+30 -4
View File
@@ -39,15 +39,40 @@ def enumNameFormat(text):
return f'{t[0].upper()}{t[1:]}'
def uniqueMemberNames(tx):
names = {}
for key, value in tx.items():
name = enumNameFormat(value)
if not name:
continue
names.setdefault(name, []).append(str(key))
unique_names = {}
for key, value in tx.items():
name = enumNameFormat(value)
if not name:
continue
if len(names[name]) == 1:
unique_names[key] = name
continue
unique_names[key] = f'{name}_{key}'
return unique_names
def transform_enum(event_def, name, name_fmt, field, tx):
out = []
member_names = uniqueMemberNames(tx)
lines_for_out = json.dumps(tx, indent=4).splitlines()
out += [f'{enumNameFormat(name_fmt)}Map = {lines_for_out[0]}']
out += lines_for_out[1:]
out += ['']
out += [f'class {enumNameFormat(name_fmt)}Enum(Enum):']
out += [
f' {enumNameFormat(v)} = {k}' for k, v in tx.items() if enumNameFormat(v)
f' {member_names[k]} = {k}' for k, v in tx.items() if k in member_names
]
out += ['']
out += [
@@ -77,13 +102,14 @@ def transform_dictionary(event_def, name, name_fmt, field, tx):
def transform_bitmask(event_def, name, name_fmt, field, tx):
out = []
member_names = uniqueMemberNames(tx)
lines_for_out = json.dumps(tx, indent=4).splitlines()
out += [f'{enumNameFormat(name_fmt)}Map = {lines_for_out[0]}']
out += lines_for_out[1:]
out += ['']
out += [f'class {enumNameFormat(name_fmt)}Bitmask(IntFlag):',]
out += [
f' {enumNameFormat(v)} = 2**{k}' for k, v in tx.items() if enumNameFormat(v)
f' {member_names[k]} = 2**{k}' for k, v in tx.items() if k in member_names
]
out += ['']
out += [
@@ -116,7 +142,7 @@ def transform_battery_charge_percent(event_def, name, name_fmt, field, tx):
out += [
'@property',
f'def batteryChargePercent(self):',
f' return (256*(self.batterychargepercentmsbRaw-14)+self.batterychargepercentlsbRaw)/(3*256)',
f' return (256*(self.batteryChargePercentMSBRaw-14)+self.batteryChargePercentLSBRaw)/(3*256)',
''
]
@@ -129,4 +155,4 @@ TRANSFORMS = {
'bitmask': transform_bitmask,
'ratio': transform_ratio,
'battery_charge_percent': transform_battery_charge_percent
}
}
+33 -75
View File
@@ -7,19 +7,26 @@ import arrow
import logging
from urllib.parse import urljoin
from typing import Optional, Union
from .api.common import ApiException
from .parser.nightscout import ENTERED_BY
def format_datetime(date):
# Anything arrow.get() accepts for the date filters / timestamps passed around
# in this module (ISO strings, datetimes, or already-parsed Arrow objects).
DateLike = Union[str, datetime.datetime, arrow.Arrow]
def format_datetime(date: DateLike) -> str:
return arrow.get(date).isoformat()
def time_range(field_name, start_time, end_time, t_to_space=False):
def fmt(date):
def time_range(field_name: str, start_time: Optional[DateLike], end_time: Optional[DateLike]) -> str:
def fmt(date: DateLike) -> str:
ret = format_datetime(date)
if t_to_space:
return ret.replace('T', ' ')
return ret
# URL-encode so the '+' in offsets like '+02:00' is not decoded
# to a space by the server, which would mangle the ISO-8601 value.
# Upstream instead retries with 'T' replaced by a space (t_to_space);
# encoding the value fixes the cause, so that fallback is not carried.
return urllib.parse.quote(ret, safe='')
arg = ''
if start_time:
arg += '&find[%s][$gte]=%s' % (field_name, fmt(start_time))
@@ -30,14 +37,14 @@ def time_range(field_name, start_time, end_time, t_to_space=False):
logger = logging.getLogger(__name__)
class NightscoutApi:
def __init__(self, url, secret, skip_verify=False, ignore_conn_errors=False):
def __init__(self, url: str, secret: str, skip_verify: bool = False, ignore_conn_errors: bool = False) -> None:
self.url = url
self.secret = secret
self.verify = False if skip_verify else None
self.ignore_conn_errors = ignore_conn_errors
def upload_entry(self, ns_format, entity='treatments'):
def upload_entry(self, ns_format: dict, entity: str = 'treatments') -> None:
r = requests.post(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
@@ -46,7 +53,7 @@ class NightscoutApi:
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout upload %s response: %s" % (r.status_code, r.text))
def delete_entry(self, entity):
def delete_entry(self, entity: str) -> None:
r = requests.delete(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json={}, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
@@ -55,7 +62,7 @@ class NightscoutApi:
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout delete %s response: %s" % (r.status_code, r.text))
def put_entry(self, ns_format, entity):
def put_entry(self, ns_format: dict, entity: str) -> None:
r = requests.put(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
@@ -64,132 +71,83 @@ class NightscoutApi:
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout put %s response: %s" % (r.status_code, r.text))
def last_uploaded_entry(self, eventType, time_start=None, time_end=None):
def internal(t_to_space):
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space)
def last_uploaded_entry(self, eventType: str, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
dateFilter = time_range('created_at', time_start, time_end)
try:
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout last_uploaded_entry %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (eventType, time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
try:
ret = None
try:
ret = internal(False)
except ApiException as e:
#logger.warning("last_uploaded_entry with no t_to_space: %s", e)
ret = None
if ret is None and (time_start or time_end):
try:
ret = internal(True)
except ApiException as e:
#logger.warning("last_uploaded_entry with t_to_space: %s", e)
ret = None
if ret is not None:
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
return ret
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
def last_uploaded_bg_entry(self, time_start=None, time_end=None):
def internal(t_to_space):
dateFilter = time_range('dateString', time_start, time_end, t_to_space=t_to_space)
def last_uploaded_bg_entry(self, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
dateFilter = time_range('dateString', time_start, time_end)
try:
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout last_uploaded_bg_entry could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("last_uploaded_bg_entry with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
return ret
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
def last_uploaded_activity(self, activityType, time_start=None, time_end=None):
def internal(t_to_space):
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space)
def last_uploaded_activity(self, activityType: str, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
dateFilter = time_range('created_at', time_start, time_end)
try:
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout activity %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (activityType, time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout activity %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("last_uploaded_activity with activityType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (activityType, time_start, time_end))
return ret
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
def last_uploaded_devicestatus(self, time_start=None, time_end=None):
def internal(t_to_space):
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space)
def last_uploaded_devicestatus(self, time_start: Optional[DateLike] = None, time_end: Optional[DateLike] = None) -> Optional[dict]:
dateFilter = time_range('created_at', time_start, time_end)
try:
latest = requests.get(urljoin(self.url, 'api/v1/devicestatus?find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout devicestatus could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout devicestatus %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("devicestatus time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
return ret
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
-27
View File
@@ -1,27 +0,0 @@
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent, BGTherapyEvent, BasalTherapyEvent
from tconnectsync.parser.tconnect import TConnectEntry
import logging
logger = logging.getLogger(__name__)
def split_therapy_events(ciqTherapyEvents):
bolusEvents = []
cgmEvents = []
bgEvents = []
basalEvents = []
for e in ciqTherapyEvents['event']:
event = TConnectEntry.parse_therapy_event(e)
if isinstance(event, BolusTherapyEvent):
bolusEvents.append(event)
elif isinstance(event, CGMTherapyEvent):
cgmEvents.append(event)
elif isinstance(event, BGTherapyEvent):
bgEvents.append(event)
elif isinstance(event, BasalTherapyEvent):
basalEvents.append(event)
logger.debug("split_therapy_events: %d bolus, %d CGM, %d BG, %d basal" % (len(bolusEvents), len(cgmEvents), len(bgEvents), len(basalEvents)))
# TODO: BG events (CGM Calibration) values are not currently returned from ciq_therapy_events.py
return bolusEvents, cgmEvents
+19 -84
View File
@@ -1,6 +1,5 @@
import arrow
from ..domain.device_settings import Profile, DeviceSettings
from ..domain.tandemsource.pump_settings import PumpProfile, PumpSettings
from ..secret import TIMEZONE_NAME, NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE
@@ -210,102 +209,59 @@ class NightscoutEntry:
"pump_event_id": pump_event_id
}
# Tandem-scraped profile to Nightscout profile store entry
@staticmethod
def profile_store(profile: Profile, device_settings: DeviceSettings) -> dict:
return {
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
"dia": "%s" % (profile.insulin_duration_min / 60),
"carbratio": [
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.carb_ratio
} for segment in profile.segments
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [ # Correction factor
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.correction_factor
} for segment in profile.segments
],
"basal": [
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.basal_rate
} for segment in profile.segments
],
"target_low": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": device_settings.low_bg_threshold
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": device_settings.high_bg_threshold
}
],
"timezone": TIMEZONE_NAME, # tconnectsync settings timezone
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
# TandemSource profile to Nightscout profile store entry
@staticmethod
def tandemsource_profile_store(profile: PumpProfile, pump_settings: PumpSettings) -> dict:
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": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": pump_settings.cgmSettings.lowGlucoseAlert.mgPerDl
"value": pump_settings.cgmSettings.lowGlucoseAlertMgPerDl
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": pump_settings.cgmSettings.highGlucoseAlert.mgPerDl
"value": pump_settings.cgmSettings.highGlucoseAlertMgPerDl
}
],
"timezone": TIMEZONE_NAME, # tconnectsync settings timezone
@@ -313,24 +269,6 @@ class NightscoutEntry:
"units": "mg/dl"
}
def tandem_to_ns_time(tandem_time: str) -> str:
numbers, ampm = tandem_time.split(' ')
hr, min = numbers.split(':')
if ampm.lower().strip() == 'am':
return "%02d:%02d" % (int(hr) % 12, int(min))
elif ampm.lower().strip() == 'pm':
return "%02d:%02d" % (12 + (int(hr) % 12), int(min))
raise InvalidTimeException(tandem_time)
def tandem_to_ns_time_seconds(tandem_time: str) -> int:
numbers, ampm = tandem_time.split(' ')
hr, min = numbers.split(':')
if ampm.lower().strip() == 'am':
return 60 * (60 * (int(hr) % 12) + int(min))
elif ampm.lower().strip() == 'pm':
return 60 * (60 * (12 + (int(hr) % 12)) + int(min))
raise InvalidTimeException(tandem_time)
def minutes_to_ns_time(minutes_time: int) -> str:
hr = minutes_time // 60
mn = minutes_time % 60
@@ -338,7 +276,4 @@ def minutes_to_ns_time(minutes_time: int) -> str:
return "%02d:%02d" % (hr, mn)
class InvalidBolusTypeException(RuntimeError):
pass
class InvalidTimeException(RuntimeError):
pass
-221
View File
@@ -1,221 +0,0 @@
from os import stat
import sys
import arrow
from tconnectsync.domain.bolus import Bolus
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent, BGTherapyEvent, BasalTherapyEvent
try:
from ..secret import TIMEZONE_NAME
except Exception:
print('Unable to import parser secrets from secret.py')
sys.exit(1)
"""
Conversion methods for parsing raw t:connect data into
a more digestable format, which is used internally.
"""
class TConnectEntry:
BASAL_EVENTS = { 0: "Suspension", 1: "Profile", 2: "TempRate", 3: "Algorithm" }
@staticmethod
def _epoch_parse(x):
# data["x"] is an integer epoch timestamp which, when read as an equivalent timestamp
# stored in Pacific time (America/Los_Angeles), contains the user's local time, but
# with the wrong timezone data attached.
#
# For example, data["x"] references UTC timestamp 2020-09-01T13:00:00+00:00,
# which when read in Pacific time is equivalent to 2020-09-01T06:00:00-07:00.
# However, the user's timezone is Eastern time, so the timezone of America/Los_Angeles
# is overwritten with America/New_York, resulting in 2020-09-01T06:00:00-04:00, the
# correct timestamp.
return arrow.get(x, tzinfo="America/Los_Angeles").replace(tzinfo=TIMEZONE_NAME)
@staticmethod
def _jsonp_epoch_parse(x):
return TConnectEntry._epoch_parse(int(x.replace('/Date(', '').replace('-0000)/', '')))
@staticmethod
def parse_ciq_basal_entry(data, delivery_type=""):
time = TConnectEntry._epoch_parse(data["x"])
duration_mins = data["duration"] / 60
basal_rate = data["y"]
return {
"time": time.format(),
"delivery_type": delivery_type,
"duration_mins": duration_mins,
"basal_rate": basal_rate,
}
@staticmethod
def manual_suspension_to_basal_entry(parsedSuspension, seconds):
duration_mins = seconds / 60
return {
"time": parsedSuspension["time"],
"delivery_type": "%s suspension" % parsedSuspension["suspendReason"],
"duration_mins": duration_mins,
"basal_rate": 0.0
}
@staticmethod
def parse_suspension_entry(data):
time = TConnectEntry._epoch_parse(data["x"])
return {
"time": time.format(),
"continuation": data["continuation"],
"suspendReason": data["suspendReason"],
}
@staticmethod
def _datetime_parse(date):
return arrow.get(date, tzinfo=TIMEZONE_NAME)
@staticmethod
def parse_cgm_entry(data):
# EventDateTime is stored in the user's timezone.
return {
"time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(),
"reading": data["Readings (CGM / BGM)"],
"reading_type": data["Description"],
}
@staticmethod
def parse_iob_entry(data):
# EventDateTime is stored in the user's timezone.
return {
"time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(),
"iob": data["IOB"],
"event_id": data["EventID"],
}
@staticmethod
def parse_csv_basal_entry(data, duration_mins=None):
# EventDateTime is stored in the user's timezone.
return {
"time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(),
"delivery_type": "Unknown",
"duration_mins": duration_mins,
"basal_rate": data["BasalRate"],
}
@staticmethod
def parse_bolus_entry(data):
# All DateTime's are stored in the user's timezone.
def is_complete(s):
return s and int(s) == 1
complete = is_complete(data["ExtendedBolusIsComplete"]) or is_complete(data["BolusIsComplete"])
extended_bolus = ("extended" in data["Description"].lower())
return Bolus(**{
"description": data["Description"],
"complete": "1" if complete else "",
"completion": data["CompletionStatusDesc"] if not extended_bolus else data["BolexCompletionStatusDesc"],
"request_time": TConnectEntry._datetime_parse(data["RequestDateTime"]).format() if not extended_bolus else None,
"completion_time": TConnectEntry._datetime_parse(data["CompletionDateTime"]).format() if not extended_bolus else None,
"insulin": data["InsulinDelivered"],
"requested_insulin": data["ActualTotalBolusRequested"],
"carbs": data["CarbSize"],
"bg": data["BG"], # Note: can be empty string for automatic Control-IQ boluses
"user_override": data["UserOverride"],
"extended_bolus": "1" if extended_bolus else "",
# Note: completion time can be empty if the extended bolus is in progress
"bolex_completion_time": TConnectEntry._datetime_parse(data["BolexCompletionDateTime"]).format() if data["BolexCompletionDateTime"] and complete and extended_bolus else None,
"bolex_start_time": TConnectEntry._datetime_parse(data["BolexStartDateTime"]).format() if data["BolexStartDateTime"] and complete and extended_bolus else None,
})
@staticmethod
def parse_reading_entry(data):
return {
"time": TConnectEntry._datetime_parse(data["EventDateTime"]).format(),
"bg": data["Readings (CGM / BGM)"],
"type": data["Description"]
}
ACTIVITY_EVENTS = { 1: "Sleep", 2: "Exercise", 3: "AutoBolus", 4: "CarbOnly" }
@staticmethod
def parse_ciq_activity_event(data):
if data["eventType"] not in TConnectEntry.ACTIVITY_EVENTS.keys():
raise UnknownCIQActivityEventException(data)
time = TConnectEntry._epoch_parse(data["x"])
return {
"time": time.format(),
"duration_mins": data["duration"] / 60,
"event_type": TConnectEntry.ACTIVITY_EVENTS[data["eventType"]]
}
BASALSUSPENSION_EVENTS = {
# site-cart corresponds to a Site or Cartridge change,
# specifically a Tubing Filled: Norm AND a Cannula Filled: Norm alert.
# (This means that a typical changing of a cartridge and then a site
# will result in two consecutive events of this type.)
"site-cart": "Site/Cartridge Change",
# alarm corresponds to one of the following:
# - an Empty Cartridge alarm
# - a Pump shutdown
"alarm": "Empty Cartridge/Pump Shutdown",
# manual corresponds to a Pumping Suspended by User event
"manual": "User Suspended",
# temp-profile corresponds to a Basal Rate Change event to 0u/hr
"temp-profile": "Basal Rate Change"
}
BASALSUSPENSION_SKIPPED_EVENTS = {
# basal-profile events are not very useful; with ControlIQ enabled,
# Tandem does not show them in the tconnect UI.
"basal-profile",
# If an event continues to occur after the date switches over to the next
# day, then the pump generates a "previous" event. This isn't useful to
# us, so we skip them.
"previous",
}
@staticmethod
def parse_basalsuspension_event(data):
if not data or "SuspendReason" not in data:
return None
if data["SuspendReason"] in TConnectEntry.BASALSUSPENSION_SKIPPED_EVENTS:
return None
if data["SuspendReason"] not in TConnectEntry.BASALSUSPENSION_EVENTS.keys():
raise UnknownBasalSuspensionEventException(data)
time = TConnectEntry._jsonp_epoch_parse(data["EventDateTime"])
return {
"time": time.format(),
"event_type": TConnectEntry.BASALSUSPENSION_EVENTS[data["SuspendReason"]]
}
# Parses an entry from controliq.therapy_events() and returns a TherapyEvent
@staticmethod
def parse_therapy_event(data):
if data["type"] == "Bolus":
return BolusTherapyEvent.parse(data)
elif data["type"] == "CGM":
return CGMTherapyEvent.parse(data)
elif data["type"] == "BG":
return BGTherapyEvent.parse(data)
elif data["type"] == "Basal":
return BasalTherapyEvent.parse(data)
raise UnknownTherapyEventException(data)
class UnknownCIQActivityEventException(Exception):
def __init__(self, data):
super().__init__("Unknown CIQ activity event type: %s" % data)
class UnknownBasalSuspensionEventException(Exception):
def __init__(self, data):
super().__init__("Unknown basal suspension event type: %s" % data)
class UnknownTherapyEventException(Exception):
def __init__(self, data):
typ = data["type"]
super().__init__(f"Unknown therapy event type: {typ} in {data}")
-202
View File
@@ -1,202 +0,0 @@
import logging
import datetime
import arrow
import time
from tconnectsync.parser.ciq_therapy_events import split_therapy_events
from .util import timeago
from .api.common import ApiException
from .sync.basal import (
process_ciq_basal_events,
add_csv_basal_events,
ns_write_basal_events
)
from .sync.bolus import (
process_bolus_events,
ns_write_bolus_events
)
from .sync.iob import (
process_iob_events,
ns_write_iob_events
)
from .sync.cgm import (
process_cgm_events,
ns_write_cgm_events
)
from .sync.pump_events import (
process_ciq_activity_events,
process_basalsuspension_events,
ns_write_pump_events
)
from .sync.profile import process_profiles
from .parser.tconnect import TConnectEntry
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS, PROFILES, PUMP_EVENTS_BASAL_SUSPENSION
from tconnectsync.sync import basal
logger = logging.getLogger(__name__)
"""
Given a TConnectApi object and start/end range, performs a single
cycle of synchronizing data within the time range.
If pretend is true, then doesn't actually write data to Nightscout.
"""
def process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
ciqTherapyTimelineData = None
if BASAL in features or PUMP_EVENTS in features:
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
# device in the time range which is queried. Since it launched in early 2020,
# ignore 404's before February.
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqTherapyTimelineData = None
else:
raise e
csvReadingData = None
csvIobData = None
csvBasalData = None
csvBolusData = None
ciqBolusData = None
ciqReadingData = None
if BOLUS in features:
logger.info("Downloading t:connect therapy_events")
ciqTherapyEventsData = tconnect.controliq.therapy_events(time_start, time_end)
ciqBolusData, ciqReadingData = split_therapy_events(ciqTherapyEventsData)
if ciqReadingData and len(ciqReadingData) > 0:
lastReading = ciqReadingData[-1].eventDateTime
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(ciqReadingData[-1])
logger.info("Last CGM reading from t:connect CIQ: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined from CIQ")
if ciqBolusData and len(ciqBolusData) > 0:
lastBolus = ciqBolusData[-1].eventDateTime
lastReading = TConnectEntry._datetime_parse(lastBolus)
logger.debug(ciqBolusData[-1].to_bolus())
logger.info("Last bolus from t:connect CIQ: %s (%s)" % (lastBolus, timeago(lastBolus)))
bolusFallingBack = (BOLUS in features and not ciqBolusData)
ciqFallingBack = (CGM in features and not ciqReadingData)
if bolusFallingBack or \
ciqFallingBack or \
BOLUS_BG in features or \
IOB in features:
logger.warning("Downloading t:connect CSV data")
if bolusFallingBack:
logger.warning("Falling back on WS2 CSV data source because BOLUS is an enabled feature and CIQ bolus data was empty!!")
if ciqFallingBack:
logger.warning("Falling back on WS2 CSV data source because CGM is an enabled feature and CIQ cgm data was empty!!")
if BOLUS_BG in features:
logger.warning("Falling back on WS2 CSV data source because BOLUS_BG is an enabled feature. " +
"Please consider disabling this feature to improve synchronization reliability.")
if IOB in features:
logger.warning("Falling back on WS2 CSV data source because IOB is an enabled feature. " +
"Please consider disabling this feature to improve synchronization reliability.")
logger.warning("<!!> The WS2 data source is unreliable and may prevent timely synchronization")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
csvReadingData = csvdata["readingData"]
csvIobData = csvdata["iobData"]
csvBasalData = csvdata["basalData"]
csvBolusData = csvdata["bolusData"]
if csvReadingData and len(csvReadingData) > 0:
lastReading = csvReadingData[-1]['EventDateTime'] if 'EventDateTime' in csvReadingData[-1] else 0
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(csvReadingData[-1])
logger.info("Last CGM reading from t:connect CSV: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined from CSV")
added = 0
if csvReadingData:
cgmData = None
if CGM in features or BOLUS_BG in features:
logger.debug("Processing CGM events")
cgmData = process_cgm_events(csvReadingData)
if CGM in features:
logger.debug("Writing CGM events")
added += ns_write_cgm_events(nightscout, cgmData, pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing CGM events")
if BASAL in features:
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
if csvBasalData:
logger.debug("CSV basal data found: processing it")
add_csv_basal_events(basalEvents, csvBasalData)
else:
logger.debug("No CSV basal data found")
if basalEvents and len(basalEvents) > 0:
logger.info("Last basal event from CIQ: %s" % basalEvents[-1])
logger.debug("Writing basal events")
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
if PUMP_EVENTS_BASAL_SUSPENSION in features:
logger.warning("Using WS2 data source for basalsuspension because PUMP_EVENTS_BASAL_SUSPENSION is an enabled feature")
logger.warning("<!!> The WS2 data source is unreliable and may prevent timely synchronization")
ws2BasalSuspension = tconnect.ws2.basalsuspension(time_start, time_end)
bsPumpEvents = process_basalsuspension_events(ws2BasalSuspension)
logger.debug("basalsuspension events: %s" % bsPumpEvents)
logger.debug("Writing pump basalsuspension events")
added += ns_write_pump_events(nightscout, bsPumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
if PUMP_EVENTS in features:
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
logger.debug("CIQ activity events: %s" % pumpEvents)
logger.debug("Writing pump events")
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
if BOLUS in features:
bolusEvents = []
if ciqBolusData:
logger.info("Processing ciqBolusData (%d entries)" % len(ciqBolusData))
bolusEvents = process_bolus_events(ciqBolusData, source="ciq")
if csvBolusData and not bolusEvents:
logger.warning("Falling back on non-CIQ csvBolusData")
bolusEvents = process_bolus_events(csvBolusData, source="csv")
logger.debug("ciq bolusEvents: %s" % bolusEvents)
logger.info("finalized bolusEvents: %s" % bolusEvents)
logger.debug("Writing bolus events")
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features), time_start=time_start, time_end=time_end)
logger.debug("Finished writing bolus events")
if csvIobData:
if IOB in features:
iobEvents = process_iob_events(csvIobData)
logger.debug("Writing iob events")
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
logger.debug("Finished writing iob events")
if PROFILES in features:
logger.debug("Running profiles feature")
if process_profiles(tconnect, nightscout, pretend=pretend):
added += 1
if pretend:
logger.info("Would have written %d events to Nightscout this process cycle (in pretend mode)" % added)
else:
logger.info("Wrote %d events to Nightscout this process cycle" % added)
return added
+8 -2
View File
@@ -41,6 +41,7 @@ def get_bool(name, default):
TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com')
TCONNECT_PASSWORD = get('TCONNECT_PASSWORD', 'password')
TCONNECT_REGION = get_one_of('TCONNECT_REGION', 'US', ['US', 'EU'])
PUMP_SERIAL_NUMBER = int(get_number('PUMP_SERIAL_NUMBER', '11111111'))
@@ -70,8 +71,13 @@ AUTOUPDATE_MAX_SLEEP_SECONDS = get_number('AUTOUPDATE_MAX_SLEEP_SECONDS', '1500'
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS = get_number('AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS', '60') # 1 minute
AUTOUPDATE_USE_FIXED_SLEEP = get_bool('AUTOUPDATE_USE_FIXED_SLEEP', 'false')
AUTOUPDATE_NO_DATA_FAILURE_MINUTES = get_number('AUTOUPDATE_NO_DATA_FAILURE_MINUTES', '180') # 3 hours
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15 minutes
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true')
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '75') # 75 minutes
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
# Give up and exit non-zero after this many minutes of unbroken API/network
# failure, so the container platform notices (and, if configured, notifies).
# Distinct from AUTOUPDATE_RESTART_ON_FAILURE, which covers the pump not
# uploading -- a case where restarting achieves nothing. Set 0 to never exit.
AUTOUPDATE_API_FAILURE_MINUTES = get_number('AUTOUPDATE_API_FAILURE_MINUTES', '45') # 45 minutes
AUTOUPDATE_MAX_LOOP_INVOCATIONS = get_number('AUTOUPDATE_MAX_LOOP_INVOCATIONS', '-1')
NIGHTSCOUT_PROFILE_UPLOAD_MODE = get_one_of('NIGHTSCOUT_PROFILE_UPLOAD_MODE', 'add', ['add', 'replace'])
-161
View File
@@ -1,161 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
BASAL_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
logger = logging.getLogger(__name__)
"""
Merges together input from the therapy timeline API
into a digestable format of basal data.
"""
def process_ciq_basal_events(data):
if data is None:
return []
suspensionEvents = {}
for s in data["suspensionDeliveryEvents"]:
entry = TConnectEntry.parse_suspension_entry(s)
suspensionEvents[entry["time"]] = entry
basalEvents = []
for b in data["basal"]["tempDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="tempDelivery"))
for b in data["basal"]["algorithmDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="algorithmDelivery"))
for b in data["basal"]["profileDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="profileDelivery"))
# Suspensions with suspendReason 'control-iq' will match a basal event found above.
for i in basalEvents:
if i["time"] in suspensionEvents:
i["delivery_type"] += " (" + suspensionEvents[i["time"]]["suspendReason"] + " suspension)"
del suspensionEvents[i["time"]]
# Suspensions with suspendReason 'manual' do not have an associated basal event,
# and require extra processing.
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
unprocessedSuspensions = list(suspensionEvents.values())
unprocessedSuspensions.sort(key=lambda x: arrow.get(x["time"]))
# For the remaining suspensions which did not match with an existing basal event,
# add a new event manually. This means we need to calculate the duration of the
# suspension.
newEvents = []
for i in range(len(basalEvents)):
if len(unprocessedSuspensions) == 0:
break
existingTime = arrow.get(basalEvents[i]["time"])
unprocessedTime = arrow.get(unprocessedSuspensions[0]["time"])
# If we've found an event which occurs after the suspension, then the
# difference in their timestamps is the duration of the suspension.
if i > 0 and existingTime > unprocessedTime:
suspension = unprocessedSuspensions.pop(0)
# TConnect's internal duration object tracks the duration in seconds
seconds = (existingTime - unprocessedTime).seconds
newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds)
logger.debug("Adding basal event for unprocessed suspension: %s" % newEvent)
newEvents.append(newEvent)
# Any remaining suspensions which have not been processed have not ended,
# which means we do not know their duration; so we will skip them (for now)
# Add any new events and re-sort
if newEvents:
basalEvents += newEvents
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
return basalEvents
"""
Processes basal data input from the therapy timeline CSV (which only
exists for pre Control-IQ data) into a digestable format.
"""
def add_csv_basal_events(basalEvents, data):
last_entry = {}
for row in data:
entry = TConnectEntry.parse_csv_basal_entry(row)
if last_entry:
diff_mins = (arrow.get(entry["time"]) - arrow.get(last_entry["time"])).seconds // 60
entry["duration_mins"] = diff_mins
basalEvents.append(entry)
last_entry = entry
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
return basalEvents
"""
Given processed basal data, adds basal events to Nightscout.
"""
def ns_write_basal_events(nightscout, basalEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_basal_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BASAL_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout basal upload: %s" % last_upload_time)
if SKIP_NS_LAST_UPLOADED_CHECK:
logger.warning("Overriding last upload check")
last_upload = None
last_upload_time = None
add_count = 0
for event in basalEvents:
if last_upload_time and arrow.get(event["time"]) < last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
recent_needs_update = False
if last_upload_time and arrow.get(event["time"]) == last_upload_time:
# If this entry has the same time as the most recent upload, but
# has newer info, then delete and recreate it.
recent_needs_update = (round(last_upload["duration"]) < round(event["duration_mins"]))
# If the timestamps are identical, and the duration is identical,
# then don't upload a duplicate entry of what we already have.
if not recent_needs_update:
continue
reason = event["delivery_type"]
if "suspendReason" in reason:
reason += " (" + reason["suspendReason"] + ")"
entry = NightscoutEntry.basal(
value=event["basal_rate"],
duration_mins=event["duration_mins"],
created_at=event["time"],
reason=reason
)
add_count += 1
logger.info(" Processing basal: %s entry: %s" % (event, entry))
if recent_needs_update:
logger.info("Replacing last uploaded entry: %s" % last_upload)
if not pretend:
entry['_id'] = last_upload['_id']
nightscout.put_entry(entry, entity='treatments')
elif not pretend:
nightscout.upload_entry(entry)
logger.debug("ns_write_basal_events: added %d events" % add_count)
return add_count
-120
View File
@@ -1,120 +0,0 @@
import arrow
import logging
from tconnectsync.domain.bolus import Bolus
from tconnectsync.sync.cgm import find_event_at
from ..parser.nightscout import (
BOLUS_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
logger = logging.getLogger(__name__)
"""
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_bolus_events(bolusdata, cgmEvents=None, source=""):
bolusEvents = []
for b in bolusdata:
parsed = None
if source == "ciq":
parsed = b.to_bolus()
else:
parsed = TConnectEntry.parse_bolus_entry(b)
assert type(parsed) == Bolus
if parsed.completion != "Completed":
if parsed.insulin and float(parsed.insulin) > 0:
# Count non-completed bolus if any insulin was delivered (vs. the amount of insulin requested)
parsed.description += " (%s: requested %s units)" % (parsed.completion, parsed.requested_insulin)
else:
logger.warning("Skipping non-completed %s bolus data (was a bolus in progress?): %s parsed: %s" % (source, b, parsed))
continue
if parsed.is_extended_bolus:
if not parsed.bolex_start_time and not parsed.request_time:
logger.warning("Skipping non-completed %s extended bolus data with no request_time: %s parsed: %s" % (source, b, parsed))
elif not parsed.bolex_start_time and parsed.request_time:
logger.warning("Setting bolex_start_time to request_time for non-completed %s extended bolus: %s parsed: %s" % (source, b, parsed))
parsed.bolex_start_time = parsed.request_time
logger.debug("process_bolus_events for incomplete bolus: %s parsed: %s" % (b, parsed))
elif parsed.is_extended_bolus:
logger.debug("process_bolus_events for complete extended bolus: %s parsed: %s" % (b, parsed))
if parsed.bg and cgmEvents:
requested_at = parsed.request_time if not parsed.extended_bolus else parsed.bolex_start_time
parsed.bg_type = guess_bolus_bg_type(parsed.bg, requested_at, cgmEvents)
bolusEvents.append(parsed)
bolusEvents.sort(key=lambda event: arrow.get(event.request_time if not event.is_extended_bolus else event.bolex_start_time))
return bolusEvents
"""
Determine whether the given BG specified in the bolus is identical to the
most recent CGM reading at that time. If it is, return SENSOR.
Otherwise, return FINGER.
"""
def guess_bolus_bg_type(bg, created_at, cgmEvents):
if not cgmEvents:
return NightscoutEntry.FINGER
event = find_event_at(cgmEvents, created_at)
if event and str(event["bg"]) == str(bg):
return NightscoutEntry.SENSOR
return NightscoutEntry.FINGER
"""
Given processed bolus data, adds bolus events to Nightscout.
"""
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None, time_start=None, time_end=None):
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout bolus upload: %s" % last_upload_time)
if SKIP_NS_LAST_UPLOADED_CHECK:
logger.warning("Overriding last upload check")
last_upload = None
last_upload_time = None
add_count = 0
for event in bolusEvents:
created_at = event.completion_time if not event.is_extended_bolus else event.bolex_start_time
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
if include_bg and event.bg:
entry = NightscoutEntry.bolus(
bolus=event.insulin,
carbs=event.carbs,
created_at=created_at,
notes="{}{}{}".format(event.description, " (Override)" if event.user_override == "1" else "", " (Extended)" if event.extended_bolus == "1" else ""),
bg=event.bg,
bg_type=event.bg_type
)
else:
entry = NightscoutEntry.bolus(
bolus=event.insulin,
carbs=event.carbs,
created_at=created_at,
notes="{}{}{}".format(event.description, " (Override)" if event.user_override == "1" else "", " (Extended)" if event.extended_bolus == "1" else "")
)
add_count += 1
logger.info(" Processing bolus: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry)
return add_count
-69
View File
@@ -1,69 +0,0 @@
import json
import arrow
import logging
from ..parser.tconnect import TConnectEntry
from ..parser.nightscout import NightscoutEntry
logger = logging.getLogger(__name__)
def process_cgm_events(readingData):
data = []
for r in readingData:
data.append(TConnectEntry.parse_reading_entry(r))
return data
"""
Given reading data and a time, finds the BG reading event which would have
been the current one at that time. e.g., it looks before the given time,
not after.
This is a heuristic for checking whether the BG component of a bolus was
manually entered or inferred based on the pump's CGM.
"""
def find_event_at(cgmEvents, find_time):
find_t = arrow.get(find_time)
events = list(map(lambda x: (arrow.get(x["time"]), x), cgmEvents))
events.sort()
closestReading = None
for t, r in events:
if t > find_t:
break
closestReading = r
return closestReading
"""
Given processed CGM data, adds reading entries to Nightscout.
"""
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_cgm_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_bg_entry(time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["dateString"])
logger.info("Last Nightscout CGM upload: %s" % last_upload_time)
add_count = 0
for event in cgmEvents:
created_at = event["time"]
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
logger.info("Skipping CGM event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
entry = NightscoutEntry.entry(
sgv=event["bg"],
created_at=created_at
)
add_count += 1
logger.info(" Processing cgm reading: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry, entity='entries')
return add_count
-59
View File
@@ -1,59 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
IOB_ACTIVITYTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given IOB data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_iob_events(iobdata):
iobEvents = []
for d in iobdata:
iobEvents.append(TConnectEntry.parse_iob_entry(d))
iobEvents.sort(key=lambda x: arrow.get(x["time"]))
return iobEvents
"""
Given processed IOB data, creates a single Nightscout activity definition to store IOB.
"""
def ns_write_iob_events(nightscout, iobEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_iob_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_activity(IOB_ACTIVITYTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout iob upload: %s" % last_upload_time)
if not iobEvents or len(iobEvents) == 0:
logger.info("No IOB events present from API: skipping")
return 0
event = iobEvents[-1]
if last_upload_time and arrow.get(event["time"]) <= last_upload_time:
logger.info(" Skipping already uploaded iob event: %s" % event)
return 0
entry = NightscoutEntry.iob(
iob=event["iob"],
created_at=event["time"]
)
logger.info(" Processing iob: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry, entity='activity')
# Delete the previous activity
if last_upload and '_id' in last_upload:
logger.info(" Deleting old iob entry: %s" % last_upload)
if not pretend:
nightscout.delete_entry('activity/{}'.format(last_upload['_id']))
return 1
-200
View File
@@ -1,200 +0,0 @@
from typing import List, Tuple
import logging
import json
import copy
import arrow
from ..api import TConnectApi
from ..domain.device_settings import Profile, DeviceSettings
from ..parser.nightscout import NightscoutEntry
from ..nightscout import NightscoutApi
from ..secret import PUMP_SERIAL_NUMBER, NIGHTSCOUT_PROFILE_UPLOAD_MODE
logger = logging.getLogger(__name__)
def _get_default_serial_number():
return PUMP_SERIAL_NUMBER
def _get_default_upload_mode():
return NIGHTSCOUT_PROFILE_UPLOAD_MODE
def get_pump_profiles(tconnect: TConnectApi, serial_number: int = None) -> Tuple[List[Profile], DeviceSettings]:
all_devices = tconnect.webui.my_devices()
if serial_number is None:
serial_number = _get_default_serial_number()
if str(serial_number) not in all_devices:
logger.warn("Could not find entry for provided pump serial number in t:connect device list: %s, received: %s", serial_number, all_devices)
return [], None
device = all_devices[str(serial_number)]
logger.info("Getting profile settings for %s", device)
device_profiles, device_settings = tconnect.webui.device_settings_from_guid(device.guid)
logger.debug("device_profiles: %s", device_profiles)
logger.debug("device_settings: %s", device_settings)
logger.info("Found pump profiles: %s", ["%s%s" % (profile.title, " (active)" if profile.active else "") for profile in device_profiles])
return device_profiles, device_settings
"""
Compare pump device and Nightscout profiles, and return a final dictionary of
Nightscout profile objects, with the pump profile settings overriding what is
currently in Nightscout.
ns_profile_obj is the output from NightscoutApi.current_profile() and should be the most
recent profile object in mongo.
Returns the new Nightscout profile and whether it was changed.
"""
def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSettings, ns_profile_obj: dict) -> Tuple[bool, dict]:
device = {profile.title: profile for profile in device_profiles}
ns = ns_profile_obj.get('store', {})
logger.info("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys())
new_ns_profile = copy.deepcopy(ns_profile_obj)
updated_ns_profile = False
missing_profiles_in_ns = set(device.keys()) - set(ns.keys())
for profile_name in missing_profiles_in_ns:
logger.info("Missing %s profile in Nightscout: %s", profile_name, device.get(profile_name))
pump_configured_profile = device[profile_name]
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
logger.info("Will add %s profile to Nightscout: %s", profile_name, ns_translated_profile)
new_ns_profile['store'][profile_name] = ns_translated_profile
updated_ns_profile = True
existent_profiles_in_ns = set(device.keys()) & set(ns.keys())
for profile_name in existent_profiles_in_ns:
logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name)
pump_configured_profile = device[profile_name]
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
ns_configured_profile = ns[profile_name]
logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile)
if nightscout_profiles_identical(ns_configured_profile, ns_translated_profile):
logger.info("Profile %s identical between pump and nightscout", profile_name)
continue
logger.info("Profile %s needs update in nightscout: %s", profile_name, ns_translated_profile)
new_ns_profile['store'][profile_name] = ns_translated_profile
updated_ns_profile = True
current_pump_profile = None
for profile in device_profiles:
if profile.active:
current_pump_profile = profile.title
if not current_pump_profile:
logger.error('No current pump profile, so skipping profile update: device: %s', device_profiles)
return False, ns_profile_obj
current_ns_profile = ns_profile_obj.get('defaultProfile')
if current_pump_profile != current_ns_profile:
logger.info("Current profile changed: pump: %s nightscout: %s", current_pump_profile, current_ns_profile)
new_ns_profile['defaultProfile'] = current_pump_profile
updated_ns_profile = True
if not updated_ns_profile:
logger.info("No Nightscout profile changes")
return False, ns_profile_obj
logger.info("New Nightscout profile object: %s", new_ns_profile)
return True, new_ns_profile
def nightscout_profiles_identical(configured: dict, translated: dict) -> bool:
if configured == translated:
logger.debug("direct dicts equal")
return True
if json.dumps(configured, sort_keys=True, indent=None) == json.dumps(translated, sort_keys=True, indent=None):
logger.debug("initial JSON dump identical")
return True
# convert all JSON values into strings
def map_nested_dicts_modify(ob, func):
for k, v in ob.items():
if isinstance(v, dict):
map_nested_dicts_modify(v, func)
elif isinstance(v, list):
map_nested_lists_modify(v, func)
else:
ob[k] = func(v)
def map_nested_lists_modify(ob, func):
for i in range(len(ob)):
v = ob[i]
if isinstance(v, dict):
map_nested_dicts_modify(v, func)
elif isinstance(v, list):
map_nested_lists_modify(v, func)
else:
ob[i] = func(v)
def to_numeric(x):
if type(x) in [int, float]:
return '%f' % x
try:
return '%f' % float(x)
except (ValueError, TypeError):
return x
convert_func = lambda x: to_numeric(x)
configured_str = json.loads(json.dumps(configured))
map_nested_dicts_modify(configured_str, convert_func)
translated_str = json.loads(json.dumps(translated))
map_nested_dicts_modify(translated_str, convert_func)
if json.dumps(configured_str, sort_keys=True, indent=None) == json.dumps(translated_str, sort_keys=True, indent=None):
logger.debug("map_nested_dicts JSON dump identical")
return True
logger.debug("profiles not identical")
return False
def setup_new_profile(ns_profile: dict) -> dict:
if '_id' in ns_profile:
del ns_profile['_id']
now = arrow.now().isoformat()
ns_profile['startDate'] = now
ns_profile['created_at'] = now
return ns_profile
def process_profiles(tconnect: TConnectApi, nightscout: NightscoutApi, pretend: bool = False, upload_mode: str = None) -> bool:
if not upload_mode:
upload_mode = _get_default_upload_mode()
logger.debug("Checking for differences between pump and nightscout profiles: %s mode", upload_mode)
ns_profile_obj = nightscout.current_profile()
pump_profiles, pump_settings = get_pump_profiles(tconnect)
diff, ns_profile_new = compare_profiles(pump_profiles, pump_settings, ns_profile_obj)
if not diff:
logger.info("Pump and Nightscout profiles up to date")
return False
if upload_mode == 'add':
profile_to_upload = setup_new_profile(ns_profile_new)
logger.info("Adding new Nightscout profiles object: %s", profile_to_upload)
if not pretend:
nightscout.upload_entry(profile_to_upload, entity='profile')
return True
elif upload_mode == 'replace':
logger.info("Replacing new Nightscout profiles object: %s", ns_profile_new)
if not pretend:
nightscout.put_entry(ns_profile_new, entity='profile')
return True
else:
raise RuntimeError('invalid upload_mode: %s' % upload_mode)
-218
View File
@@ -1,218 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
SITECHANGE_EVENTTYPE,
BASALSUSPENSION_EVENTTYPE,
EXERCISE_EVENTTYPE,
SLEEP_EVENTTYPE,
ACTIVITY_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
logger = logging.getLogger(__name__)
"""
Given a list of "activity events" from the CIQ therapy timeline endpoint,
process it into our internal events format.
These events contain a duration.
"""
def process_ciq_activity_events(data):
events = []
for event in data["events"]:
events.append(TConnectEntry.parse_ciq_activity_event(event))
return events
"""
Given a list of "basal suspension events" from the basalsuspension WS2 endpoint,
process it into our internal events format.
These events do NOT contain a duration.
"""
def process_basalsuspension_events(data):
events = []
for event in data['BasalSuspension']:
parsed = TConnectEntry.parse_basalsuspension_event(event)
if parsed:
events.append(parsed)
return events
"""
Given processed pump event data (of various types), write them to Nightscout
"""
def ns_write_pump_events(nightscout, pumpEvents, pretend=False, time_start=None, time_end=None):
count = 0
siteChangeEvents = []
emptyCartEvents = []
userSuspendedEvents = []
exerciseEvents = []
sleepEvents = []
activityEvents = []
for event in pumpEvents:
if event["event_type"] == TConnectEntry.BASALSUSPENSION_EVENTS["site-cart"]:
siteChangeEvents.append(event)
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["alarm"]:
emptyCartEvents.append(event)
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["manual"]:
userSuspendedEvents.append(event)
elif event["event_type"] == "Exercise":
exerciseEvents.append(event)
elif event["event_type"] == "Sleep":
sleepEvents.append(event)
elif event["event_type"] in TConnectEntry.ACTIVITY_EVENTS.values():
activityEvents.append(event)
logger.debug("siteChangeEvents: %s" % siteChangeEvents)
logger.debug("emptyCartEvents: %s" % emptyCartEvents)
logger.debug("userSuspendedEvents: %s" % userSuspendedEvents)
logger.debug("exerciseEvents: %s" % exerciseEvents)
logger.debug("sleepEvents: %s" % sleepEvents)
logger.debug("activityEvents: %s" % activityEvents)
count += ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_activity_events(nightscout, activityEvents, pretend=pretend, time_start=time_start, time_end=time_end)
return count
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
siteChangeEvents,
lambda event: NightscoutEntry.sitechange(
created_at=event["time"],
reason=event["event_type"]
),
SITECHANGE_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
emptyCartEvents,
lambda event: NightscoutEntry.basalsuspension(
created_at=event["time"],
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
userSuspendedEvents,
lambda event: NightscoutEntry.basalsuspension(
created_at=event["time"],
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
exerciseEvents,
lambda event: NightscoutEntry.activity(
created_at=event["time"],
reason=event["event_type"],
duration=event["duration_mins"],
event_type=EXERCISE_EVENTTYPE
),
EXERCISE_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
sleepEvents,
lambda event: NightscoutEntry.activity(
created_at=event["time"],
reason=event["event_type"],
duration=event["duration_mins"],
event_type=SLEEP_EVENTTYPE
),
SLEEP_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_activity_events(nightscout, activityEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
activityEvents,
lambda event: NightscoutEntry.activity(
created_at=event["time"],
reason=event["event_type"],
duration=event["duration_mins"]
),
ACTIVITY_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False, time_start=None, time_end=None):
if len(events) == 0:
logger.debug("No %s events to process" % eventType)
return 0
logger.debug("ns_write_pump_events: querying for last %s" % eventType)
last_upload = nightscout.last_uploaded_entry(eventType, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout %s: %s" % (eventType, last_upload_time))
if SKIP_NS_LAST_UPLOADED_CHECK:
logger.warning("Overriding last upload check")
last_upload = None
last_upload_time = None
add_count = 0
for event in events:
created_at = arrow.get(event["time"])
if last_upload_time and created_at <= last_upload_time:
skip = True
if "duration_mins" in event.keys() and "duration" in last_upload.keys():
if created_at == arrow.get(last_upload["created_at"]) and float(event["duration_mins"]) > float(last_upload["duration"]):
logger.info("Latest %s event needs updating: duration has increased from %s to %s: %s" % (eventType, last_upload["duration"], event["duration_mins"], event))
logger.info("Deleting previous %s: %s" % (eventType, last_upload))
nightscout.delete_entry('treatments/%s' % last_upload["_id"])
skip = False
if skip:
if pretend:
logger.info("Skipping %s pump event before last upload time: %s (time range: %s - %s)" % (eventType, event, time_start, time_end))
continue
entry = buildNsEventFunc(event)
add_count += 1
logger.info(" Processing %s: %s entry: %s" % (eventType, event, entry))
if not pretend:
nightscout.upload_entry(entry)
return add_count
+214 -97
View File
@@ -3,22 +3,36 @@ import logging
import datetime
import sys
import arrow
import requests
from ...api.common import ApiException, ApiLoginException
from ...features import DEFAULT_FEATURES
from ...api.tandemsource import naive_local_to_utc
from .process import ProcessTimeRange
from .choose_device import ChooseDevice
logger = logging.getLogger(__name__)
# Shortest wait after a failed poll. Doubles per consecutive failure, capped at
# AUTOUPDATE_DEFAULT_SLEEP_SECONDS (5 min by default): 30, 60, 120, 240, 300...
RETRY_INITIAL_SLEEP_SECONDS = 30
# Consecutive failures before the retry log line escalates from WARNING to
# ERROR, so a sustained outage doesn't hide quietly inside the backoff.
RETRY_ESCALATE_AFTER_FAILURES = 3
class TandemSourceAutoupdate:
"""Wrap access to secrets for easier testing."""
def __init__(self, secret):
self.secret = secret
self.autoupdate_invocations = 0
self.consecutive_failures = 0
self.first_failure_time = None
self.last_max_date_with_events = None
self.last_event_time = 0
self.last_attempt_time = 0
self.last_event_seqnum = None
self.last_successful_process_time_range = None
self.time_diffs_between_attempts = []
self.time_diffs_between_updates = []
@@ -27,134 +41,237 @@ class TandemSourceAutoupdate:
until stopped (ctrl+c), or a maximum of AUTOUPDATE_MAX_LOOP_INVOCATIONS times.
Stops if AUTOUPDATE_RESTART_ON_FAILURE is set and an error occurs.
"""
def process(self, tconnect, nightscout, time_start, time_end, pretend, features=None):
def process(self, tconnect, nightscout, pretend, features=None):
if features is None:
features = DEFAULT_FEATURES
# Read from android api, find exact interval to cut down on API calls
# Query for data, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
self.autoupdate_start = time.time()
while True:
logger.debug("autoupdate loop")
now = time.time()
try:
logger.debug("autoupdate loop")
now = time.time()
tconnectDevice = ChooseDevice(self.secret, tconnect).choose()
time_end = datetime.datetime.now()
time_start = time_end - datetime.timedelta(days=1)
event_seqnum = None
cur_max_date_with_events = arrow.get(tconnectDevice['maxDateWithEvents']).float_timestamp
if not self.last_max_date_with_events or cur_max_date_with_events > self.last_max_date_with_events:
logger.info('New reported tandemsource data. (cur_max_date: %s last_max_date: %s)' % (cur_max_date_with_events, self.last_max_date_with_events))
tconnectDevice = ChooseDevice(self.secret, tconnect).choose()
if pretend:
logger.info('Would update now if not in pretend mode')
event_seqnum = None
cur_max_date_with_events = arrow.get(naive_local_to_utc(tconnectDevice['maxDateOfEvents'])).float_timestamp
if not self.last_max_date_with_events or cur_max_date_with_events > self.last_max_date_with_events:
logger.info('New reported tandemsource data. (cur_max_date: %s last_max_date: %s)' % (cur_max_date_with_events, self.last_max_date_with_events))
if pretend:
logger.info('Would update now if not in pretend mode')
else:
added, event_seqnum = ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, self.secret, features=features).process(time_start, time_end)
logger.info('Added %d items from ProcessTimeRange' % added)
self.last_successful_process_time_range = now
# Track the time it took to find a new event between runs,
# but skip this calculation the first process cycle (since
# we don't know at what exact point the event index changed)
if self.last_event_seqnum:
# A negative diff means the pump's previously-reported maxDateWithEvents
# was in the future of wall-clock `now` — almost always a timezone /
# clock-skew issue (e.g. pump timestamps tagged as UTC but actually
# local time). Recording it would poison the rolling average and
# eventually produce a negative sleep_secs that crashes time.sleep().
diff = now - self.last_max_date_with_events
if diff >= 0:
self.time_diffs_between_updates.append(diff)
logger.debug('Updating tracking of time since last update: %s' % self.time_diffs_between_updates)
else:
logger.warning(
'Skipping negative time diff (%0.1fs) — likely pump clock skew or timezone mismatch' % diff
)
# Mark the last event index uploaded from the pump and timestamp
if event_seqnum:
self.last_event_seqnum = event_seqnum
self.last_event_time = now
self.last_max_date_with_events = cur_max_date_with_events
self.last_attempt_time = now
self.time_diffs_between_attempts = []
else:
added, event_seqnum = ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, self.secret, features=features).process(time_start, time_end)
logger.info('Added %d items from ProcessTimeRange' % added)
self.last_successful_process_time_range = now
logger.info('No new reported tandemsource data. cur_max_date: %s (%s) last_event_time: %s (%s)' % (
arrow.get(cur_max_date_with_events) if cur_max_date_with_events else None,
'%dm ago' % ((now - cur_max_date_with_events)//60) if cur_max_date_with_events else None,
arrow.get(self.last_event_time) if self.last_event_time else None,
'%dm ago' % ((now - self.last_event_time)//60) if self.last_event_time else None
))
# Track the time it took to find a new event between runs,
# but skip this calculation the first process cycle (since
# we don't know at what exact point the event index changed)
if self.last_event_seqnum:
self.time_diffs_between_updates.append(now - self.last_max_date_with_events)
logger.debug('Updating tracking of time since last update: %s' % self.time_diffs_between_updates)
# If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# then trigger an error and potentially restart.
# The most likely case here is that the pump isn't uploading right now.
if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES:
logger.error(AutoupdateNoEventIndexesDetectedError(
"%s: No new data event indexes have been detected for %d minutes. " % (datetime.datetime.now(), (now - self.last_event_time)//60) +
"New data might not be uploading."))
# Mark the last event index uploaded from the pump and timestamp
if event_seqnum:
self.last_event_seqnum = event_seqnum
self.last_event_time = now
self.last_max_date_with_events = cur_max_date_with_events
self.last_attempt_time = now
self.time_diffs_between_attempts = []
else:
logger.info('No new reported tandemsource data. cur_max_date: %s (%dm ago) last_event_time: %s (%dm ago)' % (
arrow.get(cur_max_date_with_events) if cur_max_date_with_events else None,
(now - cur_max_date_with_events)//60 if cur_max_date_with_events else None,
arrow.get(self.last_event_time) if self.last_event_time else None,
(now - self.last_event_time)//60 if self.last_event_time else None
))
# TODO: restarting doesn't really help anything here.
# Should we notify the user?
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
return 1
# If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# then trigger an error and potentially restart.
# The most likely case here is that the pump isn't uploading right now.
if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES:
logger.error(AutoupdateNoEventIndexesDetectedError(
"%s: No new data event indexes have been detected for %d minutes. " % (datetime.datetime.now(), (now - self.last_event_time)//60) +
"New data might not be uploading."))
# Similarly, if we HAVE seen pump event indexes update but have not successfully
# found any associated data updates from the tconnect API for AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# trigger an error and potentially restart. This could either be a tconnectsync problem,
# where we can see the indexes increasing, but it takes us until a period of no index
# update to reach our AUTOUPDATE_FAILURE_MINUTES threshold; or, a side effect of the
# above no indexes warning.
elif self.last_successful_process_time_range and (now - self.last_successful_process_time_range) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateNoNewDataDetectedError(
"%s: No new data has been detected via the API for %d minutes (last: %s). " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60, self.last_successful_process_time_range) +
"tconnectsync might not be functioning properly."))
# TODO: restarting doesn't really help anything here.
# Should we notify the user?
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
return 1
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("%s: Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE" % datetime.datetime.now())
return 1
# Similarly, if we HAVE seen pump event indexes update but have not successfully
# found any associated data updates from the tconnect API for AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# trigger an error and potentially restart. This could either be a tconnectsync problem,
# where we can see the indexes increasing, but it takes us until a period of no index
# update to reach our AUTOUPDATE_FAILURE_MINUTES threshold; or, a side effect of the
# above no indexes warning.
elif self.last_successful_process_time_range and (now - self.last_successful_process_time_range) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateNoNewDataDetectedError(
"%s: No new data has been detected via the API for %d minutes (last: %s). " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60, self.last_successful_process_time_range) +
"tconnectsync might not be functioning properly."))
# Track how long we've been retrying
if self.last_attempt_time:
self.time_diffs_between_attempts.append(now - self.last_attempt_time)
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("%s: Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE" % datetime.datetime.now())
return 1
self.last_attempt_time = now
# Track how long we've been retrying
if self.last_attempt_time:
self.time_diffs_between_attempts.append(now - self.last_attempt_time)
# If it's been 3 loops since the last time we found new data,
# then we're not in sync with the rate at which pump data is being
# uploaded, so
if len(self.time_diffs_between_attempts) >= 3:
# The pump hasn't sent us data that, based on previous cadence, we were expecting
logger.warning(AutoupdateNoIndexChangeWarning("Sleeping %d seconds after unexpected no index change based on previous cadence. (New data might be delayed.)" %
int(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)))
self.last_attempt_time = now
logger.debug("Last event time: %s, time diffs between attempts: %s" % (self.last_event_time, self.time_diffs_between_attempts))
# If it's been 3 loops since the last time we found new data,
# then we're not in sync with the rate at which pump data is being
# uploaded, so
if len(self.time_diffs_between_attempts) >= 3:
# The pump hasn't sent us data that, based on previous cadence, we were expecting
logger.warning(AutoupdateNoIndexChangeWarning("Sleeping %d seconds after unexpected no index change based on previous cadence. (New data might be delayed.)" %
int(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)))
# The API answered, so any prior outage is over.
self.consecutive_failures = 0
self.first_failure_time = None
logger.debug("Last event time: %s, time diffs between attempts: %s" % (self.last_event_time, self.time_diffs_between_attempts))
time.sleep(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)
time.sleep(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)
# Since we bail early, update the invocations count and potentially exit after sleeping.
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
# Since we bail early, update the invocations count and potentially exit after sleeping.
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
continue
continue
# The API answered, so any prior outage is over.
self.consecutive_failures = 0
self.first_failure_time = None
sleep_secs = self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS
sleep_secs = self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS
# Sleep for a rolling average of time between updates
if self.secret.AUTOUPDATE_USE_FIXED_SLEEP != 1:
logger.debug("Time diffs between updates: %s" % self.time_diffs_between_updates)
# Sleep for a rolling average of time between updates
if self.secret.AUTOUPDATE_USE_FIXED_SLEEP != 1:
logger.debug("Time diffs between updates: %s" % self.time_diffs_between_updates)
# Only keep the 10 latest time diffs
if len(self.time_diffs_between_updates) > 10:
self.time_diffs_between_updates = self.time_diffs_between_updates[1:]
# Only keep the 10 latest time diffs
if len(self.time_diffs_between_updates) > 10:
self.time_diffs_between_updates = self.time_diffs_between_updates[1:]
# If we have less than 3 data points,
if len(self.time_diffs_between_updates) > 2:
sleep_secs = sum(self.time_diffs_between_updates) / len(self.time_diffs_between_updates)
# If we have less than 3 data points,
if len(self.time_diffs_between_updates) > 2:
sleep_secs = sum(self.time_diffs_between_updates) / len(self.time_diffs_between_updates)
# At minimum, update every AUTOUPDATE_MAX_SLEEP_SECONDS regardless
# of how often we're seeing new data appear
if sleep_secs > self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS:
sleep_secs = self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS
# At minimum, update every AUTOUPDATE_MAX_SLEEP_SECONDS regardless
# of how often we're seeing new data appear
if sleep_secs > self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS:
sleep_secs = self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS
logger.info('Sleeping for %0.01f sec' % sleep_secs)
time.sleep(sleep_secs)
# Defensive: with the negative-diff filter above, sleep_secs should never be
# negative, but legacy state from before the fix or other unexpected inputs
# could still produce one. Clamp to AUTOUPDATE_DEFAULT_SLEEP_SECONDS so we
# don't crash with ValueError nor tight-loop the API.
if sleep_secs < 0:
logger.warning(
'Computed negative sleep duration (%0.1fs), falling back to default %ds' % (
sleep_secs, self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS
)
)
sleep_secs = self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
logger.info('Sleeping for %0.01f sec' % sleep_secs)
time.sleep(sleep_secs)
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
except ApiLoginException:
# A credentials failure is not transient: retrying it in-process
# would hammer the login endpoint with attempts that cannot
# succeed, which is the exact ban risk the backoff below exists
# to prevent. Stay fatal so the user notices and fixes config.
raise
except (
ApiException,
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
requests.exceptions.RetryError,
) as e:
# Two failure families, one response. Transient network errors
# (DNS, refused connections, timeouts, mid-stream disconnects,
# urllib3 retry-budget exhaustion) and API errors that get()
# does not retry itself (it only handles 401 and 500 — a 404,
# 502 or 503 propagates) both used to exit the process and let
# Docker restart the container.
#
# Restarting is the worst possible response: the credentials
# cache dies with the process, so every restart performs a full
# login. During the 2026-07-16 EU outage that meant a fresh
# login every ~2 minutes for hours from a single IP. Staying in
# the loop keeps the cache warm and the login endpoint untouched.
self.consecutive_failures += 1
if self.first_failure_time is None:
self.first_failure_time = time.time()
sleep_secs = self._retry_sleep_seconds()
log = logger.error if self.consecutive_failures >= RETRY_ESCALATE_AFTER_FAILURES else logger.warning
log(
'Error during autoupdate poll (%d consecutive): %s. Sleeping %ds before retry.' % (
self.consecutive_failures, e, sleep_secs
)
)
time.sleep(sleep_secs)
# Staying alive forever would make a real outage silent on
# deployments whose only alarm is the container dying. Once the
# API has been unreachable for AUTOUPDATE_API_FAILURE_MINUTES,
# exit so the platform can restart us and raise its own alert.
failing_for = time.time() - self.first_failure_time
if self.secret.AUTOUPDATE_API_FAILURE_MINUTES > 0 and failing_for >= 60 * self.secret.AUTOUPDATE_API_FAILURE_MINUTES:
logger.error(
AutoupdateFailureError(
'%s: API has been failing for %d minutes (%d consecutive attempts). '
'Exiting so the container platform restarts and reports it.' % (
datetime.datetime.now(), failing_for // 60, self.consecutive_failures
)
)
)
return 1
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
def _retry_sleep_seconds(self):
"""Exponential backoff for consecutive failed polls: 30, 60, 120, 240,
then held at AUTOUPDATE_DEFAULT_SLEEP_SECONDS (300s default). The cap
reuses the existing poll interval because a failing API should never be
contacted more often than a healthy one."""
backoff = RETRY_INITIAL_SLEEP_SECONDS * (2 ** (self.consecutive_failures - 1))
return min(backoff, self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS)
class AutoupdateError(RuntimeError):
@@ -177,4 +294,4 @@ class AutoupdateNoNewDataDetectedError(AutoupdateError):
pass
class AutoupdateNoIndexChangeWarning(AutoupdateWarning):
pass
pass
@@ -1,6 +1,8 @@
import arrow
import logging
from ...api.tandemsource import naive_local_to_utc
logger = logging.getLogger(__name__)
class ChooseDevice:
@@ -11,7 +13,10 @@ class ChooseDevice:
def choose(self):
tconnect = self.tconnect
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
pumpEventMetadata = tconnect.tandemsource.get_pumper().get('pumps', [])
if not pumpEventMetadata:
raise NoDevicesFound('No pumps are present on your Tandem Source account')
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
logger.info(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
@@ -24,19 +29,39 @@ class ChooseDevice:
tconnectDevice = serialNumberToPump[str(self.secret.PUMP_SERIAL_NUMBER)]
logger.info(f'Using pump with serial: {tconnectDevice["serialNumber"]} (tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
# Warn if pump is stale (no events in >3 days)
try:
max_event_date = arrow.get(naive_local_to_utc(tconnectDevice["maxDateOfEvents"]))
age_days = (arrow.utcnow() - max_event_date).days
if age_days > 3:
logger.warning(
f"The selected pump (serial {tconnectDevice['serialNumber']}) has no events in the last {age_days} days "
f"(last seen: {tconnectDevice['maxDateOfEvents']}). "
"You may have switched to a new pump. Consider removing or updating PUMP_SERIAL_NUMBER in your config."
)
except Exception as e:
logger.debug(f"Could not parse maxDateOfEvents to check for staleness: {e}")
logger.info(f'Using pump with serial: {tconnectDevice["serialNumber"]} (deviceId: {tconnectDevice["assignmentId"]}, last seen: {tconnectDevice["maxDateOfEvents"]})')
else:
# The BFF device list includes pumps that have never uploaded
# (maxDateOfEvents is None); skip those when picking the most
# recent one, and only fall back to one of them if nothing else.
maxDateSeen = None
for pump in pumpEventMetadata:
if not tconnectDevice:
if not pump.get('maxDateOfEvents'):
continue
pumpMaxDate = arrow.get(naive_local_to_utc(pump['maxDateOfEvents']))
if not tconnectDevice or pumpMaxDate > maxDateSeen:
maxDateSeen = pumpMaxDate
tconnectDevice = pump
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
else:
if arrow.get(pump['maxDateWithEvents']) > maxDateSeen:
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
tconnectDevice = pump
logger.info(f'Using most recent pump (serial: {tconnectDevice["serialNumber"]}, tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
# If no pump has any events yet, fall back to the first one.
if not tconnectDevice:
tconnectDevice = pumpEventMetadata[0]
logger.info(f'Using most recent pump (serial: {tconnectDevice["serialNumber"]}, deviceId: {tconnectDevice["assignmentId"]}, last seen: {tconnectDevice["maxDateOfEvents"]})')
return tconnectDevice
@@ -44,5 +69,10 @@ class ChooseDevice:
class InvalidSerialNumber(RuntimeError):
def __str__(self):
return "%s: %s" % (self.__class__.__name__, super().__str__())
class NoDevicesFound(RuntimeError):
def __str__(self):
return "%s: %s" % (self.__class__.__name__, super().__str__())
@@ -10,11 +10,11 @@ import logging
logger = logging.getLogger(__name__)
def fetch_oneshot(username, password, time_start=None, time_end=None):
tconnect = TConnectApi(username, password)
def fetch_oneshot(username, password, time_start=None, time_end=None, region=None):
tconnect = TConnectApi(username, password, region)
if not time_start and not time_end:
time_end = datetime.datetime.now()
time_start = time_end - datetime.timedelta(days=1)
tconnectDevice = ChooseDevice(secret, tconnect).choose()
return tconnect.tandemsource.pump_events(tconnectDevice['tconnectDeviceId'], time_start, time_end, fetch_all_event_types=secret.FETCH_ALL_EVENT_TYPES)
return tconnect.tandemsource.pump_events(tconnectDevice['assignmentId'], time_start, time_end, fetch_all_event_types=secret.FETCH_ALL_EVENT_TYPES)
+36 -12
View File
@@ -1,7 +1,22 @@
import logging
import collections
import arrow
from ...features import DEFAULT_FEATURES
from types import ModuleType
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
from .process_basal import ProcessBasal
@@ -20,16 +35,16 @@ from .update_profiles import UpdateProfiles
logger = logging.getLogger(__name__)
class ProcessTimeRange:
def __init__(self, tconnect, nightscout, tconnectDevice, pretend, secret, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnectDevice: "BffPump", pretend: bool, secret: ModuleType, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnectDevice['tconnectDeviceId']
self.max_date_with_events = tconnectDevice['maxDateWithEvents']
self.tconnect_device_id = tconnectDevice['assignmentId']
self.max_date_with_events = tconnectDevice.get('maxDateOfEvents')
self.pretend = pretend
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,
@@ -47,9 +62,11 @@ class ProcessTimeRange:
UpdateProfiles
]
def process(self, time_start, time_end):
logger.info(f"ProcessTimeRange time_start={time_start} time_end={time_end} tconnect_device_id={self.tconnect_device_id} features={self.features}")
events = self.tconnect.tandemsource.pump_events(self.tconnect_device_id, time_start, time_end, fetch_all_event_types=self.secret.FETCH_ALL_EVENT_TYPES)
def process(self, time_start: arrow.Arrow, time_end: arrow.Arrow) -> Tuple[int, Optional[int]]:
fetch_all_event_types = self.secret.FETCH_ALL_EVENT_TYPES or DEVICE_STATUS in self.features
logger.info(f"ProcessTimeRange time_start={time_start} time_end={time_end} tconnect_device_id={self.tconnect_device_id} features={self.features} fetch_all_event_types={fetch_all_event_types}")
events = self.tconnect.tandemsource.pump_events(self.tconnect_device_id, time_start, time_end, fetch_all_event_types=fetch_all_event_types)
events_first_time = None
events_last_time = None
@@ -79,7 +96,14 @@ class ProcessTimeRange:
c = self.event_classes[clazz](self.tconnect, self.nightscout, self.tconnect_device_id, self.pretend, self.features)
if c.enabled():
logger.info("%s is enabled from features %s" % (clazz, self.features))
ns_entries = c.process(events, events_first_time, events_last_time)
# Cap events_last_time at time_end to handle pump clock drift
# 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
# 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
@@ -87,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))
+24 -10
View File
@@ -1,6 +1,12 @@
import logging
import arrow
from typing import Iterable, List, Union, TYPE_CHECKING
from typing_extensions import assert_never
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
@@ -14,18 +20,20 @@ from ...parser.nightscout import (
logger = logging.getLogger(__name__)
AlarmOrMalfunction = Union[eventtypes.LidAlarmActivated, eventtypes.LidMalfunctionActivated]
class ProcessAlarm:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessAlarm: querying for last uploaded alarm")
last_upload = self.nightscout.last_uploaded_entry(ALARM_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -48,13 +56,16 @@ class ProcessAlarm:
return ns_entries
def skip_event(self, event):
return event.alarmid in (
def skip_event(self, event: AlarmOrMalfunction) -> bool:
if not isinstance(event, eventtypes.LidAlarmActivated):
return False
return event.alarmId in (
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm,
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm2
)
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -67,16 +78,19 @@ class ProcessAlarm:
return count
def alarm_to_nsentry(self, event):
if type(event) == eventtypes.LidAlarmActivated:
def alarm_to_nsentry(self, event: AlarmOrMalfunction) -> dict:
if isinstance(event, eventtypes.LidAlarmActivated):
alarmId = event.alarmId
reason = alarmId.name if alarmId is not None else "Alarm%s" % event.alarmIdRaw
return NightscoutEntry.alarm(
created_at = event.eventTimestamp.format(),
reason = "%s" % event.alarmid.name,
reason = reason,
pump_event_id = "%s" % event.seqNum
)
elif type(event) == eventtypes.LidMalfunctionActivated:
elif isinstance(event, eventtypes.LidMalfunctionActivated):
return NightscoutEntry.alarm(
created_at = event.eventTimestamp.format(),
reason = "Malfunction",
pump_event_id = "%s" % event.seqNum
)
assert_never(event)
@@ -1,3 +1,4 @@
import datetime
import logging
import arrow
@@ -14,20 +15,27 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
logger = logging.getLogger(__name__)
BasalEvent = Union[eventtypes.LidBasalRateChange, eventtypes.LidBasalDelivery]
class ProcessBasal:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.BASAL in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessBasal: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(BASAL_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -61,7 +69,7 @@ class ProcessBasal:
return ns_entries
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -74,17 +82,17 @@ class ProcessBasal:
return count
def basal_to_nsentry(self, start, duration, event):
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)
value = insulin_float_round(event.commandedBasalRate)
if IGNORE_ZERO_UNIT_BASAL and value < 0.01:
logger.info("Ignoring basal entry with %.2f unit basal because IGNORE_ZERO_UNIT_BASAL=true: %s" % (value, event))
return None
return NightscoutEntry.basal(
value = value,
duration_mins = duration.seconds / 60,
duration_mins = duration.total_seconds() / 60,
created_at = start.format(),
reason = ', '.join(bitmask_to_list(event.changetype)),
reason = ', '.join(bitmask_to_list(event.changeType)),
pump_event_id = "%s" % event.seqNum
)
if type(event) == eventtypes.LidBasalDelivery:
@@ -94,8 +102,10 @@ class ProcessBasal:
return None
return NightscoutEntry.basal(
value = value,
duration_mins = duration.seconds / 60,
duration_mins = duration.total_seconds() / 60,
created_at = start.format(),
reason = ', '.join(bitmask_to_list(event.commandedRateSource)),
pump_event_id = "%s" % event.seqNum
)
return None
@@ -1,6 +1,11 @@
import logging
import arrow
from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
@@ -15,17 +20,17 @@ from ...parser.nightscout import (
logger = logging.getLogger(__name__)
class ProcessBasalResume:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessBasalResume: querying for last uploaded resume-suspension")
last_upload = self.nightscout.last_uploaded_entry(BASALRESUME_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -40,12 +45,14 @@ 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
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -58,9 +65,11 @@ class ProcessBasalResume:
return count
def resume_to_nsentry(self, event):
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
@@ -1,6 +1,11 @@
import logging
import arrow
from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
@@ -15,17 +20,17 @@ from ...parser.nightscout import (
logger = logging.getLogger(__name__)
class ProcessBasalSuspension:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.PUMP_EVENTS in self.features or features.BASAL in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessBasalSuspension: querying for last uploaded suspension")
last_upload = self.nightscout.last_uploaded_entry(BASALSUSPENSION_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -40,12 +45,14 @@ 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
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -58,10 +65,12 @@ class ProcessBasalSuspension:
return count
def suspension_to_nsentry(self, event):
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)),
reason = ', '.join(bitmask_to_list(event.suspendReason)),
pump_event_id = "%s" % event.seqNum
)
return None
+51 -30
View File
@@ -13,20 +13,25 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
logger = logging.getLogger(__name__)
class ProcessBolus:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.BOLUS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessBolus: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(BOLUS_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -34,33 +39,36 @@ class ProcessBolus:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout bolus upload: %s" % last_upload_time)
# TODO EXTENDED BOLUSES
bolusCompletedEvents = []
bolusEventsForId = {}
# Correlate a bolus's request/completion messages by bolusid.
bolusEventsForId: dict = {}
for event in sorted(events, key=lambda x: x.eventTimestamp):
if event.bolusid not in bolusEventsForId.keys():
bolusEventsForId[event.bolusid] = {}
bolusEventsForId[event.bolusid][type(event)] = event
if type(event) == eventtypes.LidBolusCompleted:
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping bolusCompletedEvent not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
bolusCompletedEvents.append(event)
bolusCompletedEvents.sort(key=lambda e: e.eventTimestamp)
bolusEventsForId.setdefault(event.bolusId, {})[type(event)] = event
# Emit one Nightscout treatment per completion event, each at its own time:
# - LidBolusCompleted -> the standard / "now" bolus (carbs, bg, notes)
# - LidBolexCompleted -> the extended portion of a combo bolus (added
# separately, insulin only, so its later delivery is not dropped).
completions = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if type(event) not in (eventtypes.LidBolusCompleted, eventtypes.LidBolexCompleted):
continue
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping bolus completion not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
completions.append(event)
completions.sort(key=lambda e: e.eventTimestamp)
ns_entries = []
for bolusCompleted in bolusCompletedEvents:
m = bolusEventsForId[bolusCompleted.bolusid]
for event in completions:
if type(event) == eventtypes.LidBolexCompleted:
ns_entries.append(self.bolex_to_nsentry(event))
continue
m = bolusEventsForId[event.bolusId]
ns_entries.append(self.bolus_to_nsentry(
bolusCompleted,
event,
bolusRequested1 = m.get(eventtypes.LidBolusRequestedMsg1),
bolusRequested2 = m.get(eventtypes.LidBolusRequestedMsg2),
bolusRequested3 = m.get(eventtypes.LidBolusRequestedMsg3),
@@ -68,7 +76,7 @@ class ProcessBolus:
return ns_entries
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -81,12 +89,12 @@ class ProcessBolus:
return count
def bolus_to_nsentry(self, bolusCompleted, bolusRequested1, bolusRequested2, bolusRequested3):
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:
if bolusRequested2 and bolusRequested2.userOverride == eventtypes.LidBolusRequestedMsg2.UseroverrideEnum.Yes:
suffixes.append('(Override)')
if bolusRequested2 and bolusRequested2.declinedcorrection == eventtypes.LidBolusRequestedMsg2.DeclinedcorrectionEnum.Yes:
if bolusRequested2 and bolusRequested2.declinedCorrection == eventtypes.LidBolusRequestedMsg2.DeclinedcorrectionEnum.Yes:
suffixes.append('(Declined Correction)')
suffix = (' ' + (' '.join(suffixes))) if suffixes else ''
@@ -102,11 +110,24 @@ class ProcessBolus:
return NightscoutEntry.bolus(
bolus = insulin_float_round(bolusCompleted.insulindelivered),
carbs = bolusRequested1.carbamount if bolusRequested1 and bolusRequested1.carbamount>0 else None,
bolus = insulin_float_round(bolusCompleted.insulinDelivered),
carbs = bolusRequested1.carbAmount if bolusRequested1 and bolusRequested1.carbAmount>0 else None,
created_at = bolusCompleted.eventTimestamp.format(),
notes = notes + suffix,
bg = bolusRequested1.BG if bolusRequested1 and bolusRequested1.BG > 0 else None,
bg = bolusRequested1.bg if bolusRequested1 and bolusRequested1.bg > 0 else None,
pump_event_id = ",".join(seq_nums)
)
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.
return NightscoutEntry.bolus(
bolus = insulin_float_round(bolexCompleted.insulinDelivered),
carbs = None,
created_at = bolexCompleted.eventTimestamp.format(),
notes = "Extended Bolus",
bg = None,
pump_event_id = "%s" % bolexCompleted.seqNum
)
@@ -12,20 +12,25 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
logger = logging.getLogger(__name__)
class ProcessCartridge:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessCartridge: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(SITECHANGE_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -66,7 +71,7 @@ class ProcessCartridge:
return ns_entries
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -78,23 +83,29 @@ class ProcessCartridge:
return count
def cart_to_nsentry(self, cartFilled):
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(
created_at = cartFilled.eventTimestamp.format(),
reason = "Cartridge Filled" + (" (%du filled)" % round(cartFilled.v2Volume) if cartFilled.v2Volume else ""),
reason = "Cartridge Filled" + (" (%du filled)" % round(volume) if volume else ""),
pump_event_id = "%s" % cartFilled.seqNum
)
def cannula_to_nsentry(self, cannulaFilled):
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(
created_at = cannulaFilled.eventTimestamp.format(),
reason = "Cannula Filled" + (" (%du primed)" % round(cannulaFilled.primesize, 2) if cannulaFilled.primesize else ""),
reason = "Cannula Filled" + (" (%.1fu primed)" % primed if primed else ""),
pump_event_id = "%s" % cannulaFilled.seqNum
)
def tubing_to_nsentry(self, tubingFilled):
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(
created_at = tubingFilled.eventTimestamp.format(),
reason = "Tubing Filled" + (" (%du primed)" % round(tubingFilled.primesize) if tubingFilled.primesize else ""),
reason = "Tubing Filled" + (" (%du primed)" % round(primed) if primed else ""),
pump_event_id = "%s" % tubingFilled.seqNum
)
@@ -12,20 +12,32 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
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, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.CGM_ALERTS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessCGMAlert: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(CGM_ALERT_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -52,7 +64,7 @@ class ProcessCGMAlert:
return ns_entries
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -64,29 +76,33 @@ class ProcessCGMAlert:
return count
def alert_to_nsentry(self, alert):
if not alert.dalertid:
logger.info("ProcessCGMAlert: Skipping alert with unknown dalertid %d: %s" % (alert.dalertidRaw, alert))
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:
logger.info("ProcessCGMAlert: Skipping alert with unknown dalertid %d: %s" % (alert.dalertIdRaw, alert))
return None
if type(alert) == eventtypes.LidCgmAlertActivated:
return NightscoutEntry.cgm_alert(
created_at = alert.eventTimestamp.format(),
reason = ("CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "CGM Alert (Unknown)",
reason = ("CGM Alert (%s)" % alert.dalertId.name) if alert.dalertId else "CGM Alert (Unknown)",
pump_event_id = "%s" % alert.seqNum
)
elif type(alert) == eventtypes.LidCgmAlertActivatedDex:
if alert.dalertid == eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmOutOfRange:
logger.info("ProcessCGMAlert: Skipping alert with CgmOutOfRange dalertid %d: %s" % (alert.dalertidRaw, alert))
if alert.dalertId == eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmOutOfRange:
logger.info("ProcessCGMAlert: Skipping alert with CgmOutOfRange dalertid %d: %s" % (alert.dalertIdRaw, alert))
return None
return NightscoutEntry.cgm_alert(
created_at = alert.eventTimestamp.format(),
reason = ("Dexcom CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "Dexcom CGM Alert (Unknown)",
reason = ("Dexcom CGM Alert (%s)" % alert.dalertId.name) if alert.dalertId else "Dexcom CGM Alert (Unknown)",
pump_event_id = "%s" % alert.seqNum
)
elif type(alert) == eventtypes.LidCgmAlertActivatedFsl2:
return NightscoutEntry.cgm_alert(
created_at = alert.eventTimestamp.format(),
reason = ("Libre CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "Libre CGM Alert (Unknown)",
reason = ("Libre CGM Alert (%s)" % alert.dalertId.name) if alert.dalertId else "Libre CGM Alert (Unknown)",
pump_event_id = "%s" % alert.seqNum
)
return None
@@ -3,30 +3,86 @@ import arrow
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
from ...eventparser.utils import bitmask_to_list
from ... import secret
from ...eventparser.raw_event import TANDEM_EPOCH
from ...eventparser import events as eventtypes
from ...domain.tandemsource.event_class import EventClass
from ...parser.nightscout import (
CGM_START_EVENTTYPE,
NightscoutEntry
)
from ...parser.nightscout import NightscoutEntry
from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
# The four CGM-reading event types share the glucoseValueStatus /
# currentGlucoseDisplayValue fields determine_glucose_value() reads.
CgmReadingEvent = Union[
eventtypes.LidCgmDataG7,
eventtypes.LidCgmDataGxb,
eventtypes.LidCgmDataFsl2,
eventtypes.LidCgmDataFsl3,
]
logger = logging.getLogger(__name__)
# Mirrors the Tandem Source frontend (CgmBuilder.determineGlucoseValue): out-of-range
# and special readings are reported as sentinel values rather than the raw display value.
GLUCOSE_LIMIT_LOW = 40
GLUCOSE_LIMIT_HIGH = 400
GLUCOSE_VALUE_LOW = 39
GLUCOSE_VALUE_HIGH = 401
def _resolve_glucose_value(display_value, status, *, precise, high, low):
if status == high:
return GLUCOSE_VALUE_HIGH
if status == low:
return GLUCOSE_VALUE_LOW
if status == precise:
if display_value < GLUCOSE_LIMIT_LOW:
return GLUCOSE_VALUE_LOW
if display_value > GLUCOSE_LIMIT_HIGH:
return GLUCOSE_VALUE_HIGH
return display_value
# Each sensor is handled separately: the glucoseValueStatus enums are NOT assumed
# to be consistent across sensor types (e.g. G6 names its members differently), so
# every branch resolves against that sensor's own enum members.
def determine_glucose_value(event: CgmReadingEvent) -> int:
display_value = event.currentGlucoseDisplayValue
status = event.glucoseValueStatus
if isinstance(event, eventtypes.LidCgmDataG7):
g7 = eventtypes.LidCgmDataG7.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=g7.PreciseValue, high=g7.SpecialHigh, low=g7.SpecialLow)
if isinstance(event, eventtypes.LidCgmDataGxb):
gxb = eventtypes.LidCgmDataGxb.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=gxb.CurrentglucosedisplayvalueContainsTheGlucoseReading,
high=gxb.TheGlucoseReadingIsHigh, low=gxb.TheGlucoseReadingIsLow)
if isinstance(event, eventtypes.LidCgmDataFsl3):
fsl3 = eventtypes.LidCgmDataFsl3.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=fsl3.PreciseValue, high=fsl3.SpecialHigh, low=fsl3.SpecialLow)
if isinstance(event, eventtypes.LidCgmDataFsl2):
fsl2 = eventtypes.LidCgmDataFsl2.GlucosevaluestatusEnum
return _resolve_glucose_value(display_value, status,
precise=fsl2.PreciseValue, high=fsl2.SpecialHigh, low=fsl2.SpecialLow)
return display_value
class ProcessCGMReading:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES, timezone: Optional[str] = None) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
self.timezone = timezone or secret.TIMEZONE_NAME
def enabled(self):
def enabled(self) -> bool:
return features.CGM in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessCGMReading: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_bg_entry(time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -51,7 +107,7 @@ class ProcessCGMReading:
return ns_entries
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -63,14 +119,14 @@ class ProcessCGMReading:
return count
def timestamp_for(self, event):
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)
return arrow.get(TANDEM_EPOCH + event.egvTimeStamp, tzinfo='UTC').replace(tzinfo=self.timezone)
def to_nsentry(self, event):
def to_nsentry(self, event: CgmReadingEvent) -> dict:
return NightscoutEntry.entry(
sgv = event.currentglucosedisplayvalue,
sgv = determine_glucose_value(event),
created_at = self.timestamp_for(event).format(),
pump_event_id = "%s" % event.seqNum,
)
@@ -3,10 +3,9 @@ import arrow
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
from ...eventparser.utils import bitmask_to_list
from ...eventparser import events as eventtypes
from ...domain.tandemsource.event_class import EventClass
from ...nightscout import format_datetime
from ...parser.nightscout import (
CGM_START_EVENTTYPE,
CGM_JOIN_EVENTTYPE,
@@ -14,20 +13,40 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
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, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.PUMP_EVENTS in self.features or features.CGM_ALERTS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
last_upload = None
last_upload_time = None
for eventtype in [CGM_START_EVENTTYPE, CGM_JOIN_EVENTTYPE, CGM_STOP_EVENTTYPE]:
@@ -59,11 +78,13 @@ 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
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -75,22 +96,25 @@ class ProcessCGMStartJoinStop:
return count
def to_nsentry(self, event):
def to_nsentry(self, event: CgmSessionEvent) -> Optional[dict]:
if type(event) in EventClass._CGM_START:
return NightscoutEntry.cgm_start(
created_at = event.eventTimestamp.format(),
created_at = format_datetime(event.eventTimestamp),
reason = "CGM Session Started",
pump_event_id = "%s" % event.seqNum
)
elif type(event) in EventClass._CGM_JOIN:
return NightscoutEntry.cgm_join(
created_at = event.eventTimestamp.format(),
created_at = format_datetime(event.eventTimestamp),
reason = "CGM Session Joined",
pump_event_id = "%s" % event.seqNum
)
elif type(event) in EventClass._CGM_STOP:
return NightscoutEntry.cgm_stop(
created_at = event.eventTimestamp.format(),
created_at = format_datetime(event.eventTimestamp),
reason = "CGM Session Stopped",
pump_event_id = "%s" % event.seqNum
)
return None
@@ -13,20 +13,25 @@ from ...parser.nightscout import (
NightscoutEntry
)
from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
logger = logging.getLogger(__name__)
class ProcessDeviceStatus:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.DEVICE_STATUS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessDeviceStatus: querying for last uploaded devicestatus")
last_upload = self.nightscout.last_uploaded_devicestatus(time_start=time_start, time_end=time_end)
last_upload_time = None
@@ -50,21 +55,36 @@ class ProcessDeviceStatus:
logger.info("ProcessDeviceStatus: No last_daily_basal_event found for add (time range: %s - %s)" % (time_start, time_end))
return []
logger.info("ProcessDeviceStatus: last_daily_basal_event=%s" % (last_daily_basal_event))
ns_entries = []
ns_entries.append(self.daily_basal_to_nsentry(last_daily_basal_event))
return ns_entries
entry = self.daily_basal_to_nsentry(last_daily_basal_event)
if entry is None:
return []
return [entry]
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:
logger.warning("ProcessDeviceStatus: skipping daily basal event missing battery data: %s" % event)
return None
def daily_basal_to_nsentry(self, event):
return NightscoutEntry.devicestatus(
created_at=event.eventTimestamp.format(),
batteryVoltage=(float(event.batterylipomillivolts or 0)/1000),
batteryVoltage=(float(event.batteryLipoMilliVolts or 0)/1000),
batteryPercent=int(100*event.batteryChargePercent),
pump_event_id = "%s" % event.seqNum
)
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -1,6 +1,11 @@
import logging
import arrow
from typing import Iterable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
@@ -18,17 +23,17 @@ NOT_ENDED = "Not Ended"
logger = logging.getLogger(__name__)
class ProcessUserMode:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
def process(self, events: Iterable, time_start: arrow.Arrow, time_end: arrow.Arrow) -> List[dict]:
logger.debug("ProcessUserMode: querying for last uploaded exercise entry")
exercise_last_upload = self.nightscout.last_uploaded_entry(EXERCISE_EVENTTYPE, time_start=time_start, time_end=time_end)
exercise_last_upload_time = None
@@ -84,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:
@@ -96,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:
@@ -112,14 +117,18 @@ 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
def write(self, ns_entries):
def write(self, ns_entries: List[dict]) -> int:
count = 0
for entry in ns_entries:
if self.pretend:
@@ -131,27 +140,27 @@ class ProcessUserMode:
return count
def is_start_sleep(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep
def is_stop_sleep(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep or \
event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
def is_start_exercise(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise
def is_stop_exercise(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise or \
event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
def is_start_sleep(self, event: eventtypes.LidAaUserModeChange) -> bool:
return event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep
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: eventtypes.LidAaUserModeChange) -> bool:
return event.requestedAction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise
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, stop=None, time_end=None):
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:
if start.sleepStartedByGui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
reason = "Sleep (Manual)"
elif start.activesleepschedule:
elif start.activeSleepSchedule:
reason = "Sleep (Scheduled)"
duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60
duration_mins = (stop.eventTimestamp - start.eventTimestamp).total_seconds() / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason,
@@ -161,12 +170,12 @@ class ProcessUserMode:
)
elif start:
reason = None
if start.sleepstartedbygui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
if start.sleepStartedByGui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
reason = "Sleep (Manual)"
elif start.activesleepscheduleRaw:
elif start.activeSleepScheduleRaw:
reason = "Sleep (Scheduled)"
duration_mins = (time_end - start.eventTimestamp).seconds / 60
duration_mins = (time_end - start.eventTimestamp).total_seconds() / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason + " - " + NOT_ENDED if reason else NOT_ENDED,
@@ -175,17 +184,19 @@ class ProcessUserMode:
pump_event_id = "%s" % start.seqNum
)
return None
def exercise_to_nsentry(self, start, stop=None, time_end=None):
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:
if start.exerciseChoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
reason = "Exercise (Timed)"
if stop.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
if stop.exerciseStoppedByTimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
reason += " (Stopped by timer)"
duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60
duration_mins = (stop.eventTimestamp - start.eventTimestamp).total_seconds() / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason,
@@ -195,10 +206,10 @@ class ProcessUserMode:
)
elif start:
reason = "Exercise"
if start.exercisechoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
if start.exerciseChoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
reason = "Exercise (Timed)"
duration_mins = (time_end - start.eventTimestamp).seconds / 60
duration_mins = (time_end - start.eventTimestamp).total_seconds() / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason + " - " + NOT_ENDED,
@@ -207,14 +218,16 @@ class ProcessUserMode:
pump_event_id = "%s" % start.seqNum
)
def process_unended_sleep_stop(self, event, sleep_last_upload):
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")
else:
self.nightscout.delete_entry('treatments/%s' % sleep_last_upload["_id"])
duration_mins = (event.eventTimestamp - arrow.get(sleep_last_upload["created_at"])).seconds / 60
duration_mins = (event.eventTimestamp - arrow.get(sleep_last_upload["created_at"])).total_seconds() / 60
return NightscoutEntry.activity(
created_at=sleep_last_upload["created_at"],
reason=sleep_last_upload["reason"].replace(" - %s" % NOT_ENDED, ""),
@@ -223,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, exercise_last_upload):
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")
@@ -231,10 +244,10 @@ class ProcessUserMode:
self.nightscout.delete_entry('treatments/%s' % exercise_last_upload["_id"])
reason = exercise_last_upload["reason"].replace(" - %s" % NOT_ENDED, "")
if event.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
if event.exerciseStoppedByTimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
reason += " (Stopped by timer)"
duration_mins = (event.eventTimestamp - arrow.get(exercise_last_upload["created_at"])).seconds / 60
duration_mins = (event.eventTimestamp - arrow.get(exercise_last_upload["created_at"])).total_seconds() / 60
return NightscoutEntry.activity(
created_at=exercise_last_upload["created_at"],
reason=reason,
@@ -2,7 +2,10 @@ import logging
import arrow
import copy
import json
from typing import Tuple
from typing import Any, Callable, List, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from ...api import TConnectApi
from ...nightscout import NightscoutApi
from ...features import DEFAULT_FEATURES
from ... import features
@@ -14,34 +17,35 @@ from ...secret import NIGHTSCOUT_PROFILE_UPLOAD_MODE
logger = logging.getLogger(__name__)
def _get_default_upload_mode():
def _get_default_upload_mode() -> str:
return NIGHTSCOUT_PROFILE_UPLOAD_MODE
class UpdateProfiles:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect: "TConnectApi", nightscout: "NightscoutApi", tconnect_device_id: str, pretend: bool, features: List[str] = DEFAULT_FEATURES) -> None:
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
def enabled(self) -> bool:
return features.PROFILES in self.features
def update(self, pretend):
def update(self, pretend: bool) -> bool:
upload_mode = _get_default_upload_mode()
logger.debug("UpdateProfiles: getting Tandem Source profile data")
all_metadata = self.tconnect.tandemsource.pump_event_metadata()
all_metadata = self.tconnect.tandemsource.get_pumper().get('pumps', [])
pump_meta = None
for m in all_metadata:
if m['tconnectDeviceId'] == self.tconnect_device_id:
if m['assignmentId'] == self.tconnect_device_id:
pump_meta = m
if not pump_meta:
return False
raw_settings = pump_meta.get("lastUpload", {}).get("settings")
s = pump_meta.get("settings")
raw_settings = s["details"] if s else None
if not raw_settings:
return False
@@ -160,7 +164,7 @@ class UpdateProfiles:
return True
# convert all JSON values into strings
def map_nested_dicts_modify(ob, func):
def map_nested_dicts_modify(ob: dict, func: Callable) -> None:
for k, v in ob.items():
if isinstance(v, dict):
map_nested_dicts_modify(v, func)
@@ -169,7 +173,7 @@ class UpdateProfiles:
else:
ob[k] = func(v)
def map_nested_lists_modify(ob, func):
def map_nested_lists_modify(ob: list, func: Callable) -> None:
for i in range(len(ob)):
v = ob[i]
if isinstance(v, dict):
@@ -179,7 +183,7 @@ class UpdateProfiles:
else:
ob[i] = func(v)
def to_numeric(x):
def to_numeric(x: Any) -> Any:
if type(x) in [int, float]:
return '%f' % x
try:
+2 -2
View File
@@ -16,5 +16,5 @@ Returns a TConnectApi object with default secret parameters.
"""
def get_api():
from ..api import TConnectApi
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION)
+1 -53
View File
@@ -1,54 +1,5 @@
import tconnectsync.api
import requests
class ControlIQApi(tconnectsync.api.controliq.ControlIQApi):
def __init__(self):
self.BASE_URL = 'invalid://'
self.LOGIN_URL = 'invalid://'
self.session = requests.Session() # mocked in tests
def login(self, email, password):
raise NotImplementedError
def needs_relogin(self):
return False
def _get(self, endpoint, query):
raise NotImplementedError
class WS2Api(tconnectsync.api.ws2.WS2Api):
def __init__(self):
self.BASE_URL = 'invalid://'
self.SLEEP_SECONDS_INCREMENT = 0.01
def get(self, endpoint):
raise NotImplementedError
def get_jsonp(self, endpoint):
raise NotImplementedError
class AndroidApi(tconnectsync.api.android.AndroidApi):
def __init__(self):
self.BASE_URL = 'invalid://'
def login(self, email, password):
raise NotImplementedError
def needs_relogin(self):
return False
def _get(self, endpoint, query={}, **kwargs):
raise NotImplementedError
class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
def __init__(self, controliq):
self.controliq = controliq
def my_devices(self):
raise NotImplementedError
def device_settings(self, pump_guid):
raise NotImplementedError
class TConnectApi(tconnectsync.api.TConnectApi):
def __init__(self, email=None, password=None):
@@ -57,7 +8,4 @@ class TConnectApi(tconnectsync.api.TConnectApi):
else:
self.with_credentials = False
_ciq = ControlIQApi()
_ws2 = WS2Api()
_android = AndroidApi()
_webui = WebUIScraper(_ciq)
_tandemsource = None
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env python3
import unittest
import itertools
import datetime
from .fake import AndroidApi
from tconnectsync.api.common import ApiException
class TestAndroidApi(unittest.TestCase):
def fake_get_with_http_code(self, http_code, expected_endpoint, num_times):
tries = 0
def fake_get(endpoint, query):
nonlocal http_code, expected_endpoint, num_times, tries
if endpoint.endswith(expected_endpoint):
if tries < num_times:
tries += 1
raise ApiException(http_code, "fake HTTP %d" % http_code)
return {"faked_json": True}
raise NotImplementedError
return fake_get
def test_last_event_uploaded_works_after_single_http_500(self):
android = AndroidApi()
android._get = self.fake_get_with_http_code(500, "cloud/upload/getlasteventuploaded?sn=1111111", 1)
self.assertEqual(
android.last_event_uploaded(1111111),
{
"faked_json": True
})
def test_last_event_uploaded_fails_after_two_http_500s(self):
android = AndroidApi()
android._get = self.fake_get_with_http_code(500, "cloud/upload/getlasteventuploaded?sn=1111111", 2)
self.assertRaises(ApiException, android.last_event_uploaded, 1111111)
def test_last_event_uploaded_triggers_relogin_after_single_http_401(self):
android = AndroidApi()
android._email = 'email'
android._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
android.login = stub_login
android._get = self.fake_get_with_http_code(401, "cloud/upload/getlasteventuploaded?sn=1111111", 1)
self.assertEqual(
android.last_event_uploaded(1111111),
{
"faked_json": True
})
self.assertListEqual(hit_login, [
('email', 'password')
])
def test_last_event_uploaded_fails_after_two_http_401s(self):
android = AndroidApi()
android._email = 'email'
android._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
android.login = stub_login
android._get = self.fake_get_with_http_code(401, "cloud/upload/getlasteventuploaded?sn=1111111", 2)
self.assertRaises(ApiException, android.last_event_uploaded, 1111111)
self.assertListEqual(hit_login, [
('email', 'password')
])
if __name__ == '__main__':
unittest.main()
-293
View File
@@ -1,293 +0,0 @@
#!/usr/bin/env python3
import unittest
import itertools
import datetime
import json
import requests_mock
from bs4 import BeautifulSoup
from .fake import ControlIQApi
from tconnectsync.api.controliq import ControlIQApi as RealControlIQApi
from tconnectsync.api.common import ApiException, ApiLoginException, base_headers
class TestControlIQApi(unittest.TestCase):
LOGIN_HTML = """
<html>
<body>
<form method="post" action="./login.aspx?ReturnUrl=%2f" onsubmit="javascript:return WebForm_OnSubmit();" id="form1">
<div class="aspNetHidden">
<input type="hidden" name="__LASTFOCUS" id="__LASTFOCUS" value="" />
<input type="hidden" name="__EVENTTARGET" id="__EVENTTARGET" value="" />
<input type="hidden" name="__EVENTARGUMENT" id="__EVENTARGUMENT" value="" />
<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="AAAAA" />
</div>
<div class="aspNetHidden">
<input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="BBBBB" />
<input type="hidden" name="__EVENTVALIDATION" id="__EVENTVALIDATION" value="CCCCC" />
</div>
</form>
</body>
</html>
"""
LOGIN_POST_DATA = {
"__LASTFOCUS": "",
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
"__EVENTARGUMENT": "",
"__VIEWSTATE": "AAAAA",
"__VIEWSTATEGENERATOR": "BBBBB",
"__EVENTVALIDATION": "CCCCC",
"ctl00$ContentBody$LoginControl$txtLoginEmailAddress": "email@email.com",
"txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % ("email@email.com", "email@email.com", "email@email.com"),
"ctl00$ContentBody$LoginControl$txtLoginPassword": "password",
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % ("password", "password", "password")
}
def test_build_login_data(self):
ciq = ControlIQApi()
soup = BeautifulSoup(self.LOGIN_HTML, features='lxml')
self.assertDictEqual(
ciq._build_login_data('email@email.com', 'password', soup),
self.LOGIN_POST_DATA)
def test_login_successful(self):
ciq = ControlIQApi()
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
with requests_mock.Mocker() as m:
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers=base_headers(),
text=self.LOGIN_HTML)
def post_callback(request, context):
context.status_code = 302
context.headers['Location'] = '/newlocation'
context.cookies['UserGUID'] = 'user_guid'
context.cookies['accessToken'] = 'access_tok'
context.cookies['accessTokenExpiresAt'] = '2021-05-04T11:18:08.381Z'
return ''
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
m.post('https://tconnect.tandemdiabetes.com/newlocation',
cookies={'cookie': 'value'},
headers=base_headers(),
status_code=200)
self.assertTrue(ciq.login('email@email.com', 'password'))
self.assertEqual(ciq.userGuid, 'user_guid')
self.assertEqual(ciq.accessToken, 'access_tok')
self.assertEqual(ciq.accessTokenExpiresAt, '2021-05-04T11:18:08.381Z')
def test_login_invalid_credentials(self):
ciq = ControlIQApi()
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
with requests_mock.Mocker() as m:
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers=base_headers(),
text=self.LOGIN_HTML)
def post_callback(request, context):
context.status_code = 200
return '<html><body>...</body></html>'
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: Check your login credentials.', ciq.login, 'email@email.com', 'password')
self.assertIsNone(ciq.userGuid)
self.assertIsNone(ciq.accessToken)
self.assertIsNone(ciq.accessTokenExpiresAt)
def test_login_invalid_credentials_parsed_message(self):
ciq = ControlIQApi()
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
with requests_mock.Mocker() as m:
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers=base_headers(),
text=self.LOGIN_HTML)
def post_callback(request, context):
context.status_code = 200
return '<html><body><div class="notice_error" id="literalMessage" style="">The email address or password you entered is invalid. Please re-enter and try again.</div></body></html>'
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: The email address or password you entered is invalid. Please re-enter and try again.', ciq.login, 'email@email.com', 'password')
self.assertIsNone(ciq.userGuid)
self.assertIsNone(ciq.accessToken)
self.assertIsNone(ciq.accessTokenExpiresAt)
def test_login_unexpected_http_code(self):
ciq = ControlIQApi()
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
with requests_mock.Mocker() as m:
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers=base_headers(),
text=self.LOGIN_HTML)
def post_callback(request, context):
context.status_code = 500
return '<html><body>...</body></html>'
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
text=post_callback)
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect \(HTTP 500\)', ciq.login, 'email@email.com', 'password')
self.assertIsNone(ciq.userGuid)
self.assertIsNone(ciq.accessToken)
self.assertIsNone(ciq.accessTokenExpiresAt)
def fake_get_with_http_code(self, http_code, expected_endpoint, num_times):
tries = 0
def fake_get(endpoint, query):
nonlocal http_code, expected_endpoint, num_times, tries
if endpoint.split("?")[0].endswith(expected_endpoint):
if tries < num_times:
tries += 1
raise ApiException(http_code, "fake HTTP %d" % http_code)
return {"faked_json": True}
raise NotImplementedError
return fake_get
def test_therapy_timeline_works_after_single_http_500(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._get = self.fake_get_with_http_code(500, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 1)
self.assertEqual(
ciq.therapy_timeline('2021-04-01', '2021-04-02'),
{
"faked_json": True
})
def test_therapy_timeline_fails_after_two_http_500s(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._get = self.fake_get_with_http_code(500, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 2)
self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02')
def test_therapy_timeline_triggers_relogin_after_single_http_401(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._email = 'email'
ciq._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
ciq.login = stub_login
ciq._get = self.fake_get_with_http_code(401, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 1)
self.assertEqual(
ciq.therapy_timeline('2021-04-01', '2021-04-02'),
{
"faked_json": True
})
self.assertListEqual(hit_login, [
('email', 'password')
])
def test_therapy_timeline_fails_after_two_http_401s(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
ciq._email = 'email'
ciq._password = 'password'
hit_login = []
def stub_login(email, password):
nonlocal hit_login
hit_login.append((email, password))
ciq.login = stub_login
ciq._get = self.fake_get_with_http_code(401, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 2)
self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02')
self.assertListEqual(hit_login, [
('email', 'password')
])
def test_therapy_timeline_parses_date(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def fake_get(raw_endpoint, ignored_query):
endpoint, query = raw_endpoint.split("?")
self.assertTrue(endpoint.endswith("therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
self.assertEqual(query, "startDate=04-01-2021&endDate=04-02-2021")
return {"faked_json": True}
ciq._get = fake_get
self.assertEqual(
ciq.therapy_timeline(datetime.date(2021, 4, 1), datetime.date(2021, 4, 2)),
{
"faked_json": True
})
def test_dashboard_summary_parses_date(self):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def fake_get(raw_endpoint, ignored_query):
endpoint, query = raw_endpoint.split("?")
self.assertTrue(endpoint.endswith("summary/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
self.assertEqual(query, "startDate=04-01-2021&endDate=04-02-2021")
return {"faked_json": True}
ciq._get = fake_get
self.assertEqual(
ciq.dashboard_summary(datetime.date(2021, 4, 1), datetime.date(2021, 4, 2)),
{
"faked_json": True
})
if __name__ == '__main__':
unittest.main()
+508
View File
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
import arrow
import datetime
import unittest
import urllib.parse
from unittest.mock import patch
from tconnectsync.api.tandemsource import TandemSourceApi, naive_local_to_utc
from tconnectsync.api.common import ApiException
from tconnectsync.eventparser import events as eventtypes
# Representative GET api/reports/bff/pumper/{pumperId} response, mirroring the
# structure of a real captured account response: one active pump with settings
# and one never-uploaded pump (null date/settings fields).
BFF_PUMPER = {
"firstName": "Test",
"lastName": "User",
"name": "Test User",
"dateOfBirth": "1990-01-01",
"lowGlucoseThreshold": 70,
"highGlucoseThreshold": 180,
"country": "US",
"pumps": [
{
"algorithm": "Control-IQ",
"availableDataRange": {"start": "2021-05-06T12:31:19", "end": "2022-02-16T22:45:58"},
"assignmentId": "1b493210-9336-4901-a329-a352775738c5",
"lastUploadDate": "2022-09-20T05:50:12Z",
"maxDateOfEvents": "2022-02-16T22:45:58",
"modelNumber": "1000354",
"modelName": "t:slim X2™ Insulin Pump",
"partNumber": "1011979",
"serialNumber": "90556643",
"softwareVersion": "7.8.0.0",
"lastUploadClientType": "mobile_tconnect",
"settings": {
"id": "b7f931c8-63cd-44c9-86aa-56826f9057e5",
"deviceAssignmentId": "1b493210-9336-4901-a329-a352775738c5",
"uploadedTimeStamp": "2022-09-10T21:07:43.497",
"settingsHash": "29EDDA8E7A72C1AD060271268CC7AE81FAD54B9D",
"uploadId": "5da6a0ca-86c3-440f-9462-fa53168dcb9d",
"details": {"profiles": {"numberOfProfiles": 1}},
},
},
{
"algorithm": "Basal-IQ",
"availableDataRange": {"start": None, "end": None},
"assignmentId": "f6631fff-f403-4ce4-9362-83eff9e2850e",
"glucoseUnit": None,
"lastUploadDate": None,
"maxDateOfEvents": None,
"modelNumber": "1000096",
"modelName": "t:slim X2™ Insulin Pump",
"partNumber": "1003314",
"serialNumber": "514387",
"softwareVersion": "6.0.3.0",
"lastUploadClientType": None,
"settings": None,
},
],
}
class TestNaiveLocalToUtc(unittest.TestCase):
"""The module-level naive_local_to_utc() helper is the sole survivor of the
removed PumpMetadata adapter. It normalizes a BFF pump-local naive
wall-clock timestamp to true UTC, and is now called at the specific date
call sites that compare against real UTC."""
maxDiff = None
def test_naive_dates_normalized_to_utc(self):
# America/New_York is set in tests/conftest.py. Feb -> EST (UTC-5),
# May -> EDT (UTC-4). These are the raw BffPump maxDateOfEvents /
# availableDataRange.start values that call sites now normalize.
self.assertEqual(
naive_local_to_utc(BFF_PUMPER["pumps"][0]["maxDateOfEvents"]),
"2022-02-17T03:45:58+00:00",
)
self.assertEqual(
naive_local_to_utc(BFF_PUMPER["pumps"][0]["availableDataRange"]["start"]),
"2021-05-06T16:31:19+00:00",
)
def test_naive_local_to_utc_none_passthrough(self):
self.assertIsNone(naive_local_to_utc(None))
def test_naive_local_to_utc_idempotent_no_double_shift(self):
# A value that already carries a tz must not be shifted again. Feed the
# already-UTC output back in and confirm it is unchanged.
first = naive_local_to_utc("2022-02-16T22:45:58")
self.assertEqual(first, "2022-02-17T03:45:58+00:00")
self.assertEqual(naive_local_to_utc(first), first)
# A 'Z'-suffixed (true UTC) value is passed through as UTC unchanged.
self.assertEqual(
naive_local_to_utc("2022-09-20T05:50:12Z"),
"2022-09-20T05:50:12+00:00",
)
class TestDefaultEventIds(unittest.TestCase):
def test_default_event_ids(self):
ids = TandemSourceApi.DEFAULT_EVENT_IDS
self.assertEqual(len(ids), 55)
self.assertEqual(len(set(ids)), 55, "DEFAULT_EVENT_IDS contains duplicates")
# FSL3 ids added for the BFF pump-logs endpoint
self.assertTrue({477, 480, 486}.issubset(set(ids)))
# Trimmed real-shape pump-logs response (1 event + 1 clockChange).
PUMP_LOGS = {
"events": [
{
"deviceAssignmentId": "1b493210-9336-4901-a329-a352775738c5",
"eventCode": 16,
"sequenceGroup": 1,
"sequenceNumber": 100123,
"pumpDateTime": "2024-01-10T08:15:30",
"eventProperties": {"iob": 1.25, "bg": 112},
"estimatedDateTime": "2024-01-10T08:15:30Z",
}
],
"clockChanges": [
{
"deviceAssignmentId": "1b493210-9336-4901-a329-a352775738c5",
"eventCode": 13,
"sequenceGroup": 0,
"sequenceNumber": 5,
"pumpDateTime": "2024-01-01T00:00:00",
"eventProperties": {"timePrior": 1, "timeAfter": 2, "rawRtcTime": 3},
"estimatedDateTime": "2024-01-01T00:00:00Z",
}
],
}
class TestGetPumpLogs(unittest.TestCase):
maxDiff = None
def _api(self):
api = TandemSourceApi.__new__(TandemSourceApi)
api.pumperId = "PUMPER123"
return api
def _endpoint(self, mock_get):
mock_get.assert_called_once()
# (endpoint, query_dict) positional args
self.assertEqual(mock_get.call_args.args[1], {})
return mock_get.call_args.args[0]
def _qs(self, endpoint, keep_blank=False):
parsed = urllib.parse.urlparse(endpoint)
return parsed.path, urllib.parse.parse_qs(parsed.query, keep_blank_values=keep_blank)
def test_endpoint_path_and_params(self):
api = self._api()
with patch.object(TandemSourceApi, "get", return_value=PUMP_LOGS) as mock_get:
api.get_pump_logs("dev-uuid", min_date="2024-01-01", max_date="2024-01-15")
endpoint = self._endpoint(mock_get)
path, qs = self._qs(endpoint)
self.assertEqual(path, "api/reports/bff/pump-logs/dev-uuid")
self.assertEqual(qs["pumperId"], ["PUMPER123"])
self.assertEqual(qs["startDate"], ["2024-01-01T00:00:00Z"])
self.assertEqual(qs["endDate"], ["2024-01-15T23:59:59Z"])
def test_default_event_ids_comma_joined(self):
api = self._api()
with patch.object(TandemSourceApi, "get", return_value=PUMP_LOGS) as mock_get:
api.get_pump_logs("dev", min_date="2024-01-01", max_date="2024-01-02")
_, qs = self._qs(self._endpoint(mock_get))
self.assertEqual(qs["eventIds"][0].split(","),
[str(i) for i in TandemSourceApi.DEFAULT_EVENT_IDS])
def test_custom_event_ids_comma_joined(self):
api = self._api()
with patch.object(TandemSourceApi, "get", return_value=PUMP_LOGS) as mock_get:
api.get_pump_logs("dev", "2024-01-01", "2024-01-02", event_ids_filter=[16, 5, 28])
_, qs = self._qs(self._endpoint(mock_get))
self.assertEqual(qs["eventIds"], ["16,5,28"])
def test_none_event_ids_empty(self):
api = self._api()
with patch.object(TandemSourceApi, "get", return_value=PUMP_LOGS) as mock_get:
api.get_pump_logs("dev", "2024-01-01", "2024-01-02", event_ids_filter=None)
_, qs = self._qs(self._endpoint(mock_get), keep_blank=True)
self.assertEqual(qs["eventIds"], [""])
def test_return_value_passthrough(self):
api = self._api()
with patch.object(TandemSourceApi, "get", return_value=PUMP_LOGS):
result = api.get_pump_logs("dev", "2024-01-01", "2024-01-15")
self.assertIs(result, PUMP_LOGS)
def test_none_dates_default_to_today(self):
api = self._api()
with patch.object(TandemSourceApi, "get", return_value=PUMP_LOGS) as mock_get:
api.get_pump_logs("dev")
_, qs = self._qs(self._endpoint(mock_get))
today = datetime.datetime.now().strftime('%Y-%m-%d')
self.assertEqual(qs["startDate"], ["%sT00:00:00Z" % today])
self.assertEqual(qs["endDate"], ["%sT23:59:59Z" % today])
def _ev(group, num, event_code=16, pump_date_time="2024-01-10T08:15:30", **props):
"""Trimmed real-shape pump-log event; eventCode 16 parses to LidBgReadingTaken."""
return {
"deviceAssignmentId": "1b493210-9336-4901-a329-a352775738c5",
"eventCode": event_code,
"sequenceGroup": group,
"sequenceNumber": num,
"pumpDateTime": pump_date_time,
"eventProperties": props or {"iob": 1.25, "bg": 112},
"estimatedDateTime": pump_date_time + "Z",
}
class TestPumpLogWindows(unittest.TestCase):
"""#10: the range is paged into inclusive windows no larger than 28 days."""
maxDiff = None
def test_single_day(self):
self.assertEqual(TandemSourceApi._pump_log_windows("2024-01-01", "2024-01-01"),
[("2024-01-01", "2024-01-01")])
def test_short_range_is_one_window(self):
# A span shorter than the window must still yield a covering window.
self.assertEqual(TandemSourceApi._pump_log_windows("2024-01-01", "2024-01-15"),
[("2024-01-01", "2024-01-15")])
def test_exactly_28_days_is_one_window(self):
self.assertEqual(TandemSourceApi._pump_log_windows("2024-01-01", "2024-01-28"),
[("2024-01-01", "2024-01-28")])
def test_29_days_splits(self):
self.assertEqual(TandemSourceApi._pump_log_windows("2024-01-01", "2024-01-29"),
[("2024-01-01", "2024-01-28"), ("2024-01-29", "2024-01-29")])
def test_long_range_windows_are_contiguous_and_bounded(self):
windows = TandemSourceApi._pump_log_windows("2024-01-01", "2024-03-01")
self.assertEqual(windows, [
("2024-01-01", "2024-01-28"),
("2024-01-29", "2024-02-25"),
("2024-02-26", "2024-03-01"),
])
# each window <= 28 days, and windows are contiguous (no gaps/overlaps)
for start, end in windows:
self.assertLessEqual((arrow.get(end) - arrow.get(start)).days, 27)
for (_, prev_end), (next_start, _) in zip(windows, windows[1:]):
self.assertEqual(arrow.get(next_start), arrow.get(prev_end).shift(days=1))
def test_reversed_dates_are_swapped(self):
self.assertEqual(TandemSourceApi._pump_log_windows("2024-03-01", "2024-01-01"),
TandemSourceApi._pump_log_windows("2024-01-01", "2024-03-01"))
def test_none_dates_default_to_single_today_window(self):
windows = TandemSourceApi._pump_log_windows(None, None)
self.assertEqual(len(windows), 1)
self.assertEqual(windows[0][0], windows[0][1])
class TestPumpEvents(unittest.TestCase):
"""#16: pump_events pages get_pump_logs by window, dedupes, skips
clockChanges, and yields parsed event objects."""
maxDiff = None
def _api(self):
api = TandemSourceApi.__new__(TandemSourceApi)
api.pumperId = "PUMPER123"
return api
def test_single_window_one_call_with_default_event_ids(self):
api = self._api()
resp = {"events": [_ev(0, 1)], "clockChanges": []}
with patch.object(TandemSourceApi, "get_pump_logs", return_value=resp) as m:
out = list(api.pump_events("dev-uuid", "2024-01-01", "2024-01-10"))
m.assert_called_once_with("dev-uuid", "2024-01-01", "2024-01-10",
TandemSourceApi.DEFAULT_EVENT_IDS)
self.assertEqual([type(e).__name__ for e in out], ["LidBgReadingTaken"])
def test_fetch_all_event_types_passes_none_filter(self):
api = self._api()
resp = {"events": [], "clockChanges": []}
with patch.object(TandemSourceApi, "get_pump_logs", return_value=resp) as m:
list(api.pump_events("dev", "2024-01-01", "2024-01-10", fetch_all_event_types=True))
self.assertIsNone(m.call_args.args[3])
def test_multi_window_paging_boundaries(self):
api = self._api()
responses = [
{"events": [_ev(0, 1)], "clockChanges": []},
{"events": [_ev(0, 2)], "clockChanges": []},
{"events": [_ev(0, 3)], "clockChanges": []},
]
with patch.object(TandemSourceApi, "get_pump_logs", side_effect=responses) as m:
out = list(api.pump_events("dev", "2024-01-01", "2024-03-01"))
windows = [(c.args[1], c.args[2]) for c in m.call_args_list]
self.assertEqual(windows, [
("2024-01-01", "2024-01-28"),
("2024-01-29", "2024-02-25"),
("2024-02-26", "2024-03-01"),
])
self.assertEqual([e.seqNum for e in out], [1, 2, 3])
def test_dedupes_across_windows_by_group_and_number(self):
api = self._api()
# Same (sequenceGroup, sequenceNumber) appears in two windows -> kept once.
responses = [
{"events": [_ev(0, 100), _ev(0, 101)], "clockChanges": []},
{"events": [_ev(0, 100), _ev(0, 102)], "clockChanges": []},
]
with patch.object(TandemSourceApi, "get_pump_logs", side_effect=responses):
out = list(api.pump_events("dev", "2024-01-01", "2024-02-15"))
self.assertEqual([e.seqNum for e in out], [100, 101, 102])
def test_same_number_different_group_not_deduped(self):
api = self._api()
responses = [
{"events": [_ev(0, 100)], "clockChanges": []},
{"events": [_ev(1, 100)], "clockChanges": []},
]
with patch.object(TandemSourceApi, "get_pump_logs", side_effect=responses):
out = list(api.pump_events("dev", "2024-01-01", "2024-02-15"))
self.assertEqual(len(out), 2)
def test_clock_changes_are_skipped(self):
api = self._api()
resp = {
"events": [_ev(0, 1)],
"clockChanges": [_ev(0, 5, event_code=13), _ev(0, 6, event_code=14)],
}
with patch.object(TandemSourceApi, "get_pump_logs", return_value=resp):
out = list(api.pump_events("dev", "2024-01-01", "2024-01-10"))
self.assertEqual([e.eventId for e in out], [16])
def test_missing_events_key_is_tolerated(self):
api = self._api()
with patch.object(TandemSourceApi, "get_pump_logs", return_value={}):
out = list(api.pump_events("dev", "2024-01-01", "2024-01-10"))
self.assertEqual(out, [])
class TestPumpEventsRealEventTypes(unittest.TestCase):
"""Parse bolus (20), basal (279), CGM (399) and alarm (5) events through
pump_events(). eventProperties use Tandem's real camelCase names; bitmask
fields arrive as arrays of set-bit indices."""
maxDiff = None
def _api(self):
api = TandemSourceApi.__new__(TandemSourceApi)
api.pumperId = "PUMPER123"
return api
RESPONSE = {
"events": [
_ev(0, 201, event_code=20, completionStatus=3, bolusId=777,
insulinDelivered=2.5, insulinRequested=2.5, iob=1.1),
_ev(0, 202, event_code=279, commandedRateSource=1, commandedRate=800,
profileBasalRate=800, algorithmRate=0, tempRate=0),
_ev(0, 203, event_code=399, glucoseValueStatus=0, cgmDataType=[0], rate=-5,
algorithmState=2, rssi=-60, currentGlucoseDisplayValue=112,
egvTimeStamp=123456, egvInfoBitmask=[], interval=5),
_ev(0, 204, event_code=5, alarmId=2, faultLocatorData=100, param1=1, param2=2.0),
],
"clockChanges": [],
}
def _parse(self):
api = self._api()
with patch.object(TandemSourceApi, "get_pump_logs", return_value=self.RESPONSE):
out = list(api.pump_events("dev", "2024-01-01", "2024-01-10"))
return {type(e).__name__: e for e in out}
def test_all_four_event_types_parse(self):
parsed = self._parse()
self.assertEqual(
set(parsed),
{"LidBolusCompleted", "LidBasalDelivery", "LidCgmDataG7", "LidAlarmActivated"},
)
def test_bolus_completed_decodes(self):
e = self._parse()["LidBolusCompleted"]
self.assertEqual(e.eventId, 20)
self.assertEqual(e.seqNum, 201)
self.assertEqual(e.bolusId, 777)
self.assertEqual(e.insulinDelivered, 2.5)
self.assertEqual(e.insulinRequested, 2.5)
self.assertEqual(e.iob, 1.1)
self.assertEqual(e.completionStatus,
eventtypes.LidBolusCompleted.CompletionstatusEnum.Completed)
def test_basal_delivery_decodes(self):
e = self._parse()["LidBasalDelivery"]
self.assertEqual(e.eventId, 279)
self.assertEqual(e.seqNum, 202)
self.assertEqual(e.commandedRate, 800)
self.assertEqual(e.profileBasalRate, 800)
self.assertEqual(e.commandedRateSource,
eventtypes.LidBasalDelivery.CommandedratesourceEnum.Profile)
def test_cgm_g7_decodes(self):
e = self._parse()["LidCgmDataG7"]
self.assertEqual(e.eventId, 399)
self.assertEqual(e.seqNum, 203)
self.assertEqual(e.currentGlucoseDisplayValue, 112)
self.assertEqual(e.glucoseValueStatus,
eventtypes.LidCgmDataG7.GlucosevaluestatusEnum.PreciseValue)
# cgmDataType bitmask array [0] -> bit 0 set -> Fmr
self.assertEqual(e.cgmDataType,
eventtypes.LidCgmDataG7.CgmdatatypeBitmask.Fmr)
# rate is stored raw and scaled x0.1 by the property (-5 -> -0.5 mg/dL/min)
self.assertAlmostEqual(e.rate, -0.5)
def test_alarm_activated_decodes(self):
e = self._parse()["LidAlarmActivated"]
self.assertEqual(e.eventId, 5)
self.assertEqual(e.seqNum, 204)
self.assertEqual(e.faultLocatorData, 100)
self.assertEqual(e.param2, 2.0)
self.assertEqual(e.alarmId,
eventtypes.LidAlarmActivated.AlarmidEnum.OcclusionAlarm)
def _cc(num, code):
return {"eventCode": code, "sequenceGroup": 0, "sequenceNumber": num,
"pumpDateTime": "2024-01-01T00:00:00", "eventProperties": {}}
class TestPumpClockChanges(unittest.TestCase):
def _api(self):
api = TandemSourceApi.__new__(TandemSourceApi)
api.pumperId = "P"
return api
def test_parses_clock_changes(self):
with patch.object(TandemSourceApi, "get_pump_logs",
return_value={"clockChanges": [_cc(5, 13), _cc(6, 14)]}):
out = list(self._api().pump_clock_changes("dev", "2024-01-01", "2024-01-10"))
self.assertEqual([(type(e).__name__, e.seqNum) for e in out],
[("LidTimeChanged", 5), ("LidDateChanged", 6)])
def test_dedupes_across_windows(self):
responses = [{"clockChanges": [_cc(5, 13)]}, {"clockChanges": [_cc(5, 13), _cc(7, 14)]}]
with patch.object(TandemSourceApi, "get_pump_logs", side_effect=responses):
out = list(self._api().pump_clock_changes("dev", "2024-01-01", "2024-02-15"))
self.assertEqual([e.seqNum for e in out], [5, 7])
def test_missing_clock_changes_key_is_tolerated(self):
with patch.object(TandemSourceApi, "get_pump_logs", return_value={}):
out = list(self._api().pump_clock_changes("dev", "2024-01-01", "2024-01-10"))
self.assertEqual(out, [])
class TestGetRetry(unittest.TestCase):
"""get() retries once on 500, re-logs-in and retries once on 401, and
raises immediately on other statuses; after one retry it gives up."""
maxDiff = None
def _api(self):
api = TandemSourceApi.__new__(TandemSourceApi)
api._email = 'e'
api._password = 'p'
api.accessTokenExpiresAt = 0
return api
def test_401_triggers_relogin_then_retry_succeeds(self):
api = self._api()
with patch.object(TandemSourceApi, "_get",
side_effect=[ApiException(401, 'unauth'), {'ok': True}]) as m_get, \
patch.object(TandemSourceApi, "login", return_value=None) as m_login:
result = api.get('ep', {})
self.assertEqual(result, {'ok': True})
self.assertEqual(m_login.call_count, 1)
self.assertEqual(m_get.call_count, 2)
def test_500_retries_without_relogin(self):
api = self._api()
with patch.object(TandemSourceApi, "_get",
side_effect=[ApiException(500, 'err'), {'ok': True}]) as m_get, \
patch.object(TandemSourceApi, "login", return_value=None) as m_login:
result = api.get('ep', {})
self.assertEqual(result, {'ok': True})
self.assertEqual(m_login.call_count, 0)
self.assertEqual(m_get.call_count, 2)
def test_other_status_raises_immediately(self):
api = self._api()
with patch.object(TandemSourceApi, "_get",
side_effect=ApiException(403, 'forbidden')) as m_get, \
patch.object(TandemSourceApi, "login", return_value=None) as m_login:
with self.assertRaises(ApiException):
api.get('ep', {})
self.assertEqual(m_login.call_count, 0)
self.assertEqual(m_get.call_count, 1)
def test_persistent_401_raises_after_one_retry(self):
api = self._api()
with patch.object(TandemSourceApi, "_get",
side_effect=[ApiException(401, 'unauth'), ApiException(401, 'unauth')]) as m_get, \
patch.object(TandemSourceApi, "login", return_value=None) as m_login:
with self.assertRaises(ApiException):
api.get('ep', {})
self.assertEqual(m_login.call_count, 1)
self.assertEqual(m_get.call_count, 2)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
-179
View File
@@ -1,179 +0,0 @@
#!/usr/bin/env python3
import unittest
import itertools
import copy
from .fake import WS2Api
from tconnectsync.api.common import ApiException
class TestWS2Api(unittest.TestCase):
def fake_get_with_http_500(self, num_times):
tries = 0
def fake_get(endpoint, **kwargs):
nonlocal tries, num_times
if "therapytimeline2csv" in endpoint:
if tries < num_times:
tries += 1
raise ApiException(500, "fake HTTP 500")
return ""
raise NotImplementedError
return fake_get
def test_therapy_timeline_csv_works_after_two_retries(self):
ws2 = WS2Api()
ws2.get = self.fake_get_with_http_500(2)
self.assertEqual(
ws2.therapy_timeline_csv('04-01-2021', '04-02-2021'),
{
"readingData": [],
"iobData": [],
"basalData": [],
"bolusData": []
})
def test_therapy_timeline_csv_fails_after_three_retries(self):
ws2 = WS2Api()
ws2.get = self.fake_get_with_http_500(3)
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '04-01-2021', '04-02-2021')
RAW_DATA_HEADER = """Tandem Diabetes Care Inc.
t:connect Therapy Timeline Data Export
Patient Name, Sample Name
Patient DOB, 1/1/1990
Report Generated On, 4/24/2021 7:50:04 PM
"""
RAW_DATA_CGM = """DeviceType,SerialNumber,Description,EventDateTime,Readings (CGM / BGM)
"t:slim X2 Insulin Pump","11111111","EGV","2021-04-01T00:01:33","235",
"t:slim X2 Insulin Pump","11111111","EGV","2021-04-01T00:06:33","230",
"t:slim X2 Insulin Pump","11111111","EGV","2021-04-02T23:31:36","181",
"""
RAW_DATA_IOB = """Type,EventID,EventDateTime,IOB
"IOB","81","2021-04-01T00:00:19","13.24"
"IOB","9","2021-04-01T00:03:12","12.80"
"IOB","81","2021-04-02T23:58:19","4.25"
"""
RAW_DATA_BOLUS = """Type,Description,BG,IOB,BolusRequestID,BolusCompletionID,CompletionDateTime,InsulinDelivered,FoodDelivered,CorrectionDelivered,CompletionStatusID,CompletionStatusDesc,BolusIsComplete,BolexCompletionID,BolexSize,BolexStartDateTime,BolexCompletionDateTime,BolexInsulinDelivered,BolexIOB,BolexCompletionStatusID,BolexCompletionStatusDesc,ExtendedBolusIsComplete,EventDateTime,RequestDateTime,BolusType,BolusRequestOptions,StandardPercent,Duration,CarbSize,UserOverride,TargetBG,CorrectionFactor,FoodBolusSize,CorrectionBolusSize,ActualTotalBolusRequested,IsQuickBolus,EventHistoryReportEventDesc,EventHistoryReportDetails,NoteID,IndexID,Note
"Bolus","Standard/Correction","141",,"7001.000","7001.000","2021-04-01T12:58:26","13.53","12.50","1.03","3","Completed","1",,,,,,,,,,"2021-04-01T12:53:36","2021-04-01T12:53:36","Carb","Standard/Correction","100.00","0","75","0","110","30.00","12.50","1.03","13.53","0","0","Correction & Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110","0","1181649","",
"Bolus","Standard","131","0.71","7003.000","7003.000","2021-04-01T16:03:25","1.50","0.00","0.00","3","Completed","1",,,,,,,,,,"2021-04-01T16:02:04","2021-04-01T16:02:04","Carb","Standard","100.00","0","0","1","110","30.00","0.00","0.00","1.50","0","0","Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units","0","1182026","",
"Bolus","Standard/Correction","168","1.71","7004.000","7004.000","2021-04-01T16:24:08","2.00","0.00","0.00","3","Completed","1",,,,,,,,,,"2021-04-01T16:22:21","2021-04-01T16:22:21","Carb","Standard/Correction","100.00","0","0","1","110","30.00","0.00","0.22","2.00","0","0","Correction & Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.2 units","0","1182082","",
"Bolus","Standard","220","3.98","7032.000","7032.000","2021-04-02T23:16:24","2.50","0.00","0.00","3","Completed","1",,,,,,,,,,"2021-04-02T23:14:33","2021-04-02T23:14:33","Carb","Standard","100.00","0","0","1","110","30.00","0.00","0.00","2.50","0","0","Food Bolus","CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units","0","1185846","",
"""
RAW_DATA_FULL = RAW_DATA_HEADER + "\n" + RAW_DATA_CGM + "\n" + RAW_DATA_IOB + "\n" + RAW_DATA_BOLUS
PARSED_DATA = {
'readingData': [
{"DeviceType": "t:slim X2 Insulin Pump", "SerialNumber": "11111111", "Description": "EGV", "EventDateTime": "2021-04-01T00:01:33", "Readings (CGM / BGM)": "235"},
{"DeviceType": "t:slim X2 Insulin Pump", "SerialNumber": "11111111", "Description": "EGV", "EventDateTime": "2021-04-01T00:06:33", "Readings (CGM / BGM)": "230"},
{"DeviceType": "t:slim X2 Insulin Pump", "SerialNumber": "11111111", "Description": "EGV", "EventDateTime": "2021-04-02T23:31:36", "Readings (CGM / BGM)": "181"}
],
'iobData': [
{"Type": "IOB", "EventID": "81", "EventDateTime": "2021-04-01T00:00:19", "IOB": "13.24"},
{"Type": "IOB", "EventID": "9", "EventDateTime": "2021-04-01T00:03:12", "IOB": "12.80"},
{"Type": "IOB", "EventID": "81", "EventDateTime": "2021-04-02T23:58:19", "IOB": "4.25"},
],
'basalData': [],
'bolusData': [
{"Type": "Bolus", "Description": "Standard/Correction", "BG": "141", "IOB": "", "BolusRequestID": "7001.000", "BolusCompletionID": "7001.000", "CompletionDateTime": "2021-04-01T12:58:26", "InsulinDelivered": "13.53", "FoodDelivered": "12.50", "CorrectionDelivered": "1.03", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-01T12:53:36", "RequestDateTime": "2021-04-01T12:53:36", "BolusType": "Carb", "BolusRequestOptions": "Standard/Correction", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "75", "UserOverride": "0", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "12.50", "CorrectionBolusSize": "1.03", "ActualTotalBolusRequested": "13.53", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Correction & Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110", "IndexID": "0", "Note": "1181649"},
{"Type": "Bolus", "Description": "Standard", "BG": "131", "IOB": "0.71", "BolusRequestID": "7003.000", "BolusCompletionID": "7003.000", "CompletionDateTime": "2021-04-01T16:03:25", "InsulinDelivered": "1.50", "FoodDelivered": "0.00", "CorrectionDelivered": "0.00", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-01T16:02:04", "RequestDateTime": "2021-04-01T16:02:04", "BolusType": "Carb", "BolusRequestOptions": "Standard", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "0", "UserOverride": "1", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "0.00", "CorrectionBolusSize": "0.00", "ActualTotalBolusRequested": "1.50", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units", "IndexID": "0", "Note": "1182026"},
{"Type": "Bolus", "Description": "Standard/Correction", "BG": "168", "IOB": "1.71", "BolusRequestID": "7004.000", "BolusCompletionID": "7004.000", "CompletionDateTime": "2021-04-01T16:24:08", "InsulinDelivered": "2.00", "FoodDelivered": "0.00", "CorrectionDelivered": "0.00", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-01T16:22:21", "RequestDateTime": "2021-04-01T16:22:21", "BolusType": "Carb", "BolusRequestOptions": "Standard/Correction", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "0", "UserOverride": "1", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "0.00", "CorrectionBolusSize": "0.22", "ActualTotalBolusRequested": "2.00", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Correction & Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.2 units", "IndexID": "0", "Note": "1182082"},
{"Type": "Bolus", "Description": "Standard", "BG": "220", "IOB": "3.98", "BolusRequestID": "7032.000", "BolusCompletionID": "7032.000", "CompletionDateTime": "2021-04-02T23:16:24", "InsulinDelivered": "2.50", "FoodDelivered": "0.00", "CorrectionDelivered": "0.00", "CompletionStatusID": "3", "CompletionStatusDesc": "Completed", "BolusIsComplete": "1", "BolexCompletionID": "", "BolexSize": "", "BolexStartDateTime": "", "BolexCompletionDateTime": "", "BolexInsulinDelivered": "", "BolexIOB": "", "BolexCompletionStatusID": "", "BolexCompletionStatusDesc": "", "ExtendedBolusIsComplete": "", "EventDateTime": "2021-04-02T23:14:33", "RequestDateTime": "2021-04-02T23:14:33", "BolusType": "Carb", "BolusRequestOptions": "Standard", "StandardPercent": "100.00", "Duration": "0", "CarbSize": "0", "UserOverride": "1", "TargetBG": "110", "CorrectionFactor": "30.00", "FoodBolusSize": "0.00", "CorrectionBolusSize": "0.00", "ActualTotalBolusRequested": "2.50", "IsQuickBolus": "0", "EventHistoryReportEventDesc": "0", "EventHistoryReportDetails": "Food Bolus", "NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units", "IndexID": "0", "Note": "1185846"}
]
}
def test_therapy_timeline_csv_parses_full(self):
ws2 = WS2Api()
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
rawData = self.RAW_DATA_FULL
def fake_get(endpoint, **kwargs):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData
ws2.get = fake_get
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
self.assertDictEqual(tt, self.PARSED_DATA)
def test_therapy_timeline_csv_parses_random_order(self):
ws2 = WS2Api()
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
rawData = ""
def fake_get(endpoint, **kwargs):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData
ws2.get = fake_get
# Randomize the order of all sections
for i in itertools.permutations([self.RAW_DATA_HEADER, self.RAW_DATA_CGM, self.RAW_DATA_IOB, self.RAW_DATA_BOLUS], 4):
rawData = "\n".join(i)
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
self.assertDictEqual(tt, self.PARSED_DATA)
def test_therapy_timeline_csv_split_past_max_days(self):
ws2 = WS2Api()
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def replace_str(raw, one, two):
return raw.replace('04-01-2021', one).replace('04-02-2021', two)
rawData1 = self.RAW_DATA_FULL
rawData2 = replace_str(self.RAW_DATA_FULL, '04-03-2021', '04-04-2021')
rawData3 = replace_str(self.RAW_DATA_FULL, '04-05-2021', '04-06-2021')
rawData4 = replace_str(self.RAW_DATA_FULL, '04-07-2021', '04-07-2021')
def replace_parsed(one, two):
parsedData = copy.deepcopy(self.PARSED_DATA)
for typ in parsedData.keys():
for i in range(len(parsedData[typ])):
for f in parsedData[typ][i].keys():
if 'datetime' in f.lower():
parsedData[typ][i][f] = replace_str(parsedData[typ][i][f], one, two)
return parsedData
parsedData1 = self.PARSED_DATA
parsedData2 = replace_parsed('04-03-2021', '04-04-2021')
parsedData3 = replace_parsed('04-05-2021', '04-06-2021')
parsedData4 = replace_parsed('04-07-2021', '04-07-2021')
fullParsedData = parsedData1
for d in [parsedData2, parsedData3, parsedData4]:
for typ in d.keys():
fullParsedData[typ] += d[typ]
def fake_get(endpoint, **kwargs):
nonlocal rawData1, rawData2, rawData3, rawData4
print('fake_get call %s' % endpoint)
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData1
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-03-2021/04-04-2021?format=csv':
return rawData2
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-05-2021/04-06-2021?format=csv':
return rawData3
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-07-2021/04-07-2021?format=csv':
return rawData4
ws2.get = fake_get
tt = ws2.therapy_timeline_csv('04-01-2021', '04-07-2021')
self.assertDictEqual(tt, fullParsedData)
if __name__ == '__main__':
unittest.main()
+10
View File
@@ -0,0 +1,10 @@
import os
import sys
# Set timezone BEFORE importing any tconnectsync modules
os.environ['TIMEZONE_NAME'] = 'America/New_York'
# Remove any cached imports of tconnectsync modules to force reimport with new env
for module_name in list(sys.modules.keys()):
if module_name.startswith('tconnectsync'):
del sys.modules[module_name]
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.domain.tandemsource.pump_settings import PumpSettings
# Trimmed real bff/pumper settings.details (values from a captured account,
# schedule condensed to two segments). Extra top-level blocks the parser
# ignores (basalLimitSettings/controlIqSettings/...) are omitted.
SETTINGS_DETAILS = {
"profiles": {
"numberOfProfiles": 2,
"activeSegment": 0,
"activeIdp": 0,
"profile": [
{
"idp": 0,
"timeDependentSegmentNumber": 2,
"name": "A",
"carbEntry": "UnitsAsCarbs",
"maxBolus": 25000,
"insulinDuration": 300,
"timeDependentSegments": [
{"startTime": 0, "basalRate": 800, "carbRatio": 6000, "targetBg": 110, "isf": 30,
"status": ["BasalRateAvailability"]},
{"startTime": 480, "basalRate": 1200, "carbRatio": 6000, "targetBg": 110, "isf": 30,
"status": ["BasalRateAvailability"]},
],
},
{
"idp": 2,
"timeDependentSegmentNumber": 1,
"name": "No delivery",
"carbEntry": "UnitsAsCarbs",
"maxBolus": 25000,
"insulinDuration": 300,
# An all-zero segment must be dropped as a skip.
"timeDependentSegments": [
{"startTime": 0, "basalRate": 0, "carbRatio": 0, "targetBg": 0, "isf": 0, "status": []},
{"startTime": 720, "basalRate": 500, "carbRatio": 12000, "targetBg": 120, "isf": 45, "status": []},
],
},
],
},
"cgmSettings": {
"highGlucoseAlertMgPerDl": 200,
"highGlucoseAlertEnabled": True,
"lowGlucoseAlertMgPerDl": 80,
"lowGlucoseAlertEnabled": True,
"riseRateAlertLevel": 3,
},
# Blocks the parser does not consume; must be ignored, not error.
"basalLimitSettings": {"basalLimitDefault": 5000, "basalLimit": 2500},
"controlIqSettings": {"weight": 140, "closedLoop": False},
}
class TestPumpSettingsFromDict(unittest.TestCase):
maxDiff = None
def setUp(self):
self.settings = PumpSettings.from_dict(SETTINGS_DETAILS)
def test_profiles_container(self):
self.assertEqual(self.settings.profiles.activeIdp, 0)
self.assertEqual(len(self.settings.profiles.profile), 2)
self.assertEqual([p.name for p in self.settings.profiles.profile], ["A", "No delivery"])
def test_profile_fields(self):
profile = self.settings.profiles.profile[0]
self.assertEqual(profile.idp, 0)
self.assertEqual(profile.insulinDuration, 300)
self.assertEqual(profile.maxBolus, 25000)
self.assertEqual(profile.carbEntry, "UnitsAsCarbs")
def test_segments_parse_with_new_key(self):
# The BFF names the container timeDependentSegments (was tDependentSegs).
profile = self.settings.profiles.profile[0]
self.assertEqual(len(profile.timeDependentSegments), 2)
seg = profile.timeDependentSegments[0]
self.assertEqual((seg.startTime, seg.basalRate, seg.carbRatio, seg.targetBg, seg.isf),
(0, 800, 6000, 110, 30))
def test_tdependentsegs_alias(self):
profile = self.settings.profiles.profile[0]
self.assertIs(profile.tDependentSegs, profile.timeDependentSegments)
def test_skip_segments_are_dropped(self):
# "No delivery" has one all-zero (skip) segment and one real segment.
profile = self.settings.profiles.profile[1]
self.assertEqual(len(profile.timeDependentSegments), 1)
self.assertEqual(profile.timeDependentSegments[0].startTime, 720)
def test_cgm_settings_are_flat(self):
self.assertEqual(self.settings.cgmSettings.lowGlucoseAlertMgPerDl, 80)
self.assertEqual(self.settings.cgmSettings.highGlucoseAlertMgPerDl, 200)
if __name__ == "__main__":
unittest.main()
-367
View File
@@ -1,367 +0,0 @@
import dataclasses
import unittest
from tconnectsync.domain.bolus import Bolus
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent
class TestCGMTherapyEvent(unittest.TestCase):
maxDiff = None
sampleJson = {
"eventDateTime": "2022-07-21T00:00:08",
"eventID": 256,
"requestDateTime": "0001-01-01T00:00:00",
"type": "CGM",
"description": "EGV",
"sourceRecId": 0,
"eventTypeId": 0,
"deviceType": "t:slim X2 Insulin Pump",
"serialNumber": "xxx",
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0,
"egv": {
"estimatedGlucoseValue": 174,
"hypo": 0,
"belowTarget": 0,
"withinTarget": 1,
"aboveTarget": 0,
"hyper": 0
}
}
def test_parse_cgm(self):
e = CGMTherapyEvent.parse(self.sampleJson)
self.assertEqual(e.type, "CGM")
self.assertEqual(e.eventDateTime, "2022-07-21T00:00:08")
self.assertEqual(e.sourceRecId, 0)
self.assertEqual(e.eventID, 256)
self.assertEqual(e.egv, 174)
class TestBolusTherapyEvent(unittest.TestCase):
maxDiff = None
standardJson = {
"actualTotalBolusRequested": 4.17,
"bolusRequestOptions": "Standard",
"bolusType": "Carb",
"carbSize": 25,
"correctionBolusSize": 0,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T12:27:36",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"eventHistoryReportEventDesc": "Food Bolus",
"foodBolusSize": 4.17,
"iob": 2.62,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "573042",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-07-21T12:27:36",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T12:29:21",
"value": 4.17
},
"foodDelivered": 4.17,
"correctionDelivered": 0,
"insulinRequested": 4.17,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3362,
"bolusCompletionId": 3362
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Standard",
"sourceRecId": 1171853319,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_standard_to_bolus(self):
e = BolusTherapyEvent.parse(self.standardJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Standard",
complete="1",
completion="Completed",
request_time="2022-07-21 12:27:36-04:00",
completion_time="2022-07-21 12:29:21-04:00",
insulin="4.17",
requested_insulin="4.17",
carbs="25",
bg="",
user_override="0",
extended_bolus="0",
bolex_completion_time="",
bolex_start_time=""
)))
correctionJson = {
"actualTotalBolusRequested": 2.9,
"bg": 254,
"bolusRequestOptions": "Automatic Bolus/Correction",
"bolusType": "Automatic Correction",
"carbSize": 0,
"correctionBolusSize": 2.9,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T11:53:08",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
"eventHistoryReportEventDesc": "Correction Bolus",
"foodBolusSize": 0,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "572946",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-07-21T11:53:08",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T11:55:24",
"value": 2.9
},
"foodDelivered": 0,
"correctionDelivered": 2.9,
"insulinRequested": 2.9,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3361,
"bolusCompletionId": 3361
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Automatic Bolus/Correction",
"sourceRecId": 1171791787,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_correction_to_bolus(self):
e = BolusTherapyEvent.parse(self.correctionJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Automatic Bolus/Correction",
complete="1",
completion="Completed",
request_time="2022-07-21 11:53:08-04:00",
completion_time="2022-07-21 11:55:24-04:00",
insulin="2.9",
requested_insulin="2.9",
carbs="0",
bg="254",
user_override="0",
extended_bolus="0",
bolex_completion_time="",
bolex_start_time=""
)))
extendedBolusIncompleteJson = {
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"iob": 0,
"completionStatusId": 0,
"extendedBolusIsComplete": 0,
"insulinRequested": 0,
"bolexCompletionId": 0
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_extended_bolus_incomplete_to_bolus(self):
e = BolusTherapyEvent.parse(self.extendedBolusIncompleteJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Extended 50.00%/0.00",
complete="0",
completion="",
request_time="2022-08-09 23:19:15-04:00",
completion_time="2022-08-09 23:20:04-04:00",
insulin="0.2",
requested_insulin="0.2",
carbs="0",
bg="131",
user_override="1",
extended_bolus="1",
bolex_completion_time="",
bolex_start_time="2022-08-09 23:20:04-04:00"
)))
extendedBolusJson = {
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:35:03",
"value": 0.2
},
"iob": 5.7,
"completionStatusId": 3.0,
"completionStatusDesc": "Completed",
"extendedBolusIsComplete": 1,
"insulinRequested": 0.2,
"bolexCompletionId": 16757133
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_extended_bolus_complete_to_bolus(self):
e = BolusTherapyEvent.parse(self.extendedBolusJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Extended 50.00%/0.00",
complete="1",
completion="Completed",
request_time="2022-08-09 23:19:15-04:00",
completion_time="2022-08-09 23:20:04-04:00",
insulin="0.2",
requested_insulin="0.2",
carbs="0",
bg="131",
user_override="1",
extended_bolus="1",
bolex_completion_time="2022-08-09 23:35:03-04:00",
bolex_start_time="2022-08-09 23:20:04-04:00"
)))
BOLUS_FULL_EXAMPLES = [
TestBolusTherapyEvent.standardJson,
TestBolusTherapyEvent.correctionJson,
TestBolusTherapyEvent.extendedBolusJson
]
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
import json
import unittest
from tconnectsync.eventparser.generic import Event
from tconnectsync.eventparser import events as eventtypes
class TestLidAaDailyStatus(unittest.TestCase):
"""313 LID_AA_DAILY_STATUS: pumpControlState/usermode/sensorType enums.
Fixtures are real captured pump-log events copied verbatim, including the
extra weightUnit/weight/currentTdIpop keys the parser ignores.
"""
maxDiff = None
def setUp(self):
# Real capture: pumpControlState 3 -> PcmClosedLoop.
self.fixtureClosedLoop = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 313,
"sequenceGroup": 0,
"sequenceNumber": 393118,
"pumpDateTime": "2026-04-30T00:00:06",
"eventProperties": {
"pumpControlState": 3, "usermode": 1, "sensorType": 3,
"weightUnit": 0, "weight": 0, "currentTdIpop": 0,
},
"estimatedDateTime": "2026-04-30T00:00:06Z",
}
# Real capture: pumpControlState 0 -> PcmNoControlNoCartridgeInstalled.
self.fixtureNoControl = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 313,
"sequenceGroup": 0,
"sequenceNumber": 420321,
"pumpDateTime": "2026-05-08T00:00:06",
"eventProperties": {
"pumpControlState": 0, "usermode": 1, "sensorType": 3,
"weightUnit": 0, "weight": 0, "currentTdIpop": 0,
},
"estimatedDateTime": "2026-05-08T00:00:06Z",
}
def test_dispatches_to_lidaadailystatus(self):
self.assertIsInstance(Event(self.fixtureClosedLoop), eventtypes.LidAaDailyStatus)
self.assertIsInstance(Event(self.fixtureNoControl), eventtypes.LidAaDailyStatus)
def test_envelope_fields(self):
ev = Event(self.fixtureClosedLoop)
self.assertEqual(ev.eventId, 313)
self.assertEqual(ev.seqNum, 393118)
self.assertEqual(Event(self.fixtureNoControl).seqNum, 420321)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureClosedLoop)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T00:00:06")
def test_pumpcontrolstate_closed_loop(self):
ev = Event(self.fixtureClosedLoop)
self.assertEqual(ev.pumpControlStateRaw, 3)
self.assertEqual(ev.pumpControlState,
eventtypes.LidAaDailyStatus.PumpcontrolstateEnum.PcmClosedLoop)
def test_pumpcontrolstate_no_control_zero_value(self):
# pumpControlState 0 must resolve, not be treated as missing.
ev = Event(self.fixtureNoControl)
self.assertEqual(ev.pumpControlStateRaw, 0)
self.assertEqual(ev.pumpControlState,
eventtypes.LidAaDailyStatus.PumpcontrolstateEnum.PcmNoControlNoCartridgeInstalled)
def test_usermode_resolves(self):
ev = Event(self.fixtureClosedLoop)
self.assertEqual(ev.usermodeRaw, 1)
self.assertEqual(ev.usermode,
eventtypes.LidAaDailyStatus.UsermodeEnum.Sleeping)
def test_sensortype_resolves(self):
ev = Event(self.fixtureClosedLoop)
self.assertEqual(ev.sensorTypeRaw, 3)
self.assertEqual(ev.sensorType,
eventtypes.LidAaDailyStatus.SensortypeEnum.CgmTypeDexcomG7)
def test_unknown_keys_ignored(self):
# weightUnit/weight/currentTdIpop are not in the schema and must be
# dropped without raising or becoming attributes.
ev = Event(self.fixtureClosedLoop)
self.assertFalse(hasattr(ev, "weightUnit"))
self.assertFalse(hasattr(ev, "weight"))
self.assertFalse(hasattr(ev, "currentTdIpop"))
def test_todict_is_json_serializable(self):
ev = Event(self.fixtureClosedLoop)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 313)
self.assertEqual(d["name"], "LID_AA_DAILY_STATUS")
self.assertEqual(d["seqNum"], 393118)
self.assertEqual(d["pumpControlStateRaw"], 3)
self.assertEqual(d["usermodeRaw"], 1)
self.assertEqual(d["sensorTypeRaw"], 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,147 @@
#!/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 TestLidAaPcmChange(unittest.TestCase):
"""230 LID_AA_PCM_CHANGE: currentPcm/previousPcm resolve to a PCM enum,
and the boolean-ish fields resolve to False/True enum members. All
fixtures are real captured events copied verbatim."""
maxDiff = None
def setUp(self):
# currentPcm:0 (NoControl) from previousPcm:3 (ClosedLoop), suspended.
self.fixtureSuspendedNoControl = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 230,
"sequenceGroup": 0,
"sequenceNumber": 394337,
"pumpDateTime": "2026-04-30T10:01:49",
"eventProperties": {
"currentPcm": 0, "previousPcm": 3, "pumpSuspended": 1,
"calculationAvailable": 1, "cgmAvailable": 1,
"closedLoopPreferred": 1, "sufficientClosedLoopParams": 1,
},
"estimatedDateTime": "2026-04-30T10:01:49Z",
}
# currentPcm:3 (ClosedLoop) from previousPcm:0 (NoControl), not suspended.
self.fixtureResumedClosedLoop = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 230,
"sequenceGroup": 0,
"sequenceNumber": 394430,
"pumpDateTime": "2026-04-30T10:16:31",
"eventProperties": {
"currentPcm": 3, "previousPcm": 0, "pumpSuspended": 0,
"calculationAvailable": 1, "cgmAvailable": 1,
"closedLoopPreferred": 1, "sufficientClosedLoopParams": 1,
},
"estimatedDateTime": "2026-04-30T10:16:31Z",
}
# currentPcm:2 (Pining) with cgmAvailable:0 -> FalseVal boolean-ish field.
self.fixturePiningNoCgm = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 230,
"sequenceGroup": 0,
"sequenceNumber": 409128,
"pumpDateTime": "2026-05-04T18:58:22",
"eventProperties": {
"currentPcm": 2, "previousPcm": 3, "pumpSuspended": 0,
"calculationAvailable": 1, "cgmAvailable": 0,
"closedLoopPreferred": 1, "sufficientClosedLoopParams": 1,
},
"estimatedDateTime": "2026-05-04T18:58:22Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureSuspendedNoControl)
self.assertIsInstance(ev, eventtypes.LidAaPcmChange)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureSuspendedNoControl)
self.assertEqual(ev.eventId, 230)
self.assertEqual(ev.seqNum, 394337)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureSuspendedNoControl)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T10:01:49")
def test_pcm_enums_no_control_from_closed_loop(self):
# currentPcm:0 -> NoControl, previousPcm:3 -> ClosedLoop
ev = Event(self.fixtureSuspendedNoControl)
self.assertEqual(ev.currentPcmRaw, 0)
self.assertEqual(ev.currentPcm,
eventtypes.LidAaPcmChange.CurrentpcmEnum.NoControl)
self.assertEqual(ev.previousPcmRaw, 3)
self.assertEqual(ev.previousPcm,
eventtypes.LidAaPcmChange.PreviouspcmEnum.ClosedLoop)
def test_pcm_enums_closed_loop_from_no_control(self):
# currentPcm:3 -> ClosedLoop, previousPcm:0 -> NoControl
ev = Event(self.fixtureResumedClosedLoop)
self.assertEqual(ev.currentPcm,
eventtypes.LidAaPcmChange.CurrentpcmEnum.ClosedLoop)
self.assertEqual(ev.previousPcm,
eventtypes.LidAaPcmChange.PreviouspcmEnum.NoControl)
def test_pcm_enum_pining(self):
# currentPcm:2 -> Pining
ev = Event(self.fixturePiningNoCgm)
self.assertEqual(ev.currentPcmRaw, 2)
self.assertEqual(ev.currentPcm,
eventtypes.LidAaPcmChange.CurrentpcmEnum.Pining)
def test_boolean_fields_when_suspended(self):
ev = Event(self.fixtureSuspendedNoControl)
self.assertEqual(ev.pumpSuspendedRaw, 1)
self.assertEqual(ev.pumpSuspended,
eventtypes.LidAaPcmChange.PumpsuspendedEnum.TrueVal)
self.assertEqual(ev.calculationAvailable,
eventtypes.LidAaPcmChange.CalculationavailableEnum.TrueVal)
self.assertEqual(ev.cgmAvailable,
eventtypes.LidAaPcmChange.CgmavailableEnum.TrueVal)
self.assertEqual(ev.closedLoopPreferred,
eventtypes.LidAaPcmChange.ClosedlooppreferredEnum.TrueVal)
self.assertEqual(ev.sufficientClosedLoopParams,
eventtypes.LidAaPcmChange.SufficientclosedloopparamsEnum.TrueVal)
def test_pump_suspended_false(self):
# pumpSuspended:0 -> FalseVal (0 must not be treated as missing)
ev = Event(self.fixtureResumedClosedLoop)
self.assertEqual(ev.pumpSuspendedRaw, 0)
self.assertEqual(ev.pumpSuspended,
eventtypes.LidAaPcmChange.PumpsuspendedEnum.FalseVal)
def test_cgm_available_false(self):
# cgmAvailable:0 -> FalseVal while other boolean-ish fields stay TrueVal
ev = Event(self.fixturePiningNoCgm)
self.assertEqual(ev.cgmAvailableRaw, 0)
self.assertEqual(ev.cgmAvailable,
eventtypes.LidAaPcmChange.CgmavailableEnum.FalseVal)
self.assertEqual(ev.calculationAvailable,
eventtypes.LidAaPcmChange.CalculationavailableEnum.TrueVal)
self.assertEqual(ev.closedLoopPreferred,
eventtypes.LidAaPcmChange.ClosedlooppreferredEnum.TrueVal)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureSuspendedNoControl,
self.fixtureResumedClosedLoop,
self.fixturePiningNoCgm):
ev = Event(fixture)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 230)
self.assertEqual(d["name"], "LID_AA_PCM_CHANGE")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,163 @@
#!/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 TestLidAaUserModeChange(unittest.TestCase):
"""229 LID_AA_USER_MODE_CHANGE, from real captured pump-log events."""
maxDiff = None
def setUp(self):
# Normal <- Sleeping, requestedAction StopSleep, activeSleepSchedule [0].
self.fixtureStopSleep = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456851,
"pumpDateTime": "2026-05-18T10:15:53",
"eventProperties": {
"currentUserMode": 0, "previousUserMode": 1, "requestedAction": 2,
"spareA3": 0, "sleepStartedByGui": 1, "activeSleepSchedule": [0],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
"estimatedDateTime": "2026-05-18T10:15:53Z",
}
# Sleeping <- Normal, requestedAction StartSleep, activeSleepSchedule [0].
self.fixtureStartSleep = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456855,
"pumpDateTime": "2026-05-18T10:16:00",
"eventProperties": {
"currentUserMode": 1, "previousUserMode": 0, "requestedAction": 1,
"spareA3": 0, "sleepStartedByGui": 1, "activeSleepSchedule": [0],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
"estimatedDateTime": "2026-05-18T10:16:00Z",
}
# Exercising <- Normal, requestedAction StartExercise, empty activeSleepSchedule.
self.fixtureStartExercise = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456961,
"pumpDateTime": "2026-05-18T10:20:04",
"eventProperties": {
"currentUserMode": 2, "previousUserMode": 0, "requestedAction": 3,
"spareA3": 0, "sleepStartedByGui": 0, "activeSleepSchedule": [],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
"estimatedDateTime": "2026-05-18T10:20:04Z",
}
# Sleeping <- Exercising, requestedAction StopExercise, activeSleepSchedule [0].
self.fixtureStopExercise = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 229,
"sequenceGroup": 0,
"sequenceNumber": 456965,
"pumpDateTime": "2026-05-18T10:20:15",
"eventProperties": {
"currentUserMode": 1, "previousUserMode": 2, "requestedAction": 4,
"spareA3": 0, "sleepStartedByGui": 0, "activeSleepSchedule": [0],
"spareB6": 0, "exerciseStoppedByTimer": 0, "exerciseChoice": 0,
"exerciseTime": 0, "eatingSoonStoppedByTimer": 0,
},
"estimatedDateTime": "2026-05-18T10:20:15Z",
}
def test_dispatches_to_correct_class(self):
for fx in (self.fixtureStopSleep, self.fixtureStartSleep,
self.fixtureStartExercise, self.fixtureStopExercise):
ev = Event(fx)
self.assertIsInstance(ev, eventtypes.LidAaUserModeChange)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureStopSleep)
self.assertEqual(ev.eventId, 229)
self.assertEqual(ev.seqNum, 456851)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-18T10:15:53")
def test_envelope_fields_other_fixture(self):
ev = Event(self.fixtureStartExercise)
self.assertEqual(ev.eventId, 229)
self.assertEqual(ev.seqNum, 456961)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-18T10:20:04")
def test_stop_sleep_enums(self):
ev = Event(self.fixtureStopSleep)
self.assertEqual(ev.currentUserModeRaw, 0)
self.assertEqual(ev.currentUserMode,
eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(ev.previousUserModeRaw, 1)
self.assertEqual(ev.previousUserMode,
eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
self.assertEqual(ev.requestedActionRaw, 2)
self.assertEqual(ev.requestedAction,
eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
def test_start_sleep_enums(self):
ev = Event(self.fixtureStartSleep)
self.assertEqual(ev.currentUserMode,
eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
self.assertEqual(ev.previousUserMode,
eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(ev.requestedAction,
eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep)
def test_start_exercise_enums(self):
ev = Event(self.fixtureStartExercise)
self.assertEqual(ev.currentUserMode,
eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Exercising)
self.assertEqual(ev.previousUserMode,
eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(ev.requestedAction,
eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise)
def test_stop_exercise_enums(self):
ev = Event(self.fixtureStopExercise)
self.assertEqual(ev.currentUserMode,
eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
self.assertEqual(ev.previousUserMode,
eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
self.assertEqual(ev.requestedAction,
eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
def test_active_sleep_schedule_single_bit(self):
# activeSleepSchedule:[0] -> 1<<0 == 1 -> SleepSchedule1IsActive
ev = Event(self.fixtureStopSleep)
self.assertEqual(ev.activeSleepScheduleRaw, 1)
self.assertEqual(ev.activeSleepSchedule,
eventtypes.LidAaUserModeChange.ActivesleepscheduleBitmask.SleepSchedule1IsActive)
def test_active_sleep_schedule_empty(self):
# An empty array folds to 0 (empty IntFlag), not None.
ev = Event(self.fixtureStartExercise)
self.assertEqual(ev.activeSleepScheduleRaw, 0)
self.assertEqual(ev.activeSleepSchedule,
eventtypes.LidAaUserModeChange.ActivesleepscheduleBitmask(0))
self.assertEqual(int(ev.activeSleepSchedule), 0)
def test_todict_json_serializable(self):
for fx in (self.fixtureStopSleep, self.fixtureStartSleep,
self.fixtureStartExercise, self.fixtureStopExercise):
ev = Event(fx)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 229)
self.assertEqual(d["name"], "LID_AA_USER_MODE_CHANGE")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,100 @@
#!/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 TestLidAlarmActivated(unittest.TestCase):
"""5: LID_ALARM_ACTIVATED. Real captured pump-log events; alarmId is a
dictionary transform resolving to an AlarmidEnum member."""
maxDiff = None
def setUp(self):
# Real capture: alarmId 18 -> RESUME_PUMP_ALARM.
self.fixtureResumePumpAlarm = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 5,
"sequenceGroup": 0,
"sequenceNumber": 398724,
"pumpDateTime": "2026-05-01T17:08:10",
"eventProperties": {"alarmId": 18, "faultLocatorData": 8311, "param1": 3993668, "param2": 0},
"estimatedDateTime": "2026-05-01T17:08:10Z",
}
# Real capture: alarmId 23 -> RESUME_PUMP_ALARM2.
self.fixtureResumePumpAlarm2 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 5,
"sequenceGroup": 0,
"sequenceNumber": 398725,
"pumpDateTime": "2026-05-01T17:08:10",
"eventProperties": {"alarmId": 23, "faultLocatorData": 8311, "param1": 18, "param2": 0},
"estimatedDateTime": "2026-05-01T17:08:10Z",
}
def test_dispatches_to_correct_class(self):
self.assertIsInstance(Event(self.fixtureResumePumpAlarm), eventtypes.LidAlarmActivated)
self.assertIsInstance(Event(self.fixtureResumePumpAlarm2), eventtypes.LidAlarmActivated)
self.assertNotIsInstance(Event(self.fixtureResumePumpAlarm), RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureResumePumpAlarm)
self.assertEqual(ev.eventId, 5)
self.assertEqual(ev.seqNum, 398724)
ev2 = Event(self.fixtureResumePumpAlarm2)
self.assertEqual(ev2.eventId, 5)
self.assertEqual(ev2.seqNum, 398725)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureResumePumpAlarm)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-05-01T17:08:10")
def test_alarmid_resolves_resume_pump_alarm(self):
# alarmId:18 -> RESUME_PUMP_ALARM
ev = Event(self.fixtureResumePumpAlarm)
self.assertEqual(ev.alarmIdRaw, 18)
self.assertEqual(ev.alarmId, eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm)
def test_alarmid_resolves_resume_pump_alarm2(self):
# alarmId:23 -> RESUME_PUMP_ALARM2
ev = Event(self.fixtureResumePumpAlarm2)
self.assertEqual(ev.alarmIdRaw, 23)
self.assertEqual(ev.alarmId, eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm2)
def test_plain_fields(self):
ev = Event(self.fixtureResumePumpAlarm)
self.assertEqual(ev.faultLocatorData, 8311)
self.assertEqual(ev.param1, 3993668)
self.assertEqual(ev.param2, 0)
ev2 = Event(self.fixtureResumePumpAlarm2)
self.assertEqual(ev2.faultLocatorData, 8311)
self.assertEqual(ev2.param1, 18)
self.assertEqual(ev2.param2, 0)
def test_todict_is_json_serializable(self):
for f in (self.fixtureResumePumpAlarm, self.fixtureResumePumpAlarm2):
ev = Event(f)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 5)
self.assertEqual(d["name"], "LID_ALARM_ACTIVATED")
self.assertEqual(Event(self.fixtureResumePumpAlarm).todict(), {
"id": 5,
"name": "LID_ALARM_ACTIVATED",
"seqNum": 398724,
"eventTimestamp": "2026-05-01T17:08:10-04:00",
"alarmIdRaw": 18,
"faultLocatorData": 8311,
"param1": 3993668,
"param2": 0,
})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,86 @@
#!/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 TestLidAlarmCleared(unittest.TestCase):
"""28: LID_ALARM_CLEARED. Real captured pump-log events; alarmId is a
dictionary transform resolving to an AlarmidEnum member."""
maxDiff = None
def setUp(self):
# Real capture: alarmId 18 -> RESUME_PUMP_ALARM.
self.fixtureResumePumpAlarm = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 28,
"sequenceGroup": 0,
"sequenceNumber": 398734,
"pumpDateTime": "2026-05-01T17:11:22",
"eventProperties": {"alarmId": 18},
"estimatedDateTime": "2026-05-01T17:11:22Z",
}
# Real capture: alarmId 23 -> RESUME_PUMP_ALARM2.
self.fixtureResumePumpAlarm2 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 28,
"sequenceGroup": 0,
"sequenceNumber": 398733,
"pumpDateTime": "2026-05-01T17:11:22",
"eventProperties": {"alarmId": 23},
"estimatedDateTime": "2026-05-01T17:11:22Z",
}
def test_dispatches_to_correct_class(self):
self.assertIsInstance(Event(self.fixtureResumePumpAlarm), eventtypes.LidAlarmCleared)
self.assertIsInstance(Event(self.fixtureResumePumpAlarm2), eventtypes.LidAlarmCleared)
self.assertNotIsInstance(Event(self.fixtureResumePumpAlarm), RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureResumePumpAlarm)
self.assertEqual(ev.eventId, 28)
self.assertEqual(ev.seqNum, 398734)
ev2 = Event(self.fixtureResumePumpAlarm2)
self.assertEqual(ev2.eventId, 28)
self.assertEqual(ev2.seqNum, 398733)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureResumePumpAlarm)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-05-01T17:11:22")
def test_alarmid_resolves_resume_pump_alarm(self):
# alarmId:18 -> RESUME_PUMP_ALARM
ev = Event(self.fixtureResumePumpAlarm)
self.assertEqual(ev.alarmIdRaw, 18)
self.assertEqual(ev.alarmId, eventtypes.LidAlarmCleared.AlarmidEnum.ResumePumpAlarm)
def test_alarmid_resolves_resume_pump_alarm2(self):
# alarmId:23 -> RESUME_PUMP_ALARM2
ev = Event(self.fixtureResumePumpAlarm2)
self.assertEqual(ev.alarmIdRaw, 23)
self.assertEqual(ev.alarmId, eventtypes.LidAlarmCleared.AlarmidEnum.ResumePumpAlarm2)
def test_todict_is_json_serializable(self):
for f in (self.fixtureResumePumpAlarm, self.fixtureResumePumpAlarm2):
ev = Event(f)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 28)
self.assertEqual(d["name"], "LID_ALARM_CLEARED")
self.assertEqual(Event(self.fixtureResumePumpAlarm).todict(), {
"id": 28,
"name": "LID_ALARM_CLEARED",
"seqNum": 398734,
"eventTimestamp": "2026-05-01T17:11:22-04:00",
"alarmIdRaw": 18,
})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,155 @@
#!/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 TestLidAlertActivated(unittest.TestCase):
"""4: LID_ALERT_ACTIVATED. alertid is a dictionary/enum field resolved
through alertidRaw; faultlocatordata/param1/param2 are plain numeric fields.
All fixtures are real captured pump-log events copied verbatim."""
maxDiff = None
def setUp(self):
# alertId:50 -> DefaultAlert50; integer param2, zero fault/param1.
self.fixtureDefaultAlert50 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 4,
"sequenceGroup": 0,
"sequenceNumber": 395022,
"pumpDateTime": "2026-04-30T14:26:30",
"eventProperties": {"alertId": 50, "faultLocatorData": 0, "param1": 0, "param2": 866},
"estimatedDateTime": "2026-04-30T14:26:30Z",
}
# alertId:51 -> ControlIqLow.
self.fixtureControlIqLow = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 4,
"sequenceGroup": 0,
"sequenceNumber": 396263,
"pumpDateTime": "2026-04-30T23:07:17",
"eventProperties": {"alertId": 51, "faultLocatorData": 0, "param1": 0, "param2": 877},
"estimatedDateTime": "2026-04-30T23:07:17Z",
}
# alertId:0 -> LowInsulinAlert (zero must resolve, not read as missing);
# non-zero faultLocatorData and a fractional float param2.
self.fixtureLowInsulinFloat = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 4,
"sequenceGroup": 0,
"sequenceNumber": 398333,
"pumpDateTime": "2026-05-01T15:57:06",
"eventProperties": {"alertId": 0, "faultLocatorData": 8242, "param1": 102, "param2": 249.76517},
"estimatedDateTime": "2026-05-01T15:57:06Z",
}
# alertId:14 -> IncompleteFillTubingAlert; all-zero params.
self.fixtureIncompleteFillTubing = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 4,
"sequenceGroup": 0,
"sequenceNumber": 420528,
"pumpDateTime": "2026-05-08T00:52:18",
"eventProperties": {"alertId": 14, "faultLocatorData": 8378, "param1": 0, "param2": 0},
"estimatedDateTime": "2026-05-08T00:52:18Z",
}
# alertId:2 -> LowPowerAlert.
self.fixtureLowPower = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 4,
"sequenceGroup": 0,
"sequenceNumber": 436691,
"pumpDateTime": "2026-05-12T17:52:10",
"eventProperties": {"alertId": 2, "faultLocatorData": 8306, "param1": 20, "param2": 1},
"estimatedDateTime": "2026-05-12T17:52:10Z",
}
def test_dispatches_to_correct_class(self):
for f in (self.fixtureDefaultAlert50, self.fixtureControlIqLow,
self.fixtureLowInsulinFloat, self.fixtureIncompleteFillTubing,
self.fixtureLowPower):
ev = Event(f)
self.assertIsInstance(ev, eventtypes.LidAlertActivated)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureDefaultAlert50)
self.assertEqual(ev.eventId, 4)
self.assertEqual(ev.seqNum, 395022)
ev = Event(self.fixtureControlIqLow)
self.assertEqual(ev.eventId, 4)
self.assertEqual(ev.seqNum, 396263)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureDefaultAlert50)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T14:26:30")
ev = Event(self.fixtureLowPower)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-12T17:52:10")
def test_plain_fields(self):
ev = Event(self.fixtureLowInsulinFloat)
self.assertEqual(ev.faultLocatorData, 8242)
self.assertEqual(ev.param1, 102)
self.assertAlmostEqual(ev.param2, 249.76517)
ev = Event(self.fixtureIncompleteFillTubing)
self.assertEqual(ev.faultLocatorData, 8378)
self.assertEqual(ev.param1, 0)
self.assertEqual(ev.param2, 0)
def test_alertid_enum_resolves_from_raw_int(self):
ev = Event(self.fixtureDefaultAlert50)
self.assertEqual(ev.alertIdRaw, 50)
self.assertEqual(ev.alertId,
eventtypes.LidAlertActivated.AlertidEnum.DefaultAlert50)
ev = Event(self.fixtureControlIqLow)
self.assertEqual(ev.alertIdRaw, 51)
self.assertEqual(ev.alertId,
eventtypes.LidAlertActivated.AlertidEnum.ControlIqLow)
ev = Event(self.fixtureIncompleteFillTubing)
self.assertEqual(ev.alertIdRaw, 14)
self.assertEqual(ev.alertId,
eventtypes.LidAlertActivated.AlertidEnum.IncompleteFillTubingAlert)
ev = Event(self.fixtureLowPower)
self.assertEqual(ev.alertIdRaw, 2)
self.assertEqual(ev.alertId,
eventtypes.LidAlertActivated.AlertidEnum.LowPowerAlert)
def test_alertid_zero_value_resolves(self):
# alertId:0 -> LowInsulinAlert (0 must not be treated as missing).
ev = Event(self.fixtureLowInsulinFloat)
self.assertEqual(ev.alertIdRaw, 0)
self.assertEqual(ev.alertId,
eventtypes.LidAlertActivated.AlertidEnum.LowInsulinAlert)
def test_todict_is_json_serializable(self):
for f in (self.fixtureDefaultAlert50, self.fixtureLowInsulinFloat,
self.fixtureLowPower):
ev = Event(f)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 4)
self.assertEqual(d["name"], "LID_ALERT_ACTIVATED")
def test_todict_round_trips_fields(self):
ev = Event(self.fixtureLowInsulinFloat)
d = ev.todict()
self.assertEqual(d["seqNum"], 398333)
self.assertEqual(d["alertIdRaw"], 0)
self.assertEqual(d["faultLocatorData"], 8242)
self.assertEqual(d["param1"], 102)
self.assertAlmostEqual(d["param2"], 249.76517)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,113 @@
#!/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
# Real LID_ALERT_CLEARED (eventCode 26) events copied verbatim from a captured
# pump-log response. Each has a different alertId (dictionary enum) value.
class TestLidAlertCleared(unittest.TestCase):
maxDiff = None
def setUp(self):
# alertId 0 -> LowInsulinAlert (0 must not be treated as missing)
self.fixtureLowInsulin = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 26,
"sequenceGroup": 0,
"sequenceNumber": 398601,
"pumpDateTime": "2026-05-01T16:49:45",
"eventProperties": {"alertId": 0, "faultLocatorData": 0},
"estimatedDateTime": "2026-05-01T16:49:45Z",
}
# alertId 2 -> LowPowerAlert
self.fixtureLowPower = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 26,
"sequenceGroup": 0,
"sequenceNumber": 439055,
"pumpDateTime": "2026-05-13T10:06:58",
"eventProperties": {"alertId": 2, "faultLocatorData": 0},
"estimatedDateTime": "2026-05-13T10:06:58Z",
}
# alertId 14 -> IncompleteFillTubingAlert
self.fixtureIncompleteFillTubing = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 26,
"sequenceGroup": 0,
"sequenceNumber": 420534,
"pumpDateTime": "2026-05-08T00:52:50",
"eventProperties": {"alertId": 14, "faultLocatorData": 0},
"estimatedDateTime": "2026-05-08T00:52:50Z",
}
# alertId 51 -> ControlIqLow
self.fixtureControlIqLow = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 26,
"sequenceGroup": 0,
"sequenceNumber": 396276,
"pumpDateTime": "2026-04-30T23:12:18",
"eventProperties": {"alertId": 51, "faultLocatorData": 0},
"estimatedDateTime": "2026-04-30T23:12:18Z",
}
def test_dispatches_to_lidalertcleared(self):
for fx in (self.fixtureLowInsulin, self.fixtureLowPower,
self.fixtureIncompleteFillTubing, self.fixtureControlIqLow):
ev = Event(fx)
self.assertIsInstance(ev, eventtypes.LidAlertCleared)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureControlIqLow)
self.assertEqual(ev.eventId, 26)
self.assertEqual(ev.seqNum, 396276)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureControlIqLow)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T23:12:18")
def test_plain_field_round_trips(self):
ev = Event(self.fixtureControlIqLow)
self.assertEqual(ev.faultLocatorData, 0)
def test_alertid_raw_round_trips(self):
self.assertEqual(Event(self.fixtureLowInsulin).alertIdRaw, 0)
self.assertEqual(Event(self.fixtureLowPower).alertIdRaw, 2)
self.assertEqual(Event(self.fixtureIncompleteFillTubing).alertIdRaw, 14)
self.assertEqual(Event(self.fixtureControlIqLow).alertIdRaw, 51)
def test_alertid_resolves_to_enum(self):
E = eventtypes.LidAlertCleared.AlertidEnum
self.assertEqual(Event(self.fixtureLowInsulin).alertId, E.LowInsulinAlert)
self.assertEqual(Event(self.fixtureLowPower).alertId, E.LowPowerAlert)
self.assertEqual(Event(self.fixtureIncompleteFillTubing).alertId,
E.IncompleteFillTubingAlert)
self.assertEqual(Event(self.fixtureControlIqLow).alertId, E.ControlIqLow)
def test_alertid_zero_resolves(self):
# alertId 0 must resolve, not be dropped as a falsy/missing value.
ev = Event(self.fixtureLowInsulin)
self.assertEqual(ev.alertIdRaw, 0)
self.assertEqual(ev.alertId,
eventtypes.LidAlertCleared.AlertidEnum.LowInsulinAlert)
def test_todict_is_json_serializable(self):
ev = Event(self.fixtureControlIqLow)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 26)
self.assertEqual(d["name"], "LID_ALERT_CLEARED")
self.assertEqual(d["seqNum"], 396276)
self.assertEqual(d["alertIdRaw"], 51)
self.assertEqual(d["faultLocatorData"], 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import json
import unittest
from tconnectsync.eventparser.generic import Event
from tconnectsync.eventparser import events as eventtypes
class TestLidBasalDelivery(unittest.TestCase):
"""279 LID_BASAL_DELIVERY: commandedRateSource enum + milliunits/hr rates.
Fixtures are real captured pump-log dicts (copied verbatim), each with a
different commandedRateSource so every enum member is exercised. reservedA2
and spareA3 are ignored by the parser and not asserted on.
"""
maxDiff = None
def setUp(self):
# commandedRateSource:0 -> Suspended; commandedRate 0, algorithmRate/tempRate sentinel.
self.fixtureSuspended = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 279,
"sequenceGroup": 0,
"sequenceNumber": 394356,
"pumpDateTime": "2026-04-30T10:04:05",
"eventProperties": {
"commandedRateSource": 0, "reservedA2": 3, "spareA3": 0,
"commandedRate": 0, "profileBasalRate": 1200,
"algorithmRate": 65535, "tempRate": 65535,
},
"estimatedDateTime": "2026-04-30T10:04:05Z",
}
# commandedRateSource:1 -> Profile; commandedRate follows profileBasalRate.
self.fixtureProfile = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 279,
"sequenceGroup": 0,
"sequenceNumber": 409132,
"pumpDateTime": "2026-05-04T18:58:52",
"eventProperties": {
"commandedRateSource": 1, "reservedA2": 3, "spareA3": 0,
"commandedRate": 1000, "profileBasalRate": 1000,
"algorithmRate": 65535, "tempRate": 65535,
},
"estimatedDateTime": "2026-05-04T18:58:52Z",
}
# commandedRateSource:2 -> TempRate; real tempRate=500 (not the 65535 sentinel).
self.fixtureTempRate = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 279,
"sequenceGroup": 0,
"sequenceNumber": 449599,
"pumpDateTime": "2026-05-16T11:16:07",
"eventProperties": {
"commandedRateSource": 2, "reservedA2": 0, "spareA3": 0,
"commandedRate": 500, "profileBasalRate": 1000,
"algorithmRate": 65535, "tempRate": 500,
},
"estimatedDateTime": "2026-05-16T11:16:07Z",
}
# commandedRateSource:3 -> Algorithm; commandedRate follows algorithmRate, tempRate sentinel.
self.fixtureAlgorithm = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 279,
"sequenceGroup": 0,
"sequenceNumber": 393151,
"pumpDateTime": "2026-04-30T00:08:30",
"eventProperties": {
"commandedRateSource": 3, "reservedA2": 3, "spareA3": 0,
"commandedRate": 1061, "profileBasalRate": 1000,
"algorithmRate": 1061, "tempRate": 65535,
},
"estimatedDateTime": "2026-04-30T00:08:30Z",
}
# commandedRateSource:4 -> TempRateAndAlgorithm; both algorithmRate and real tempRate=600 present.
self.fixtureTempRateAndAlgorithm = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 279,
"sequenceGroup": 0,
"sequenceNumber": 449571,
"pumpDateTime": "2026-05-16T11:01:05",
"eventProperties": {
"commandedRateSource": 4, "reservedA2": 3, "spareA3": 0,
"commandedRate": 500, "profileBasalRate": 1000,
"algorithmRate": 500, "tempRate": 500,
},
"estimatedDateTime": "2026-05-16T11:01:05Z",
}
def test_dispatches_to_correct_class(self):
for fixture in (self.fixtureSuspended, self.fixtureProfile,
self.fixtureTempRate, self.fixtureAlgorithm,
self.fixtureTempRateAndAlgorithm):
ev = Event(fixture)
self.assertIsInstance(ev, eventtypes.LidBasalDelivery)
def test_envelope_fields(self):
ev = Event(self.fixtureAlgorithm)
self.assertEqual(ev.eventId, 279)
self.assertEqual(ev.seqNum, 393151)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T00:08:30")
def test_rate_fields_round_trip(self):
ev = Event(self.fixtureAlgorithm)
self.assertEqual(ev.commandedRate, 1061)
self.assertEqual(ev.profileBasalRate, 1000)
self.assertEqual(ev.algorithmRate, 1061)
self.assertEqual(ev.tempRate, 65535)
def test_temp_rate_sentinel_vs_real(self):
# Algorithm capture uses the 65535 sentinel; TempRate capture has a real value.
self.assertEqual(Event(self.fixtureAlgorithm).tempRate, 65535)
self.assertEqual(Event(self.fixtureTempRate).tempRate, 500)
self.assertEqual(Event(self.fixtureTempRateAndAlgorithm).tempRate, 500)
def test_commanded_rate_source_suspended(self):
ev = Event(self.fixtureSuspended)
self.assertEqual(ev.commandedRateSourceRaw, 0)
self.assertEqual(ev.commandedRateSource,
eventtypes.LidBasalDelivery.CommandedratesourceEnum.Suspended)
def test_commanded_rate_source_profile(self):
ev = Event(self.fixtureProfile)
self.assertEqual(ev.commandedRateSourceRaw, 1)
self.assertEqual(ev.commandedRateSource,
eventtypes.LidBasalDelivery.CommandedratesourceEnum.Profile)
def test_commanded_rate_source_temp_rate(self):
ev = Event(self.fixtureTempRate)
self.assertEqual(ev.commandedRateSourceRaw, 2)
self.assertEqual(ev.commandedRateSource,
eventtypes.LidBasalDelivery.CommandedratesourceEnum.TempRate)
def test_commanded_rate_source_algorithm(self):
ev = Event(self.fixtureAlgorithm)
self.assertEqual(ev.commandedRateSourceRaw, 3)
self.assertEqual(ev.commandedRateSource,
eventtypes.LidBasalDelivery.CommandedratesourceEnum.Algorithm)
def test_commanded_rate_source_temp_rate_and_algorithm(self):
ev = Event(self.fixtureTempRateAndAlgorithm)
self.assertEqual(ev.commandedRateSourceRaw, 4)
self.assertEqual(ev.commandedRateSource,
eventtypes.LidBasalDelivery.CommandedratesourceEnum.TempRateAndAlgorithm)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureSuspended, self.fixtureProfile,
self.fixtureTempRate, self.fixtureAlgorithm,
self.fixtureTempRateAndAlgorithm):
d = Event(fixture).todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 279)
self.assertEqual(d["name"], "LID_BASAL_DELIVERY")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,115 @@
#!/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 TestLidBgReadingTaken(unittest.TestCase):
maxDiff = None
def setUp(self):
# Real captured LID_BG_READING_TAKEN (eventCode 16) events, copied
# verbatim. The two fixtures differ only in bgEntryType.
self.fixtureManualEntry = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 16,
"sequenceGroup": 0,
"sequenceNumber": 456822,
"pumpDateTime": "2026-05-18T10:15:14",
"eventProperties": {
"bg": 151, "cgmCalibration": 0, "bgEntryType": 0,
"iob": 1.1809407, "targetBg": 110, "isf": 30,
"selectedIob": 1, "bgSourceType": 1,
},
"estimatedDateTime": "2026-05-18T10:15:14Z",
}
self.fixtureAutoPopulated = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 16,
"sequenceGroup": 0,
"sequenceNumber": 394632,
"pumpDateTime": "2026-04-30T11:57:36",
"eventProperties": {
"bg": 164, "cgmCalibration": 0, "bgEntryType": 1,
"iob": 1.8189592, "targetBg": 110, "isf": 30,
"selectedIob": 1, "bgSourceType": 1,
},
"estimatedDateTime": "2026-04-30T11:57:36Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureManualEntry)
self.assertIsInstance(ev, eventtypes.LidBgReadingTaken)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureManualEntry)
self.assertEqual(ev.eventId, 16)
self.assertEqual(ev.seqNum, 456822)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureManualEntry)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-18T10:15:14")
def test_bg_iob_targetbg_isf_round_trip(self):
ev = Event(self.fixtureManualEntry)
self.assertEqual(ev.bg, 151)
self.assertAlmostEqual(ev.iob, 1.1809407)
self.assertEqual(ev.targetBg, 110)
self.assertEqual(ev.isf, 30)
ev2 = Event(self.fixtureAutoPopulated)
self.assertEqual(ev2.bg, 164)
self.assertAlmostEqual(ev2.iob, 1.8189592)
self.assertEqual(ev2.targetBg, 110)
self.assertEqual(ev2.isf, 30)
def test_selectediob_enum_resolves(self):
# selectedIob:1 -> SwanIobMeal
ev = Event(self.fixtureManualEntry)
self.assertEqual(ev.selectedIobRaw, 1)
self.assertEqual(ev.selectedIob,
eventtypes.LidBgReadingTaken.SelectediobEnum.SwanIobMeal)
def test_bgentrytype_enum_resolves(self):
# bgEntryType:0 -> ManualEntryByTheUserViaNumpad (0 not treated as missing)
ev = Event(self.fixtureManualEntry)
self.assertEqual(ev.bgEntryTypeRaw, 0)
self.assertEqual(
ev.bgEntryType,
eventtypes.LidBgReadingTaken.BgentrytypeEnum.ManualEntryByTheUserViaNumpad)
# bgEntryType:1 -> AutoPopulatedBgUsingDexcomEgv
ev2 = Event(self.fixtureAutoPopulated)
self.assertEqual(ev2.bgEntryTypeRaw, 1)
self.assertEqual(
ev2.bgEntryType,
eventtypes.LidBgReadingTaken.BgentrytypeEnum.AutoPopulatedBgUsingDexcomEgv)
def test_bgsourcetype_enum_resolves(self):
# bgSourceType:1 -> RemoteEntry
ev = Event(self.fixtureManualEntry)
self.assertEqual(ev.bgSourceTypeRaw, 1)
self.assertEqual(ev.bgSourceType,
eventtypes.LidBgReadingTaken.BgsourcetypeEnum.RemoteEntry)
def test_cgmcalibration_enum_resolves(self):
# cgmCalibration:0 -> No (0 not treated as missing)
ev = Event(self.fixtureManualEntry)
self.assertEqual(ev.cgmCalibrationRaw, 0)
self.assertEqual(ev.cgmCalibration,
eventtypes.LidBgReadingTaken.CgmcalibrationEnum.No)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureManualEntry, self.fixtureAutoPopulated):
ev = Event(fixture)
json.dumps(ev.todict())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,110 @@
#!/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 TestLidBolusActivated(unittest.TestCase):
"""55 LID_BOLUS_ACTIVATED: real captured pump-log events."""
maxDiff = None
def setUp(self):
# Real captured events (verbatim). All observed captures have
# selectedIob=1 (Swan IOB Meal); fixtures differ by bolusSize/iob.
self.fixtureMeal = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 55,
"sequenceGroup": 0,
"sequenceNumber": 394650,
"pumpDateTime": "2026-04-30T11:57:53",
"eventProperties": {
"bolusId": 1423, "selectedIob": 1, "spareA3": 0,
"iob": 1.8189592, "bolusSize": 8.33,
},
"estimatedDateTime": "2026-04-30T11:57:53Z",
}
self.fixtureZeroIob = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 55,
"sequenceGroup": 0,
"sequenceNumber": 395970,
"pumpDateTime": "2026-04-30T21:38:00",
"eventProperties": {
"bolusId": 1426, "selectedIob": 1, "spareA3": 0,
"iob": 0, "bolusSize": 10.96,
},
"estimatedDateTime": "2026-04-30T21:38:00Z",
}
self.fixtureSmall = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 55,
"sequenceGroup": 0,
"sequenceNumber": 395158,
"pumpDateTime": "2026-04-30T15:13:14",
"eventProperties": {
"bolusId": 1425, "selectedIob": 1, "spareA3": 0,
"iob": 4.116488, "bolusSize": 2,
},
"estimatedDateTime": "2026-04-30T15:13:14Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureMeal)
self.assertIsInstance(ev, eventtypes.LidBolusActivated)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureMeal)
self.assertEqual(ev.eventId, 55)
self.assertEqual(ev.seqNum, 394650)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureMeal)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T11:57:53")
def test_bolus_fields_round_trip(self):
ev = Event(self.fixtureMeal)
self.assertEqual(ev.bolusId, 1423)
self.assertAlmostEqual(ev.iob, 1.8189592)
self.assertAlmostEqual(ev.bolusSize, 8.33)
def test_zero_iob_is_preserved(self):
# iob:0 must not be dropped as missing.
ev = Event(self.fixtureZeroIob)
self.assertEqual(ev.bolusId, 1426)
self.assertEqual(ev.iob, 0)
self.assertAlmostEqual(ev.bolusSize, 10.96)
def test_small_bolus_round_trips(self):
ev = Event(self.fixtureSmall)
self.assertEqual(ev.bolusId, 1425)
self.assertAlmostEqual(ev.iob, 4.116488)
self.assertEqual(ev.bolusSize, 2)
def test_selectediob_resolves_to_enum(self):
# selectedIob:1 -> Swan IOB Meal
ev = Event(self.fixtureMeal)
self.assertEqual(ev.selectedIobRaw, 1)
self.assertEqual(ev.selectedIob,
eventtypes.LidBolusActivated.SelectediobEnum.SwanIobMeal)
def test_spareA3_is_ignored(self):
ev = Event(self.fixtureMeal)
self.assertFalse(hasattr(ev, "spareA3"))
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureMeal, self.fixtureZeroIob, self.fixtureSmall):
ev = Event(fixture)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 55)
self.assertEqual(d["name"], "LID_BOLUS_ACTIVATED")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,93 @@
#!/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 TestLidBolusCompleted(unittest.TestCase):
maxDiff = None
def setUp(self):
# Real captured LID_BOLUS_COMPLETED (eventCode 20) events, verbatim.
# completionStatus 3 -> Completed, insulinDelivered == insulinRequested.
self.fixtureCompleted = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 20,
"sequenceGroup": 0,
"sequenceNumber": 394675,
"pumpDateTime": "2026-04-30T12:01:53",
"eventProperties": {
"completionStatus": 3, "bolusId": 1423, "iob": 10.088287,
"insulinDelivered": 8.33, "insulinRequested": 8.33,
},
"estimatedDateTime": "2026-04-30T12:01:53Z",
}
# completionStatus 0 -> UserAborted, an interrupted bolus where
# insulinDelivered (0.04657) is far below insulinRequested (0.5).
self.fixtureInterrupted = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 20,
"sequenceGroup": 0,
"sequenceNumber": 456849,
"pumpDateTime": "2026-05-18T10:15:39",
"eventProperties": {
"completionStatus": 0, "bolusId": 1644, "iob": 1.2275107,
"insulinDelivered": 0.04657, "insulinRequested": 0.5,
},
"estimatedDateTime": "2026-05-18T10:15:39Z",
}
def test_dispatches_to_lidboluscompleted(self):
self.assertIsInstance(Event(self.fixtureCompleted), eventtypes.LidBolusCompleted)
self.assertIsInstance(Event(self.fixtureInterrupted), eventtypes.LidBolusCompleted)
self.assertNotIsInstance(Event(self.fixtureCompleted), RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureCompleted)
self.assertEqual(ev.eventId, 20)
self.assertEqual(ev.seqNum, 394675)
# eventTimestamp keeps pumpDateTime's wall-clock.
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-04-30T12:01:53")
def test_completed_fields_round_trip(self):
ev = Event(self.fixtureCompleted)
self.assertEqual(ev.bolusId, 1423)
self.assertAlmostEqual(ev.insulinDelivered, 8.33)
self.assertAlmostEqual(ev.insulinRequested, 8.33)
self.assertAlmostEqual(ev.iob, 10.088287)
def test_interrupted_fields_round_trip(self):
ev = Event(self.fixtureInterrupted)
self.assertEqual(ev.bolusId, 1644)
self.assertAlmostEqual(ev.insulinDelivered, 0.04657)
self.assertAlmostEqual(ev.insulinRequested, 0.5)
self.assertAlmostEqual(ev.iob, 1.2275107)
# Interrupted: less insulin delivered than requested.
self.assertLess(ev.insulinDelivered, ev.insulinRequested)
def test_completionstatus_resolves_to_enum(self):
completed = Event(self.fixtureCompleted)
self.assertEqual(completed.completionStatusRaw, 3)
self.assertEqual(completed.completionStatus,
eventtypes.LidBolusCompleted.CompletionstatusEnum.Completed)
interrupted = Event(self.fixtureInterrupted)
self.assertEqual(interrupted.completionStatusRaw, 0)
self.assertEqual(interrupted.completionStatus,
eventtypes.LidBolusCompleted.CompletionstatusEnum.UserAborted)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureCompleted, self.fixtureInterrupted):
ev = Event(fixture)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 20)
self.assertEqual(d["name"], "LID_BOLUS_COMPLETED")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,133 @@
#!/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 TestLidBolusDelivery(unittest.TestCase):
"""280: LID_BOLUS_DELIVERY. All fixtures are real captured pump-log events."""
maxDiff = None
def setUp(self):
# Manual pump-button bolus, started: bolusType [0] (Now),
# bolusSource 0 (PumpButton), bolusDeliveryStatus 1 (BolusStarted).
self.fixturePumpButtonStarted = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 280,
"sequenceGroup": 0,
"sequenceNumber": 395159,
"pumpDateTime": "2026-04-30T15:13:14",
"eventProperties": {
"bolusId": 1425, "bolusDeliveryStatus": 1, "bolusType": [0],
"bolusSource": 0, "remoteId": 145, "requestedNow": 2000,
"requestedLater": 0, "correction": 0,
"extendedDurationRequested": 0, "deliveredTotal": 0,
},
"estimatedDateTime": "2026-04-30T15:13:14Z",
}
# Carb+correction BLE bolus, started: bolusType [0,3,4]
# (Now|Correction|Carb), bolusSource 8 (Ble), status 1 (BolusStarted).
self.fixtureCarbCorrectionStarted = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 280,
"sequenceGroup": 0,
"sequenceNumber": 395971,
"pumpDateTime": "2026-04-30T21:38:00",
"eventProperties": {
"bolusId": 1426, "bolusDeliveryStatus": 1, "bolusType": [0, 3, 4],
"bolusSource": 8, "remoteId": 146, "requestedNow": 10960,
"requestedLater": 0, "correction": 130,
"extendedDurationRequested": 0, "deliveredTotal": 0,
},
"estimatedDateTime": "2026-04-30T21:38:00Z",
}
# Completion of the same bolus: status 0 (BolusCompleted),
# deliveredTotal now populated (10960).
self.fixtureCarbCorrectionCompleted = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 280,
"sequenceGroup": 0,
"sequenceNumber": 395991,
"pumpDateTime": "2026-04-30T21:40:03",
"eventProperties": {
"bolusId": 1426, "bolusDeliveryStatus": 0, "bolusType": [0, 3, 4],
"bolusSource": 8, "remoteId": 146, "requestedNow": 10960,
"requestedLater": 0, "correction": 130,
"extendedDurationRequested": 0, "deliveredTotal": 10960,
},
"estimatedDateTime": "2026-04-30T21:40:03Z",
}
def test_dispatches_to_correct_class(self):
self.assertIsInstance(Event(self.fixturePumpButtonStarted),
eventtypes.LidBolusDelivery)
self.assertNotIsInstance(Event(self.fixturePumpButtonStarted), RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureCarbCorrectionStarted)
self.assertEqual(ev.eventId, 280)
self.assertEqual(ev.seqNum, 395971)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T21:38:00")
def test_plain_fields_round_trip(self):
ev = Event(self.fixtureCarbCorrectionStarted)
self.assertEqual(ev.bolusId, 1426)
self.assertEqual(ev.requestedNow, 10960)
self.assertEqual(ev.deliveredTotal, 0)
self.assertEqual(ev.correction, 130)
self.assertEqual(ev.remoteId, 146)
self.assertEqual(ev.requestedLater, 0)
self.assertEqual(ev.extendedDurationRequested, 0)
def test_completion_carries_delivered_total(self):
ev = Event(self.fixtureCarbCorrectionCompleted)
self.assertEqual(ev.bolusId, 1426)
self.assertEqual(ev.deliveredTotal, 10960)
def test_bolustype_single_bit_folds_and_resolves(self):
# bolusType [0] -> 1<<0 == 1 -> Now
ev = Event(self.fixturePumpButtonStarted)
self.assertEqual(ev.bolusTypeRaw, 1)
self.assertEqual(ev.bolusType, eventtypes.LidBolusDelivery.BolustypeBitmask.Now)
def test_bolustype_multi_bit_folds_and_resolves(self):
# bolusType [0,3,4] -> 1<<0 | 1<<3 | 1<<4 == 25 -> Now|Correction|Carb
ev = Event(self.fixtureCarbCorrectionStarted)
self.assertEqual(ev.bolusTypeRaw, sum(1 << i for i in [0, 3, 4]))
self.assertEqual(ev.bolusTypeRaw, 25)
bt = eventtypes.LidBolusDelivery.BolustypeBitmask
self.assertEqual(ev.bolusType, bt.Now | bt.Correction | bt.Carb)
def test_bolussource_resolves(self):
self.assertEqual(
Event(self.fixturePumpButtonStarted).bolusSource,
eventtypes.LidBolusDelivery.BolussourceEnum.PumpButton)
self.assertEqual(
Event(self.fixtureCarbCorrectionStarted).bolusSource,
eventtypes.LidBolusDelivery.BolussourceEnum.Ble)
def test_bolusdeliverystatus_resolves(self):
self.assertEqual(
Event(self.fixtureCarbCorrectionStarted).bolusDeliveryStatus,
eventtypes.LidBolusDelivery.BolusdeliverystatusEnum.BolusStarted)
self.assertEqual(
Event(self.fixtureCarbCorrectionCompleted).bolusDeliveryStatus,
eventtypes.LidBolusDelivery.BolusdeliverystatusEnum.BolusCompleted)
def test_todict_is_json_serializable(self):
for fixture in (self.fixturePumpButtonStarted,
self.fixtureCarbCorrectionStarted,
self.fixtureCarbCorrectionCompleted):
ev = Event(fixture)
json.dumps(ev.todict()) # must not raise
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,125 @@
#!/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 TestLidBolusRequestedMsg1(unittest.TestCase):
"""64: LID_BOLUS_REQUESTED_MSG1. Fixtures are real captured pump-log events
copied verbatim from a captured account response."""
maxDiff = None
def setUp(self):
# bolusType 3 (Remote), correctionBolusIncluded 0 (No), carbs present.
self.fixtureRemoteWithCarbs = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 64, "sequenceGroup": 0, "sequenceNumber": 394641,
"pumpDateTime": "2026-04-30T11:57:38",
"eventProperties": {
"bolusId": 1423, "bolusType": 3, "correctionBolusIncluded": 0,
"carbAmount": 50, "bg": 164, "iob": 1.82, "carbRatio": 0,
},
"estimatedDateTime": "2026-04-30T11:57:38Z",
}
# bolusType 3 (Remote), correctionBolusIncluded 1 (Yes), iob 0.
self.fixtureRemoteWithCorrection = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 64, "sequenceGroup": 0, "sequenceNumber": 395959,
"pumpDateTime": "2026-04-30T21:37:45",
"eventProperties": {
"bolusId": 1426, "bolusType": 3, "correctionBolusIncluded": 1,
"carbAmount": 65, "bg": 114, "iob": 0, "carbRatio": 0,
},
"estimatedDateTime": "2026-04-30T21:37:45Z",
}
# bolusType 0 (Insulin), no carbs, bg 0, fractional iob.
self.fixtureInsulinNoCarbs = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 64, "sequenceGroup": 0, "sequenceNumber": 395146,
"pumpDateTime": "2026-04-30T15:12:59",
"eventProperties": {
"bolusId": 1425, "bolusType": 0, "correctionBolusIncluded": 0,
"carbAmount": 0, "bg": 0, "iob": 4.116488, "carbRatio": 0,
},
"estimatedDateTime": "2026-04-30T15:12:59Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureRemoteWithCarbs)
self.assertIsInstance(ev, eventtypes.LidBolusRequestedMsg1)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureRemoteWithCarbs)
self.assertEqual(ev.eventId, 64)
self.assertEqual(ev.seqNum, 394641)
# eventTimestamp keeps pumpDateTime's wall-clock.
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T11:57:38")
def test_plain_fields_round_trip(self):
ev = Event(self.fixtureRemoteWithCarbs)
self.assertEqual(ev.bolusId, 1423)
self.assertEqual(ev.carbAmount, 50)
self.assertEqual(ev.bg, 164)
self.assertEqual(ev.iob, 1.82)
def test_fractional_iob_and_zero_bg(self):
ev = Event(self.fixtureInsulinNoCarbs)
self.assertEqual(ev.bolusId, 1425)
self.assertEqual(ev.carbAmount, 0)
self.assertEqual(ev.bg, 0)
self.assertAlmostEqual(ev.iob, 4.116488)
def test_bolustype_remote(self):
ev = Event(self.fixtureRemoteWithCarbs)
self.assertEqual(ev.bolusTypeRaw, 3)
self.assertEqual(ev.bolusType,
eventtypes.LidBolusRequestedMsg1.BolustypeEnum.Remote)
def test_bolustype_insulin(self):
# bolusType 0 must resolve (0 not treated as missing).
ev = Event(self.fixtureInsulinNoCarbs)
self.assertEqual(ev.bolusTypeRaw, 0)
self.assertEqual(ev.bolusType,
eventtypes.LidBolusRequestedMsg1.BolustypeEnum.Insulin)
def test_correctionbolusincluded_no(self):
ev = Event(self.fixtureRemoteWithCarbs)
self.assertEqual(ev.correctionBolusIncludedRaw, 0)
self.assertEqual(
ev.correctionBolusIncluded,
eventtypes.LidBolusRequestedMsg1.CorrectionbolusincludedEnum.No)
def test_correctionbolusincluded_yes(self):
ev = Event(self.fixtureRemoteWithCorrection)
self.assertEqual(ev.correctionBolusIncludedRaw, 1)
self.assertEqual(
ev.correctionBolusIncluded,
eventtypes.LidBolusRequestedMsg1.CorrectionbolusincludedEnum.Yes)
def test_carbratio_scales(self):
# carbratio is carbratioRaw * 0.001; real captures carry 0.
ev = Event(self.fixtureRemoteWithCorrection)
self.assertEqual(ev.carbRatioRaw, 0)
self.assertAlmostEqual(ev.carbRatio, 0.0)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureRemoteWithCarbs,
self.fixtureRemoteWithCorrection,
self.fixtureInsulinNoCarbs):
ev = Event(fixture)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 64)
self.assertEqual(d["name"], "LID_BOLUS_REQUESTED_MSG1")
self.assertEqual(d["bolusId"],
fixture["eventProperties"]["bolusId"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,125 @@
#!/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 TestLidBolusRequestedMsg2(unittest.TestCase):
"""65: LID_BOLUS_REQUESTED_MSG2 — real captured pump-log events."""
maxDiff = None
def setUp(self):
# BLE standard bolus, user did NOT override the bolus size.
self.fixtureBleStandard = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 65,
"sequenceGroup": 0,
"sequenceNumber": 394642,
"pumpDateTime": "2026-04-30T11:57:38",
"eventProperties": {
"bolusId": 1423, "options": 4, "standardPercent": 100,
"duration": 0, "spareB6": 0, "isf": 0, "targetBg": 0,
"userOverride": 0, "declinedCorrection": 0, "selectedIob": 1,
},
"estimatedDateTime": "2026-04-30T11:57:38Z",
}
# BLE standard bolus, user DID override the bolus size.
self.fixtureUserOverride = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 65,
"sequenceGroup": 0,
"sequenceNumber": 394840,
"pumpDateTime": "2026-04-30T13:13:28",
"eventProperties": {
"bolusId": 1424, "options": 4, "standardPercent": 100,
"duration": 0, "spareB6": 0, "isf": 0, "targetBg": 0,
"userOverride": 1, "declinedCorrection": 0, "selectedIob": 1,
},
"estimatedDateTime": "2026-04-30T13:13:28Z",
}
# Quick bolus (options=2).
self.fixtureQuick = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 65,
"sequenceGroup": 0,
"sequenceNumber": 395147,
"pumpDateTime": "2026-04-30T15:12:59",
"eventProperties": {
"bolusId": 1425, "options": 2, "standardPercent": 100,
"duration": 0, "spareB6": 0, "isf": 0, "targetBg": 0,
"userOverride": 0, "declinedCorrection": 0, "selectedIob": 1,
},
"estimatedDateTime": "2026-04-30T15:12:59Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureBleStandard)
self.assertIsInstance(ev, eventtypes.LidBolusRequestedMsg2)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureBleStandard)
self.assertEqual(ev.eventId, 65)
self.assertEqual(ev.seqNum, 394642)
self.assertEqual(
ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-04-30T11:57:38")
def test_plain_fields_round_trip(self):
ev = Event(self.fixtureBleStandard)
self.assertEqual(ev.bolusId, 1423)
self.assertEqual(ev.standardPercent, 100)
self.assertEqual(ev.duration, 0)
self.assertEqual(ev.isf, 0)
self.assertEqual(ev.targetBg, 0)
def test_options_enum_ble_standard(self):
ev = Event(self.fixtureBleStandard)
self.assertEqual(ev.optionsRaw, 4)
self.assertEqual(ev.options,
eventtypes.LidBolusRequestedMsg2.OptionsEnum.BleStandardBolus)
def test_options_enum_quick(self):
ev = Event(self.fixtureQuick)
self.assertEqual(ev.optionsRaw, 2)
self.assertEqual(ev.options,
eventtypes.LidBolusRequestedMsg2.OptionsEnum.QuickBolus)
def test_selectediob_enum(self):
ev = Event(self.fixtureBleStandard)
self.assertEqual(ev.selectedIobRaw, 1)
self.assertEqual(ev.selectedIob,
eventtypes.LidBolusRequestedMsg2.SelectediobEnum.SwanIobMeal)
def test_useroverride_enum_no(self):
ev = Event(self.fixtureBleStandard)
self.assertEqual(ev.userOverrideRaw, 0)
self.assertEqual(ev.userOverride,
eventtypes.LidBolusRequestedMsg2.UseroverrideEnum.No)
def test_useroverride_enum_yes(self):
ev = Event(self.fixtureUserOverride)
self.assertEqual(ev.userOverrideRaw, 1)
self.assertEqual(ev.userOverride,
eventtypes.LidBolusRequestedMsg2.UseroverrideEnum.Yes)
def test_declinedcorrection_enum(self):
ev = Event(self.fixtureBleStandard)
self.assertEqual(ev.declinedCorrectionRaw, 0)
self.assertEqual(ev.declinedCorrection,
eventtypes.LidBolusRequestedMsg2.DeclinedcorrectionEnum.No)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureBleStandard, self.fixtureUserOverride,
self.fixtureQuick):
ev = Event(fixture)
json.dumps(ev.todict()) # must not raise
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,127 @@
#!/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 TestLidBolusRequestedMsg3(unittest.TestCase):
"""66: LID_BOLUS_REQUESTED_MSG3. Fixtures are real captured pump-log
events copied verbatim (spareA2 is present but ignored by the parser)."""
maxDiff = None
def setUp(self):
# food-only bolus; total carries float rounding (8.330001).
self.fixtureFoodOnly = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 66,
"sequenceGroup": 0,
"sequenceNumber": 394643,
"pumpDateTime": "2026-04-30T11:57:38",
"eventProperties": {
"bolusId": 1423, "spareA2": 0, "foodBolusSize": 8.33,
"correctionBolusSize": 0, "totalBolusSize": 8.330001,
},
"estimatedDateTime": "2026-04-30T11:57:38Z",
}
# food + correction; both components non-zero.
self.fixtureFoodAndCorrection = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 66,
"sequenceGroup": 0,
"sequenceNumber": 395961,
"pumpDateTime": "2026-04-30T21:37:45",
"eventProperties": {
"bolusId": 1426, "spareA2": 0, "foodBolusSize": 10.83,
"correctionBolusSize": 0.13, "totalBolusSize": 10.96,
},
"estimatedDateTime": "2026-04-30T21:37:45Z",
}
# correction-only food component; total exceeds correction.
self.fixtureCorrectionOnly = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 66,
"sequenceGroup": 0,
"sequenceNumber": 398360,
"pumpDateTime": "2026-05-01T16:02:12",
"eventProperties": {
"bolusId": 1430, "spareA2": 0, "foodBolusSize": 0,
"correctionBolusSize": 1.47, "totalBolusSize": 3,
},
"estimatedDateTime": "2026-05-01T16:02:12Z",
}
# both breakdown components zero but a non-zero total.
self.fixtureTotalOnly = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 66,
"sequenceGroup": 0,
"sequenceNumber": 394841,
"pumpDateTime": "2026-04-30T13:13:28",
"eventProperties": {
"bolusId": 1424, "spareA2": 0, "foodBolusSize": 0,
"correctionBolusSize": 0, "totalBolusSize": 4,
},
"estimatedDateTime": "2026-04-30T13:13:28Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureFoodOnly)
self.assertIsInstance(ev, eventtypes.LidBolusRequestedMsg3)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureFoodOnly)
self.assertEqual(ev.eventId, 66)
self.assertEqual(ev.seqNum, 394643)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureFoodOnly)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T11:57:38")
def test_food_only(self):
ev = Event(self.fixtureFoodOnly)
self.assertEqual(ev.bolusId, 1423)
self.assertAlmostEqual(ev.foodBolusSize, 8.33)
self.assertAlmostEqual(ev.correctionBolusSize, 0)
self.assertAlmostEqual(ev.totalBolusSize, 8.330001)
def test_food_and_correction(self):
ev = Event(self.fixtureFoodAndCorrection)
self.assertEqual(ev.bolusId, 1426)
self.assertAlmostEqual(ev.foodBolusSize, 10.83)
self.assertAlmostEqual(ev.correctionBolusSize, 0.13)
self.assertAlmostEqual(ev.totalBolusSize, 10.96)
def test_correction_only(self):
ev = Event(self.fixtureCorrectionOnly)
self.assertEqual(ev.bolusId, 1430)
self.assertAlmostEqual(ev.foodBolusSize, 0)
self.assertAlmostEqual(ev.correctionBolusSize, 1.47)
self.assertAlmostEqual(ev.totalBolusSize, 3)
def test_total_only(self):
ev = Event(self.fixtureTotalOnly)
self.assertEqual(ev.bolusId, 1424)
self.assertAlmostEqual(ev.foodBolusSize, 0)
self.assertAlmostEqual(ev.correctionBolusSize, 0)
self.assertAlmostEqual(ev.totalBolusSize, 4)
def test_todict_json_serializable(self):
for fixture in (self.fixtureFoodOnly, self.fixtureFoodAndCorrection,
self.fixtureCorrectionOnly, self.fixtureTotalOnly):
ev = Event(fixture)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 66)
self.assertEqual(d["name"], "LID_BOLUS_REQUESTED_MSG3")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,67 @@
#!/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 TestLidCannulaFilled(unittest.TestCase):
"""61: LID_CANNULA_FILLED. Fixture is a real captured pump-log event.
Only one distinct eventProperties shape exists in the captures, so a
single fixture covers the observed behavior. The extra infusionSetType
key is not in the schema and must be ignored by the parser."""
maxDiff = None
def setUp(self):
self.fixtureCompleted = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 61,
"sequenceGroup": 0,
"sequenceNumber": 412912,
"pumpDateTime": "2026-05-05T19:16:40",
"eventProperties": {
"primeSize": 0.3, "completionStatus": 3, "infusionSetType": 0,
},
"estimatedDateTime": "2026-05-05T19:16:40Z",
}
def test_dispatches_to_lidcannulafilled(self):
ev = Event(self.fixtureCompleted)
self.assertIsInstance(ev, eventtypes.LidCannulaFilled)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureCompleted)
self.assertEqual(ev.eventId, 61)
self.assertEqual(ev.seqNum, 412912)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-05T19:16:40")
def test_primesize_round_trips(self):
ev = Event(self.fixtureCompleted)
self.assertEqual(ev.primeSize, 0.3)
def test_completionstatus_resolves_to_enum(self):
# completionStatus:3 -> Completed
ev = Event(self.fixtureCompleted)
self.assertEqual(ev.completionStatusRaw, 3)
self.assertEqual(ev.completionStatus,
eventtypes.LidCannulaFilled.CompletionstatusEnum.Completed)
def test_unknown_infusionsettype_key_is_ignored(self):
# infusionSetType is not in the schema; the parser must not raise and
# must not expose an attribute for it.
ev = Event(self.fixtureCompleted) # must not raise
self.assertFalse(hasattr(ev, "infusionSetType"))
self.assertFalse(hasattr(ev, "infusionsettype"))
def test_todict_is_json_serializable(self):
ev = Event(self.fixtureCompleted)
json.dumps(ev.todict()) # must not raise
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,85 @@
#!/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 TestLidCartridgeFilled(unittest.TestCase):
"""33: LID_CARTRIDGE_FILLED. All fixtures are real captured pump-log
events (verbatim). insulinVolume varies; v2Volume is always 0."""
maxDiff = None
def setUp(self):
# Smallest observed insulinVolume.
self.fixtureMin = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 33,
"sequenceGroup": 0,
"sequenceNumber": 418402,
"pumpDateTime": "2026-05-07T10:35:59",
"eventProperties": {"insulinVolume": 60, "v2Volume": 0},
"estimatedDateTime": "2026-05-07T10:35:59Z",
}
# A mid-range fill.
self.fixtureMid = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 33,
"sequenceGroup": 0,
"sequenceNumber": 394427,
"pumpDateTime": "2026-04-30T10:16:09",
"eventProperties": {"insulinVolume": 105, "v2Volume": 0},
"estimatedDateTime": "2026-04-30T10:16:09Z",
}
# Largest observed insulinVolume.
self.fixtureMax = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 33,
"sequenceGroup": 0,
"sequenceNumber": 463083,
"pumpDateTime": "2026-05-20T02:15:04",
"eventProperties": {"insulinVolume": 190, "v2Volume": 0},
"estimatedDateTime": "2026-05-20T02:15:04Z",
}
def test_dispatches_to_lidcartridgefilled(self):
ev = Event(self.fixtureMid)
self.assertIsInstance(ev, eventtypes.LidCartridgeFilled)
self.assertIsNot(type(ev), RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureMid)
self.assertEqual(ev.eventId, 33)
self.assertEqual(ev.seqNum, 394427)
self.assertEqual(
ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-04-30T10:16:09")
def test_insulinvolume_round_trips(self):
self.assertEqual(Event(self.fixtureMin).insulinVolume, 60)
self.assertEqual(Event(self.fixtureMid).insulinVolume, 105)
self.assertEqual(Event(self.fixtureMax).insulinVolume, 190)
def test_v2volume_round_trips(self):
for fixture in (self.fixtureMin, self.fixtureMid, self.fixtureMax):
self.assertEqual(Event(fixture).v2Volume, 0)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureMin, self.fixtureMid, self.fixtureMax):
d = Event(fixture).todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 33)
self.assertEqual(d["name"], "LID_CARTRIDGE_FILLED")
def test_todict_reflects_real_values(self):
d = Event(self.fixtureMax).todict()
self.assertEqual(d["seqNum"], 463083)
self.assertEqual(d["insulinVolume"], 190)
self.assertEqual(d["v2Volume"], 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import json
import unittest
from tconnectsync.eventparser.generic import Event
from tconnectsync.eventparser import events as eventtypes
# Real captured LID_CGM_ALERT_ACK_DEX (371) events, copied verbatim.
# dalertId/sensorType/ackSource are dictionary/enum fields. "spareA2" is
# ignored by the parser (kept here but never asserted on).
class TestLidCgmAlertAckDex(unittest.TestCase):
maxDiff = None
def setUp(self):
# dalertId:2 (CGM High), sensorType:3 (G7), ackSource:0 (by User)
self.fixtureCgmHigh = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 371,
"sequenceGroup": 0,
"sequenceNumber": 416807,
"pumpDateTime": "2026-05-06T23:04:58",
"eventProperties": {"dalertId": 2, "sensorType": 3, "spareA2": 0, "ackSource": 0},
"estimatedDateTime": "2026-05-06T23:04:58Z",
}
# dalertId:12 (CGM Sensor Expiring Soon), sensorType:3 (G7), ackSource:0 (by User)
self.fixtureSensorExpiringSoon = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 371,
"sequenceGroup": 0,
"sequenceNumber": 446797,
"pumpDateTime": "2026-05-15T16:43:28",
"eventProperties": {"dalertId": 12, "sensorType": 3, "spareA2": 0, "ackSource": 0},
"estimatedDateTime": "2026-05-15T16:43:28Z",
}
# dalertId:32 (not in enum -> None), sensorType:3 (G7), ackSource:1 (by Software)
self.fixtureAckBySoftware = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 371,
"sequenceGroup": 0,
"sequenceNumber": 449784,
"pumpDateTime": "2026-05-16T12:34:28",
"eventProperties": {"dalertId": 32, "sensorType": 3, "spareA2": 0, "ackSource": 1},
"estimatedDateTime": "2026-05-16T12:34:28Z",
}
def test_dispatches_to_lidcgmalertackdex(self):
for fx in (self.fixtureCgmHigh, self.fixtureSensorExpiringSoon, self.fixtureAckBySoftware):
self.assertIsInstance(Event(fx), eventtypes.LidCgmAlertAckDex)
def test_envelope_fields(self):
ev = Event(self.fixtureCgmHigh)
self.assertEqual(ev.eventId, 371)
self.assertEqual(ev.seqNum, 416807)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-05-06T23:04:58")
def test_dalertid_resolves_cgm_high(self):
ev = Event(self.fixtureCgmHigh)
self.assertEqual(ev.dalertIdRaw, 2)
self.assertEqual(ev.dalertId, eventtypes.LidCgmAlertAckDex.DalertidEnum.CgmHigh)
def test_dalertid_resolves_sensor_expiring_soon(self):
ev = Event(self.fixtureSensorExpiringSoon)
self.assertEqual(ev.dalertIdRaw, 12)
self.assertEqual(ev.dalertId, eventtypes.LidCgmAlertAckDex.DalertidEnum.CgmSensorExpiringSoon)
def test_sensortype_resolves_g7(self):
for fx in (self.fixtureCgmHigh, self.fixtureSensorExpiringSoon, self.fixtureAckBySoftware):
ev = Event(fx)
self.assertEqual(ev.sensorTypeRaw, 3)
self.assertEqual(ev.sensorType, eventtypes.LidCgmAlertAckDex.SensortypeEnum.CgmTypeDexcomG7)
def test_acksource_resolves_by_user(self):
ev = Event(self.fixtureCgmHigh)
self.assertEqual(ev.ackSourceRaw, 0)
self.assertEqual(ev.ackSource, eventtypes.LidCgmAlertAckDex.AcksourceEnum.AlertAcknowledgedByUser)
def test_acksource_resolves_by_software(self):
ev = Event(self.fixtureAckBySoftware)
self.assertEqual(ev.ackSourceRaw, 1)
self.assertEqual(ev.ackSource, eventtypes.LidCgmAlertAckDex.AcksourceEnum.AlertAcknowledgedBySoftware)
def test_todict_is_json_serializable(self):
for fx in (self.fixtureCgmHigh, self.fixtureSensorExpiringSoon, self.fixtureAckBySoftware):
ev = Event(fx)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 371)
self.assertEqual(d["name"], "LID_CGM_ALERT_ACK_DEX")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,126 @@
#!/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 TestLidCgmAlertActivatedDex(unittest.TestCase):
"""369 LID_CGM_ALERT_ACTIVATED_DEX: dalertId resolves via a dictionary
transform enum, sensorType is an enum. All fixtures are real captures."""
maxDiff = None
def setUp(self):
# dalertId:2 -> CgmHigh
self.fixtureHigh = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 369, "sequenceGroup": 0, "sequenceNumber": 394822,
"pumpDateTime": "2026-04-30T13:13:00",
"eventProperties": {
"dalertId": 2, "sensorType": 3, "spareA2": 0,
"faultLocatorData": 8468, "param1": 214, "param2": 200,
},
"estimatedDateTime": "2026-04-30T13:13:00Z",
}
# dalertId:3 -> CgmLow
self.fixtureLow = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 369, "sequenceGroup": 0, "sequenceNumber": 398698,
"pumpDateTime": "2026-05-01T16:58:01",
"eventProperties": {
"dalertId": 3, "sensorType": 3, "spareA2": 0,
"faultLocatorData": 8467, "param1": 73, "param2": 80,
},
"estimatedDateTime": "2026-05-01T16:58:01Z",
}
# dalertId:1 -> CgmFixedLow
self.fixtureFixedLow = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 369, "sequenceGroup": 0, "sequenceNumber": 403231,
"pumpDateTime": "2026-05-03T01:28:03",
"eventProperties": {
"dalertId": 1, "sensorType": 3, "spareA2": 0,
"faultLocatorData": 8467, "param1": 53, "param2": 55,
},
"estimatedDateTime": "2026-05-03T01:28:03Z",
}
# dalertId:14 -> CgmOutOfRange
self.fixtureOutOfRange = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 369, "sequenceGroup": 0, "sequenceNumber": 409133,
"pumpDateTime": "2026-05-04T18:59:00",
"eventProperties": {
"dalertId": 14, "sensorType": 3, "spareA2": 0,
"faultLocatorData": 8462, "param1": 25, "param2": 917,
},
"estimatedDateTime": "2026-05-04T18:59:00Z",
}
# dalertId:11 -> CgmSensorFail
self.fixtureSensorFail = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 369, "sequenceGroup": 0, "sequenceNumber": 481654,
"pumpDateTime": "2026-05-25T08:35:32",
"eventProperties": {
"dalertId": 11, "sensorType": 3, "spareA2": 0,
"faultLocatorData": 8481, "param1": 35, "param2": 765,
},
"estimatedDateTime": "2026-05-25T08:35:32Z",
}
def test_dispatches_to_correct_class(self):
for f in (self.fixtureHigh, self.fixtureLow, self.fixtureFixedLow,
self.fixtureOutOfRange, self.fixtureSensorFail):
ev = Event(f)
self.assertIsInstance(ev, eventtypes.LidCgmAlertActivatedDex)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureHigh)
self.assertEqual(ev.eventId, 369)
self.assertEqual(ev.seqNum, 394822)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureSensorFail)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-25T08:35:32")
def test_plain_fields_round_trip(self):
ev = Event(self.fixtureOutOfRange)
self.assertEqual(ev.faultLocatorData, 8462)
self.assertEqual(ev.param1, 25)
self.assertEqual(ev.param2, 917)
def test_dalertid_dictionary_enum_resolves(self):
cases = [
(self.fixtureHigh, 2, eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmHigh),
(self.fixtureLow, 3, eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmLow),
(self.fixtureFixedLow, 1, eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmFixedLow),
(self.fixtureOutOfRange, 14, eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmOutOfRange),
(self.fixtureSensorFail, 11, eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmSensorFail),
]
for f, raw, member in cases:
ev = Event(f)
self.assertEqual(ev.dalertIdRaw, raw)
self.assertEqual(ev.dalertId, member)
def test_sensortype_enum_resolves(self):
# every capture is sensorType:3 -> Dexcom G7
for f in (self.fixtureHigh, self.fixtureLow, self.fixtureFixedLow,
self.fixtureOutOfRange, self.fixtureSensorFail):
ev = Event(f)
self.assertEqual(ev.sensorTypeRaw, 3)
self.assertEqual(ev.sensorType,
eventtypes.LidCgmAlertActivatedDex.SensortypeEnum.CgmTypeDexcomG7)
def test_todict_is_json_serializable(self):
for f in (self.fixtureHigh, self.fixtureLow, self.fixtureFixedLow,
self.fixtureOutOfRange, self.fixtureSensorFail):
ev = Event(f)
json.dumps(ev.todict()) # must not raise
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,91 @@
#!/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 TestLidCgmAlertClearedDex(unittest.TestCase):
"""370 LID_CGM_ALERT_CLEARED_DEX: dalertId (dict->enum) and sensorType (enum).
All fixtures are real captured pump-log events copied verbatim; they share
sensorType 3 (Dexcom G7) and differ only in dalertId.
"""
maxDiff = None
def setUp(self):
# dalertId 3 -> CgmLow
self.fixtureCgmLow = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 370, "sequenceGroup": 0, "sequenceNumber": 398753,
"pumpDateTime": "2026-05-01T17:18:01",
"eventProperties": {"dalertId": 3, "sensorType": 3},
"estimatedDateTime": "2026-05-01T17:18:01Z",
}
# dalertId 1 -> CgmFixedLow
self.fixtureCgmFixedLow = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 370, "sequenceGroup": 0, "sequenceNumber": 403279,
"pumpDateTime": "2026-05-03T01:48:03",
"eventProperties": {"dalertId": 1, "sensorType": 3},
"estimatedDateTime": "2026-05-03T01:48:03Z",
}
# dalertId 14 -> CgmOutOfRange
self.fixtureCgmOutOfRange = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 370, "sequenceGroup": 0, "sequenceNumber": 409147,
"pumpDateTime": "2026-05-04T19:07:08",
"eventProperties": {"dalertId": 14, "sensorType": 3},
"estimatedDateTime": "2026-05-04T19:07:08Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureCgmLow)
self.assertIsInstance(ev, eventtypes.LidCgmAlertClearedDex)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureCgmLow)
self.assertEqual(ev.eventId, 370)
self.assertEqual(ev.seqNum, 398753)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureCgmLow)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-01T17:18:01")
def test_dalertid_resolves_to_enum(self):
self.assertEqual(Event(self.fixtureCgmLow).dalertIdRaw, 3)
self.assertEqual(Event(self.fixtureCgmLow).dalertId,
eventtypes.LidCgmAlertClearedDex.DalertidEnum.CgmLow)
self.assertEqual(Event(self.fixtureCgmFixedLow).dalertIdRaw, 1)
self.assertEqual(Event(self.fixtureCgmFixedLow).dalertId,
eventtypes.LidCgmAlertClearedDex.DalertidEnum.CgmFixedLow)
self.assertEqual(Event(self.fixtureCgmOutOfRange).dalertIdRaw, 14)
self.assertEqual(Event(self.fixtureCgmOutOfRange).dalertId,
eventtypes.LidCgmAlertClearedDex.DalertidEnum.CgmOutOfRange)
def test_sensortype_resolves_to_enum(self):
ev = Event(self.fixtureCgmLow)
self.assertEqual(ev.sensorTypeRaw, 3)
self.assertEqual(ev.sensorType,
eventtypes.LidCgmAlertClearedDex.SensortypeEnum.CgmTypeDexcomG7)
def test_todict_is_json_serializable(self):
ev = Event(self.fixtureCgmOutOfRange)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 370)
self.assertEqual(d["name"], "LID_CGM_ALERT_CLEARED_DEX")
self.assertEqual(d["seqNum"], 409147)
self.assertEqual(d["dalertIdRaw"], 14)
self.assertEqual(d["sensorTypeRaw"], 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,169 @@
#!/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 TestLidCgmDataG7(unittest.TestCase):
"""399 LID_CGM_DATA_G7 parsed from real captured pump-log events."""
maxDiff = None
def setUp(self):
# Rising: large positive rate, high glucose, FMR reading.
self.fixtureRising = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 464452,
"pumpDateTime": "2026-05-20T12:15:13",
"eventProperties": {
"glucoseValueStatus": 0, "cgmDataType": [0], "rate": 52,
"algorithmState": 32, "rssi": -87, "currentGlucoseDisplayValue": 287,
"egvTimeStamp": 580133707, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12],
"interval": 0, "reservedD15": 0,
},
"estimatedDateTime": "2026-05-20T12:15:13Z",
}
# Falling: large negative rate.
self.fixtureFalling = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 416862,
"pumpDateTime": "2026-05-06T23:26:04",
"eventProperties": {
"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -39,
"algorithmState": 32, "rssi": -54, "currentGlucoseDisplayValue": 195,
"egvTimeStamp": 578964361, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12],
"interval": 0, "reservedD15": 0,
},
"estimatedDateTime": "2026-05-06T23:26:04Z",
}
# SpecialLow: glucoseValueStatus 2, very low display value.
self.fixtureSpecialLow = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 450303,
"pumpDateTime": "2026-05-16T15:44:53",
"eventProperties": {
"glucoseValueStatus": 2, "cgmDataType": [0], "rate": -5,
"algorithmState": 32, "rssi": -55, "currentGlucoseDisplayValue": 31,
"egvTimeStamp": 579800690, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12],
"interval": 0, "reservedD15": 0,
},
"estimatedDateTime": "2026-05-16T15:44:53Z",
}
# Backfill: different cgmDataType/egvInfoBitmask, high glucose, zero rate.
self.fixtureBackfill = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 399, "sequenceGroup": 0, "sequenceNumber": 484027,
"pumpDateTime": "2026-05-25T22:34:18",
"eventProperties": {
"glucoseValueStatus": 0, "cgmDataType": [1], "rate": 0,
"algorithmState": 32, "rssi": -83, "currentGlucoseDisplayValue": 380,
"egvTimeStamp": 580602548, "egvInfoBitmask": [1, 5, 6, 7, 8, 11, 12],
"interval": 1, "reservedD15": 0,
},
"estimatedDateTime": "2026-05-25T22:34:18Z",
}
def test_dispatches_to_lidcgmdatag7(self):
for fx in (self.fixtureRising, self.fixtureFalling,
self.fixtureSpecialLow, self.fixtureBackfill):
ev = Event(fx)
self.assertIsInstance(ev, eventtypes.LidCgmDataG7)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureRising)
self.assertEqual(ev.eventId, 399)
self.assertEqual(ev.seqNum, 464452)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-20T12:15:13")
def test_display_value_and_rssi_round_trip(self):
ev = Event(self.fixtureRising)
self.assertEqual(ev.currentGlucoseDisplayValue, 287)
self.assertEqual(ev.rssi, -87)
low = Event(self.fixtureSpecialLow)
self.assertEqual(low.currentGlucoseDisplayValue, 31)
self.assertEqual(low.rssi, -55)
high = Event(self.fixtureBackfill)
self.assertEqual(high.currentGlucoseDisplayValue, 380)
self.assertEqual(high.rssi, -83)
def test_glucosevaluestatus_enum_resolves(self):
# 0 -> PreciseValue: the zero value must resolve, not be treated as missing.
ev = Event(self.fixtureRising)
self.assertEqual(ev.glucoseValueStatusRaw, 0)
self.assertEqual(ev.glucoseValueStatus,
eventtypes.LidCgmDataG7.GlucosevaluestatusEnum.PreciseValue)
# 2 -> SpecialLow
low = Event(self.fixtureSpecialLow)
self.assertEqual(low.glucoseValueStatusRaw, 2)
self.assertEqual(low.glucoseValueStatus,
eventtypes.LidCgmDataG7.GlucosevaluestatusEnum.SpecialLow)
def test_algorithmstate_enum_resolves(self):
# 32 -> ReportablePeriodValidEgv on every fixture.
for fx in (self.fixtureRising, self.fixtureFalling,
self.fixtureSpecialLow, self.fixtureBackfill):
ev = Event(fx)
self.assertEqual(ev.algorithmStateRaw, 32)
self.assertEqual(ev.algorithmState,
eventtypes.LidCgmDataG7.AlgorithmstateEnum.ReportablePeriodValidEgv)
def test_cgm_datatype_bitmask_folds_to_raw_int(self):
# cgmDataType:[0] -> 1<<0 == 1 -> Fmr
ev = Event(self.fixtureRising)
self.assertEqual(ev.cgmDataTypeRaw, 1)
self.assertEqual(ev.cgmDataType,
eventtypes.LidCgmDataG7.CgmdatatypeBitmask.Fmr)
# cgmDataType:[1] -> 1<<1 == 2 -> Backfill
bf = Event(self.fixtureBackfill)
self.assertEqual(bf.cgmDataTypeRaw, 2)
self.assertEqual(bf.cgmDataType,
eventtypes.LidCgmDataG7.CgmdatatypeBitmask.Backfill)
def test_egvinfobitmask_folds_to_raw_int(self):
# [0,5,6,7,8,11,12] -> sum(1<<i) == 6625
ev = Event(self.fixtureRising)
self.assertEqual(ev.egvInfoBitmaskRaw,
sum(1 << i for i in [0, 5, 6, 7, 8, 11, 12]))
self.assertEqual(ev.egvInfoBitmaskRaw, 6625)
# [1,5,6,7,8,11,12] -> sum(1<<i) == 6626
bf = Event(self.fixtureBackfill)
self.assertEqual(bf.egvInfoBitmaskRaw,
sum(1 << i for i in [1, 5, 6, 7, 8, 11, 12]))
self.assertEqual(bf.egvInfoBitmaskRaw, 6626)
def test_rate_ratio_scales(self):
# rateRaw ×0.1 mg/dL/min
rising = Event(self.fixtureRising)
self.assertEqual(rising.rateRaw, 52)
self.assertAlmostEqual(rising.rate, 5.2)
falling = Event(self.fixtureFalling)
self.assertEqual(falling.rateRaw, -39)
self.assertAlmostEqual(falling.rate, -3.9)
flat = Event(self.fixtureBackfill)
self.assertEqual(flat.rateRaw, 0)
self.assertAlmostEqual(flat.rate, 0.0)
def test_egv_timestamp_is_raw_seconds(self):
# egvTimeStamp (camelCase) normalizes onto egvTimestamp, kept as raw seconds int.
self.assertEqual(Event(self.fixtureRising).egvTimeStamp, 580133707)
self.assertEqual(Event(self.fixtureFalling).egvTimeStamp, 578964361)
self.assertEqual(Event(self.fixtureSpecialLow).egvTimeStamp, 579800690)
self.assertEqual(Event(self.fixtureBackfill).egvTimeStamp, 580602548)
def test_todict_json_serializable(self):
for fx in (self.fixtureRising, self.fixtureFalling,
self.fixtureSpecialLow, self.fixtureBackfill):
json.dumps(Event(fx).todict()) # must not raise
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,91 @@
#!/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 TestLidCgmJoinSessionG7(unittest.TestCase):
"""394 LID_CGM_JOIN_SESSION_G7: cgmTimestamp/sessionSignature are plain ints."""
maxDiff = None
def setUp(self):
# Real captured pump-log events (values copied verbatim).
self.fixtureLowCgmTs = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 394,
"sequenceGroup": 0,
"sequenceNumber": 413856,
"pumpDateTime": "2026-05-06T01:53:28",
"eventProperties": {"cgmTimestamp": 3042, "sessionSignature": 72},
"estimatedDateTime": "2026-05-06T01:53:28Z",
}
self.fixtureHighCgmTs = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 394,
"sequenceGroup": 0,
"sequenceNumber": 449825,
"pumpDateTime": "2026-05-16T12:38:39",
"eventProperties": {"cgmTimestamp": 51767, "sessionSignature": 117},
"estimatedDateTime": "2026-05-16T12:38:39Z",
}
self.fixtureSharedSignature = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 394,
"sequenceGroup": 0,
"sequenceNumber": 481763,
"pumpDateTime": "2026-05-25T09:39:19",
"eventProperties": {"cgmTimestamp": 248, "sessionSignature": 117},
"estimatedDateTime": "2026-05-25T09:39:19Z",
}
def test_dispatches_to_correct_class(self):
ev = Event(self.fixtureLowCgmTs)
self.assertIsInstance(ev, eventtypes.LidCgmJoinSessionG7)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureLowCgmTs)
self.assertEqual(ev.eventId, 394)
self.assertEqual(ev.seqNum, 413856)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixtureLowCgmTs)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-06T01:53:28")
def test_plain_fields_round_trip(self):
ev = Event(self.fixtureLowCgmTs)
self.assertEqual(ev.cgmTimestamp, 3042)
self.assertEqual(ev.sessionSignature, 72)
def test_high_cgm_timestamp_fixture(self):
ev = Event(self.fixtureHighCgmTs)
self.assertEqual(ev.eventId, 394)
self.assertEqual(ev.seqNum, 449825)
self.assertEqual(ev.cgmTimestamp, 51767)
self.assertEqual(ev.sessionSignature, 117)
def test_shared_signature_fixture(self):
# Same sessionSignature as the high-cgm fixture but a distinct cgmTimestamp.
ev = Event(self.fixtureSharedSignature)
self.assertEqual(ev.seqNum, 481763)
self.assertEqual(ev.cgmTimestamp, 248)
self.assertEqual(ev.sessionSignature, 117)
def test_todict_is_json_serializable(self):
ev = Event(self.fixtureLowCgmTs)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 394)
self.assertEqual(d["name"], "LID_CGM_JOIN_SESSION_G7")
self.assertEqual(d["seqNum"], 413856)
self.assertEqual(d["cgmTimestamp"], 3042)
self.assertEqual(d["sessionSignature"], 72)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,110 @@
#!/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 TestLidCgmStopSessionG7(unittest.TestCase):
"""447: LID_CGM_STOP_SESSION_G7. sessionStopReason is a plain int (no enum
in the generated class), so it round-trips as its captured value. Fixtures
are real captured events copied verbatim, one per distinct stop reason."""
maxDiff = None
def setUp(self):
# sessionStopReason=5 with a real stop time and stopSessionCode=1.
self.fixtureReason5 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 447,
"sequenceGroup": 0,
"sequenceNumber": 413840,
"pumpDateTime": "2026-05-06T01:53:10",
"eventProperties": {
"currentTransmitterTime": 890598, "sessionStartTime": 73,
"sessionStopTime": 890591, "sessionDuration": 10,
"sessionStopReason": 5, "stopSessionCode": 1,
},
"estimatedDateTime": "2026-05-06T01:53:10Z",
}
# sessionStopReason=16 with zero stop time / code.
self.fixtureReason16 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 447,
"sequenceGroup": 0,
"sequenceNumber": 449816,
"pumpDateTime": "2026-05-16T12:38:30",
"eventProperties": {
"currentTransmitterTime": 905595, "sessionStartTime": 72,
"sessionStopTime": 0, "sessionDuration": 10,
"sessionStopReason": 16, "stopSessionCode": 0,
},
"estimatedDateTime": "2026-05-16T12:38:30Z",
}
# sessionStopReason=15 with a UINT32-max sessionStartTime sentinel.
self.fixtureReason15 = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 447,
"sequenceGroup": 0,
"sequenceNumber": 481653,
"pumpDateTime": "2026-05-25T08:35:32",
"eventProperties": {
"currentTransmitterTime": 814740, "sessionStartTime": 4294967295,
"sessionStopTime": 0, "sessionDuration": 10,
"sessionStopReason": 15, "stopSessionCode": 0,
},
"estimatedDateTime": "2026-05-25T08:35:32Z",
}
def test_dispatches_to_lidcgmstopsessiong7(self):
ev = Event(self.fixtureReason5)
self.assertIsInstance(ev, eventtypes.LidCgmStopSessionG7)
self.assertIsNot(type(ev), RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureReason5)
self.assertEqual(ev.eventId, 447)
self.assertEqual(ev.seqNum, 413840)
# eventTimestamp keeps pumpDateTime's wall-clock.
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
"2026-05-06T01:53:10")
def test_session_time_fields_round_trip(self):
ev = Event(self.fixtureReason5)
self.assertEqual(ev.currentTransmitterTime, 890598)
self.assertEqual(ev.sessionStartTime, 73)
self.assertEqual(ev.sessionStopTime, 890591)
self.assertEqual(ev.sessionDuration, 10)
self.assertEqual(ev.stopSessionCode, 1)
def test_session_start_time_sentinel_round_trips(self):
# UINT32-max sentinel must survive as-is.
ev = Event(self.fixtureReason15)
self.assertEqual(ev.sessionStartTime, 4294967295)
self.assertEqual(ev.sessionStopTime, 0)
self.assertEqual(ev.stopSessionCode, 0)
def test_session_stop_reason_is_raw_int(self):
# No enum is generated for sessionStopReason; it stays the captured int.
self.assertFalse(hasattr(eventtypes.LidCgmStopSessionG7,
"SessionstopreasonEnum"))
self.assertEqual(Event(self.fixtureReason5).sessionStopReason, 5)
self.assertEqual(Event(self.fixtureReason16).sessionStopReason, 16)
self.assertEqual(Event(self.fixtureReason15).sessionStopReason, 15)
def test_todict_is_json_serializable(self):
for fixture in (self.fixtureReason5, self.fixtureReason16,
self.fixtureReason15):
d = Event(fixture).todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 447)
self.assertEqual(d["name"], "LID_CGM_STOP_SESSION_G7")
self.assertEqual(
d["sessionStopReason"],
fixture["eventProperties"]["sessionStopReason"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,95 @@
#!/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 TestLidDateChanged(unittest.TestCase):
maxDiff = None
def setUp(self):
# Real captured code-14 (LID_DATE_CHANGED) events, copied verbatim from
# the clockChanges arrays of pump-log responses.
# datePrior == dateAfter: a resync that did not move the day.
self.fixtureEqual = {
"deviceAssignmentId": "73aeb403-1d22-4d12-a3fd-229e5b6641ee",
"eventCode": 14,
"sequenceGroup": 0,
"sequenceNumber": 364364,
"pumpDateTime": "2025-11-02T16:27:58",
"eventProperties": {"datePrior": 6515, "dateAfter": 6515, "rawRtcTime": 1625159578},
"estimatedDateTime": "2025-11-02T16:27:58Z",
}
# datePrior != dateAfter by one day: a small clock adjustment.
self.fixtureOffByOne = {
"deviceAssignmentId": "73aeb403-1d22-4d12-a3fd-229e5b6641ee",
"eventCode": 14,
"sequenceGroup": 0,
"sequenceNumber": 418774,
"pumpDateTime": "2025-11-18T22:55:04",
"eventProperties": {"datePrior": 6530, "dateAfter": 6531, "rawRtcTime": 2958809165},
"estimatedDateTime": "2025-11-18T22:55:04Z",
}
# Large jump (initial date set): datePrior far from dateAfter.
self.fixtureLargeJump = {
"deviceAssignmentId": "73aeb403-1d22-4d12-a3fd-229e5b6641ee",
"eventCode": 14,
"sequenceGroup": 0,
"sequenceNumber": 95,
"pumpDateTime": "2025-07-20T04:38:28",
"eventProperties": {"datePrior": 4394, "dateAfter": 6410, "rawRtcTime": 1089120495},
"estimatedDateTime": "2025-07-20T04:38:28Z",
}
def test_dispatches_to_liddatechanged(self):
for fx in (self.fixtureEqual, self.fixtureOffByOne, self.fixtureLargeJump):
ev = Event(fx)
self.assertIsInstance(ev, eventtypes.LidDateChanged)
self.assertNotIsInstance(ev, RawEvent)
def test_envelope_fields(self):
ev = Event(self.fixtureOffByOne)
self.assertEqual(ev.eventId, 14)
self.assertEqual(ev.seqNum, 418774)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2025-11-18T22:55:04")
def test_equal_dates_round_trip(self):
ev = Event(self.fixtureEqual)
self.assertEqual(ev.datePrior, 6515)
self.assertEqual(ev.dateAfter, 6515)
self.assertEqual(ev.rawRtcTime, 1625159578)
def test_off_by_one_dates_round_trip(self):
ev = Event(self.fixtureOffByOne)
self.assertEqual(ev.datePrior, 6530)
self.assertEqual(ev.dateAfter, 6531)
self.assertEqual(ev.rawRtcTime, 2958809165)
def test_large_jump_dates_round_trip(self):
ev = Event(self.fixtureLargeJump)
self.assertEqual(ev.datePrior, 4394)
self.assertEqual(ev.dateAfter, 6410)
self.assertEqual(ev.rawRtcTime, 1089120495)
def test_todict_is_json_serializable(self):
for fx in (self.fixtureEqual, self.fixtureOffByOne, self.fixtureLargeJump):
ev = Event(fx)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 14)
self.assertEqual(d["name"], "LID_DATE_CHANGED")
self.assertEqual(d["seqNum"], fx["sequenceNumber"])
self.assertEqual(d["datePrior"], fx["eventProperties"]["datePrior"])
self.assertEqual(d["dateAfter"], fx["eventProperties"]["dateAfter"])
self.assertEqual(d["rawRtcTime"], fx["eventProperties"]["rawRtcTime"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,59 @@
#!/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 TestLidMalfunctionActivated(unittest.TestCase):
maxDiff = None
def setUp(self):
self.fixture = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 6,
"sequenceGroup": 0,
"sequenceNumber": 500123,
"pumpDateTime": "2026-05-16T00:07:00",
"eventProperties": {"malfId": 7, "faultLocatorData": 8311, "param1": 42, "param2": 0},
"estimatedDateTime": "2026-05-16T00:07:00Z",
}
def test_dispatches_to_correct_class(self):
self.assertIsInstance(Event(self.fixture), eventtypes.LidMalfunctionActivated)
self.assertNotIsInstance(Event(self.fixture), RawEvent)
def test_has_no_alarmid_attribute(self):
ev = Event(self.fixture)
self.assertFalse(hasattr(ev, 'alarmId'))
self.assertEqual(ev.malfIdRaw, 7)
def test_envelope_fields(self):
ev = Event(self.fixture)
self.assertEqual(ev.eventId, 6)
self.assertEqual(ev.seqNum, 500123)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixture)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-05-16T00:07:00")
def test_plain_fields(self):
ev = Event(self.fixture)
self.assertEqual(ev.faultLocatorData, 8311)
self.assertEqual(ev.param1, 42)
self.assertEqual(ev.param2, 0)
def test_todict_is_json_serializable(self):
ev = Event(self.fixture)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 6)
self.assertEqual(d["name"], "LID_MALFUNCTION_ACTIVATED")
self.assertEqual(d["malfIdRaw"], 7)
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More