mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-24 10:14:36 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67731f23ff | ||
|
|
2b8d8ebaf8 | ||
|
|
da58c6fc81 | ||
|
|
196ffc2cfb | ||
|
|
2753b4da20 | ||
|
|
97ad0e7629 | ||
|
|
7c4b2f4ddb | ||
|
|
74576bbb51 | ||
|
|
272a329664 | ||
|
|
758204d4de | ||
|
|
ddeaa79ded | ||
|
|
c95fe424ab | ||
|
|
4bae860212 | ||
|
|
823bb785ba | ||
|
|
984487da44 | ||
|
|
4655e31561 | ||
|
|
7f88d88ea4 | ||
|
|
afbc99010c | ||
|
|
6f987989b8 | ||
|
|
1500438c1b | ||
|
|
ee7384cc31 | ||
|
|
e5304dc605 | ||
|
|
cb045796e5 | ||
|
|
ef87d18469 | ||
|
|
9d8ab283a0 | ||
|
|
a28e0f1ee0 | ||
|
|
ddca912eea | ||
|
|
46a5a28baf | ||
|
|
b7375d11da | ||
|
|
6030022476 | ||
|
|
a977bd65f9 | ||
|
|
69637015e2 | ||
|
|
d1d67b5042 | ||
|
|
7516226bd3 | ||
|
|
1b89a4ba4d | ||
|
|
02e3910928 | ||
|
|
c5854ac17c | ||
|
|
b362bbbf3d | ||
|
|
408366fe6b | ||
|
|
0779803de2 | ||
|
|
d2e947ffa3 | ||
|
|
27ddf0cb1d | ||
|
|
b60bfb94ee | ||
|
|
4c548203a5 | ||
|
|
5c5449d5bd | ||
|
|
8761e70b18 | ||
|
|
f124b884b2 | ||
|
|
d568a5be85 | ||
|
|
654ee7ead3 | ||
|
|
9619174958 | ||
|
|
a9c9a7ebd9 | ||
|
|
8fd9bfcf72 | ||
|
|
000bae38b6 | ||
|
|
ea7cc8f4ec | ||
|
|
e5195b2613 | ||
|
|
f0f94baf02 | ||
|
|
bf894bce02 | ||
|
|
594ee19450 | ||
|
|
d78d70adf5 | ||
|
|
8381e50fe6 | ||
|
|
93b0901aae | ||
|
|
82ccaa5a88 | ||
|
|
b1f63f8858 | ||
|
|
9878e83755 | ||
|
|
d8b57fe788 | ||
|
|
664f97be35 | ||
|
|
9094976085 | ||
|
|
b592f6b464 | ||
|
|
4f2affe70f | ||
|
|
0733531f67 |
@@ -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: >-
|
||||
|
||||
@@ -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,9 +25,9 @@ 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
|
||||
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
|
||||
@@ -50,15 +50,18 @@ jobs:
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --ignore=F824 --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:
|
||||
|
||||
+1
-1
@@ -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/
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -381,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,
|
||||
@@ -419,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:
|
||||
@@ -438,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()
|
||||
```
|
||||
|
||||
Executable
+225
@@ -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()
|
||||
@@ -1,9 +1,9 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 2.3.4
|
||||
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
|
||||
|
||||
@@ -3,13 +3,15 @@ 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 .sync.tandemsource.autoupdate import TandemSourceAutoupdate
|
||||
@@ -37,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):
|
||||
@@ -126,4 +128,3 @@ def main(*args, **kwargs):
|
||||
|
||||
# return exit code 0 if processed events
|
||||
sys.exit(0 if added>0 else 1)
|
||||
|
||||
|
||||
@@ -1,26 +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, region='US'):
|
||||
def __init__(self, email, password, region=None):
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.region = region
|
||||
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
|
||||
@@ -32,49 +28,3 @@ class TConnectApi:
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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), {})
|
||||
@@ -9,20 +9,182 @@ 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/'
|
||||
@@ -34,7 +196,7 @@ class TandemSourceApi:
|
||||
'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': '0oa27ho9tpZE9Arjy4h7',
|
||||
'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',
|
||||
@@ -54,7 +216,13 @@ class TandemSourceApi:
|
||||
'AUTHORIZATION_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/authorize'
|
||||
}
|
||||
|
||||
def __init__(self, email, password, region='US'):
|
||||
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'.")
|
||||
@@ -66,30 +234,30 @@ class TandemSourceApi:
|
||||
self._password = password
|
||||
|
||||
@property
|
||||
def LOGIN_API_URL(self):
|
||||
def LOGIN_API_URL(self) -> str:
|
||||
return self._region_urls['LOGIN_API_URL']
|
||||
|
||||
|
||||
@property
|
||||
def TDC_OAUTH_AUTHORIZE_URL(self):
|
||||
def TDC_OAUTH_AUTHORIZE_URL(self) -> str:
|
||||
return self._region_urls['TDC_OAUTH_AUTHORIZE_URL']
|
||||
|
||||
|
||||
@property
|
||||
def TDC_OIDC_JWKS_URL(self):
|
||||
def TDC_OIDC_JWKS_URL(self) -> str:
|
||||
return self._region_urls['TDC_OIDC_JWKS_URL']
|
||||
|
||||
|
||||
@property
|
||||
def TDC_OIDC_ISSUER(self):
|
||||
def TDC_OIDC_ISSUER(self) -> str:
|
||||
return self._region_urls['TDC_OIDC_ISSUER']
|
||||
|
||||
|
||||
@property
|
||||
def TDC_OIDC_CLIENT_ID(self):
|
||||
def TDC_OIDC_CLIENT_ID(self) -> str:
|
||||
return self._region_urls['TDC_OIDC_CLIENT_ID']
|
||||
|
||||
|
||||
@property
|
||||
def SOURCE_URL(self):
|
||||
def SOURCE_URL(self) -> str:
|
||||
return self._region_urls['SOURCE_URL']
|
||||
|
||||
def login(self, email, password):
|
||||
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")
|
||||
@@ -125,12 +293,12 @@ class TandemSourceApi:
|
||||
|
||||
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('=')
|
||||
@@ -208,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
|
||||
|
||||
@@ -226,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
|
||||
|
||||
@@ -292,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
|
||||
@@ -324,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
|
||||
@@ -353,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:
|
||||
@@ -378,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:
|
||||
@@ -389,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)
|
||||
@@ -402,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)
|
||||
|
||||
|
||||
|
||||
@@ -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 )
|
||||
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))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
+16
-10
@@ -3,20 +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 .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"
|
||||
|
||||
"""
|
||||
@@ -58,6 +61,9 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
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=}")
|
||||
|
||||
@@ -87,7 +93,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
serialNumberToPump = None
|
||||
try:
|
||||
log("Fetching pump metadata...")
|
||||
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
|
||||
pumpEventMetadata = tconnect.tandemsource.get_pumper().get('pumps', [])
|
||||
|
||||
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
|
||||
log(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
|
||||
@@ -99,11 +105,11 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
|
||||
log(f'ChooseDevice selected: {tconnectDevice}')
|
||||
|
||||
tconnectDeviceId = tconnectDevice['tconnectDeviceId']
|
||||
deviceId = tconnectDevice['assignmentId']
|
||||
|
||||
log(f'Fetching pump events for {tconnectDeviceId=} {time_start=} {time_end=} fetch_all_event_types=False')
|
||||
log(f'Fetching pump events for {deviceId=} {time_start=} {time_end=} fetch_all_event_types=False')
|
||||
|
||||
events = tconnect.tandemsource.pump_events(tconnectDeviceId, 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)}")
|
||||
@@ -184,7 +190,7 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
|
||||
if serialNumberToPump:
|
||||
for i, (pumpSerial, pumpDetails) in enumerate(serialNumberToPump.items()):
|
||||
sanitizedData[f'PUMP_SERIAL_{i}'] = pumpSerial
|
||||
sanitizedData[f'TCONNECT_DEVICE_ID_{i}'] = pumpDetails['tconnectDeviceId']
|
||||
sanitizedData[f'TCONNECT_DEVICE_ID_{i}'] = pumpDetails['assignmentId']
|
||||
loglines = [run_sanitize(i, sanitizedData) for i in loglines]
|
||||
|
||||
f.writelines(loglines)
|
||||
@@ -205,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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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% 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% 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -92,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):
|
||||
@@ -130,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
|
||||
@@ -154,6 +200,7 @@ def build_event(event_id, 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"]
|
||||
|
||||
@@ -18,18 +18,19 @@
|
||||
"offset": 8,
|
||||
"uom": "units"
|
||||
},
|
||||
"batteryChargePercentMSBRaw": {
|
||||
"type": "uint8",
|
||||
"offset": 12
|
||||
},
|
||||
"batteryChargePercentLSBRaw": {
|
||||
"type": "uint8",
|
||||
"offset": 13,
|
||||
"transform": [["battery_charge_percent", ""]]
|
||||
},
|
||||
"batteryLipoMilliVolts": {
|
||||
"type": "uint16",
|
||||
"offset": 14
|
||||
"offset": 12,
|
||||
"uom": "millivolts"
|
||||
},
|
||||
"batteryChargePercent": {
|
||||
"type": "uint8",
|
||||
"offset": 14,
|
||||
"uom": "percent"
|
||||
},
|
||||
"finalEventForDay": {
|
||||
"type": "uint8",
|
||||
"offset": 15
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+1149
-267
File diff suppressed because it is too large
Load Diff
+2093
-985
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -48,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,
|
||||
|
||||
@@ -48,9 +48,9 @@ ALERTS_DICT = {
|
||||
"46": "TRANSMITTER_EXPIRING_ALERT3",
|
||||
"47": "DEFAULT_ALERT_47",
|
||||
"48": "CGM_UNAVAILABLE",
|
||||
"49": "DEFAULT_ALERT_49",
|
||||
"49": "FILL_TUBING_STILL_IN_PROGRESS",
|
||||
"50": "DEFAULT_ALERT_50",
|
||||
"51": "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"
|
||||
}
|
||||
@@ -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 += [
|
||||
@@ -111,22 +137,9 @@ def transform_ratio(event_def, name, name_fmt, field, tx):
|
||||
|
||||
return out
|
||||
|
||||
def transform_battery_charge_percent(event_def, name, name_fmt, field, tx):
|
||||
out = []
|
||||
out += [
|
||||
'@property',
|
||||
f'def batteryChargePercent(self):',
|
||||
f' return (256*(self.batterychargepercentmsbRaw-14)+self.batterychargepercentlsbRaw)/(3*256)',
|
||||
''
|
||||
]
|
||||
|
||||
return out
|
||||
|
||||
|
||||
TRANSFORMS = {
|
||||
'enum': transform_enum,
|
||||
'dictionary': transform_dictionary,
|
||||
'bitmask': transform_bitmask,
|
||||
'ratio': transform_ratio,
|
||||
'battery_charge_percent': transform_battery_charge_percent
|
||||
}
|
||||
}
|
||||
|
||||
+33
-75
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -73,6 +73,11 @@ 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', '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'])
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -37,127 +51,227 @@ class TandemSourceAutoupdate:
|
||||
self.autoupdate_start = time.time()
|
||||
|
||||
while True:
|
||||
logger.debug("autoupdate loop")
|
||||
now = time.time()
|
||||
try:
|
||||
logger.debug("autoupdate loop")
|
||||
now = time.time()
|
||||
|
||||
time_end = datetime.datetime.now()
|
||||
time_start = time_end - datetime.timedelta(days=1)
|
||||
time_end = datetime.datetime.now()
|
||||
time_start = time_end - datetime.timedelta(days=1)
|
||||
|
||||
tconnectDevice = ChooseDevice(self.secret, tconnect).choose()
|
||||
tconnectDevice = ChooseDevice(self.secret, tconnect).choose()
|
||||
|
||||
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))
|
||||
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')
|
||||
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):
|
||||
@@ -180,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()}')
|
||||
@@ -26,31 +31,37 @@ class ChooseDevice:
|
||||
|
||||
# Warn if pump is stale (no events in >3 days)
|
||||
try:
|
||||
max_event_date = arrow.get(tconnectDevice["maxDateWithEvents"])
|
||||
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['maxDateWithEvents']}). "
|
||||
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 maxDateWithEvents to check for staleness: {e}")
|
||||
logger.debug(f"Could not parse maxDateOfEvents to check for staleness: {e}")
|
||||
|
||||
logger.info(f'Using pump with serial: {tconnectDevice["serialNumber"]} (tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
|
||||
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
|
||||
@@ -58,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, region='US'):
|
||||
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)
|
||||
@@ -2,6 +2,20 @@ import logging
|
||||
import collections
|
||||
import arrow
|
||||
|
||||
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
|
||||
@@ -21,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,
|
||||
@@ -48,7 +62,7 @@ class ProcessTimeRange:
|
||||
UpdateProfiles
|
||||
]
|
||||
|
||||
def process(self, time_start, time_end):
|
||||
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}")
|
||||
@@ -86,7 +100,10 @@ class ProcessTimeRange:
|
||||
# Ensure time_end is timezone-aware for comparison
|
||||
time_end_aware = arrow.get(time_end)
|
||||
capped_time_end = min(events_last_time, time_end_aware) if events_last_time else time_end_aware
|
||||
ns_entries = c.process(events, events_first_time, capped_time_end)
|
||||
# events_first_time is populated whenever for_eventclass has entries
|
||||
# (i.e. at least one event was seen); fall back to time_start otherwise.
|
||||
time_start_for_events = events_first_time if events_first_time else time_start
|
||||
ns_entries = c.process(events, time_start_for_events, capped_time_end)
|
||||
w = c.write(ns_entries)
|
||||
if w:
|
||||
processed_count += w
|
||||
@@ -94,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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,20 +4,74 @@ import arrow
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ... import secret
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
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, timezone=None):
|
||||
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
|
||||
@@ -25,10 +79,10 @@ class ProcessCGMReading:
|
||||
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
|
||||
@@ -53,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:
|
||||
@@ -65,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, tzinfo='UTC').replace(tzinfo=self.timezone)
|
||||
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
|
||||
@@ -52,20 +57,34 @@ class ProcessDeviceStatus:
|
||||
|
||||
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.
|
||||
#
|
||||
# batteryChargePercent is the pump's own state-of-charge byte, already
|
||||
# scaled 0-100; if the event arrived without it (an event shape we
|
||||
# can't yet parse), skip it rather than emit a bogus device status.
|
||||
if event.batteryChargePercent is None:
|
||||
logger.warning("ProcessDeviceStatus: skipping daily basal event missing battery data: %s" % event)
|
||||
return None
|
||||
|
||||
def daily_basal_to_nsentry(self, event):
|
||||
return NightscoutEntry.devicestatus(
|
||||
created_at=event.eventTimestamp.format(),
|
||||
batteryVoltage=(float(event.batterylipomillivolts or 0)/1000),
|
||||
batteryPercent=int(100*event.batteryChargePercent),
|
||||
batteryVoltage=(float(event.batteryLipoMilliVolts or 0)/1000),
|
||||
batteryPercent=int(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:
|
||||
|
||||
+1
-53
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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% 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% 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
|
||||
]
|
||||
@@ -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,129 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from tconnectsync.eventparser.generic import Event
|
||||
from tconnectsync.eventparser import events as eventtypes
|
||||
from tconnectsync.eventparser.raw_event import RawEvent
|
||||
|
||||
|
||||
class TestLidDailyBasal(unittest.TestCase):
|
||||
"""81: LID_DAILY_BASAL. Real captured binary events.
|
||||
|
||||
The trailing 4 bytes are one packed uint32:
|
||||
(batteryLipoMilliVolts << 16) | (batteryChargePercent << 8) | finalEventForDay
|
||||
Tandem Source serializes scalars big-endian, so on the wire those bytes are
|
||||
[lipo_hi, lipo_lo, percent, final] at absolute offsets 22, 23, 24, 25.
|
||||
pumpX2's BLE stream packs the same u32 little-endian, which is why its
|
||||
Java/Swift ports read the three fields in the opposite order.
|
||||
|
||||
finalEventForDay is a boolean close-out marker whose usual -- but not only
|
||||
-- trigger is the daily rollover: in a 452-record capture it precedes both
|
||||
day resets, and is also set once mid-afternoon, a second before
|
||||
LID_PUMPING_RESUMED ended an alarm suspension, with no reset following. Not
|
||||
enough to pin the semantics, so pumpX2's TODO(confirm) on the field stays
|
||||
open. The generator has no bool type, so it surfaces as an int here where
|
||||
the Java/Swift ports expose a bool.
|
||||
"""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.fixtureMidCharge = b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'
|
||||
self.fixtureMidChargeLater = b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00'
|
||||
self.fixtureFinalEventForDay = self.fixtureMidCharge[:25] + b'\x01'
|
||||
self.fixtureFullCharge = self.fixtureMidCharge[:24] + b'\x64' + self.fixtureMidCharge[25:]
|
||||
|
||||
def test_dispatches_to_liddailybasal(self):
|
||||
ev = Event(self.fixtureMidCharge)
|
||||
self.assertIsInstance(ev, eventtypes.LidDailyBasal)
|
||||
self.assertIsNot(type(ev), RawEvent)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixtureMidCharge)
|
||||
self.assertEqual(ev.eventId, 81)
|
||||
self.assertEqual(ev.seqNum, 1046436)
|
||||
self.assertEqual(ev.raw.timestampRaw, 534123623)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
ev = Event(self.fixtureMidCharge)
|
||||
self.assertEqual(
|
||||
ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2024-12-03T23:40:23",
|
||||
)
|
||||
|
||||
def test_leading_float_fields(self):
|
||||
ev = Event(self.fixtureMidCharge)
|
||||
self.assertAlmostEqual(ev.dailyTotalBasal, 22.3535, places=4)
|
||||
self.assertAlmostEqual(ev.lastBasalRate, 0.8, places=4)
|
||||
self.assertAlmostEqual(ev.iob, 3.9823, places=4)
|
||||
|
||||
def test_battery_fields_mid_charge(self):
|
||||
ev = Event(self.fixtureMidCharge)
|
||||
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
|
||||
self.assertEqual(ev.batteryChargePercent, 55)
|
||||
self.assertEqual(ev.finalEventForDay, 0)
|
||||
|
||||
def test_battery_fields_mid_charge_later(self):
|
||||
ev = Event(self.fixtureMidChargeLater)
|
||||
self.assertEqual(ev.seqNum, 1046875)
|
||||
self.assertEqual(ev.batteryLipoMilliVolts, 3827)
|
||||
self.assertEqual(ev.batteryChargePercent, 54)
|
||||
self.assertEqual(ev.finalEventForDay, 0)
|
||||
|
||||
def test_lipo_millivolts_is_a_plausible_single_cell_voltage(self):
|
||||
for raw in (self.fixtureMidCharge, self.fixtureMidChargeLater):
|
||||
with self.subTest(raw=raw):
|
||||
ev = Event(raw)
|
||||
self.assertGreater(ev.batteryLipoMilliVolts, 3000)
|
||||
self.assertLess(ev.batteryLipoMilliVolts, 4400)
|
||||
|
||||
def test_final_event_for_day_set(self):
|
||||
ev = Event(self.fixtureFinalEventForDay)
|
||||
self.assertEqual(ev.finalEventForDay, 1)
|
||||
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
|
||||
self.assertEqual(ev.batteryChargePercent, 55)
|
||||
|
||||
def test_battery_charge_percent_is_a_direct_percentage(self):
|
||||
ev = Event(self.fixtureFullCharge)
|
||||
self.assertEqual(ev.batteryChargePercent, 100)
|
||||
self.assertIsInstance(ev.batteryChargePercent, int)
|
||||
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
|
||||
self.assertEqual(ev.finalEventForDay, 0)
|
||||
|
||||
def test_build_from_json(self):
|
||||
ev = Event({
|
||||
"eventCode": 81,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 1046436,
|
||||
"pumpDateTime": "2024-12-03T23:40:23",
|
||||
"eventProperties": {
|
||||
"dailyTotalBasal": 22.3535,
|
||||
"lastBasalRate": 0.8,
|
||||
"iob": 3.9823,
|
||||
"batteryLipoMilliVolts": 3830,
|
||||
"batteryChargePercent": 55,
|
||||
"finalEventForDay": 0,
|
||||
},
|
||||
})
|
||||
self.assertIsInstance(ev, eventtypes.LidDailyBasal)
|
||||
self.assertEqual(ev.batteryLipoMilliVolts, 3830)
|
||||
self.assertEqual(ev.batteryChargePercent, 55)
|
||||
self.assertEqual(ev.finalEventForDay, 0)
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
ev = Event(self.fixtureMidCharge)
|
||||
d = ev.todict()
|
||||
json.dumps(d) # must not raise
|
||||
self.assertEqual(d["id"], 81)
|
||||
self.assertEqual(d["name"], "LID_DAILY_BASAL")
|
||||
self.assertEqual(d["seqNum"], 1046436)
|
||||
self.assertEqual(d["batteryLipoMilliVolts"], 3830)
|
||||
self.assertEqual(d["batteryChargePercent"], 55)
|
||||
self.assertEqual(d["finalEventForDay"], 0)
|
||||
self.assertNotIn("batteryChargePercentMSBRaw", d)
|
||||
self.assertNotIn("batteryChargePercentLSBRaw", d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/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 TestLidNewDay(unittest.TestCase):
|
||||
"""90: LID_NEW_DAY. Real captured pump-log events.
|
||||
|
||||
featuresBitmask / featureBitmaskIndex arrive as JSON arrays but the schema
|
||||
declares them plain (no bitmask transform), so the parser stores the raw
|
||||
value verbatim (the list as-is, e.g. []).
|
||||
"""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
# commandedBasalRate == 0
|
||||
self.fixtureZeroBasal = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 90,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 393109,
|
||||
"pumpDateTime": "2026-04-30T00:00:00",
|
||||
"eventProperties": {
|
||||
"commandedBasalRate": 0,
|
||||
"featuresBitmask": [],
|
||||
"featureBitmaskIndex": [],
|
||||
},
|
||||
"estimatedDateTime": "2026-04-30T00:00:00Z",
|
||||
}
|
||||
# fractional commandedBasalRate
|
||||
self.fixtureFractionalBasal = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 90,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 396368,
|
||||
"pumpDateTime": "2026-05-01T00:00:00",
|
||||
"eventProperties": {
|
||||
"commandedBasalRate": 0.763,
|
||||
"featuresBitmask": [],
|
||||
"featureBitmaskIndex": [],
|
||||
},
|
||||
"estimatedDateTime": "2026-05-01T00:00:00Z",
|
||||
}
|
||||
# integer-valued commandedBasalRate == 1
|
||||
self.fixtureUnitBasal = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 90,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 409989,
|
||||
"pumpDateTime": "2026-05-05T00:00:00",
|
||||
"eventProperties": {
|
||||
"commandedBasalRate": 1,
|
||||
"featuresBitmask": [],
|
||||
"featureBitmaskIndex": [],
|
||||
},
|
||||
"estimatedDateTime": "2026-05-05T00:00:00Z",
|
||||
}
|
||||
|
||||
def test_dispatches_to_lidnewday(self):
|
||||
ev = Event(self.fixtureZeroBasal)
|
||||
self.assertIsInstance(ev, eventtypes.LidNewDay)
|
||||
self.assertIsNot(type(ev), RawEvent)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixtureZeroBasal)
|
||||
self.assertEqual(ev.eventId, 90)
|
||||
self.assertEqual(ev.seqNum, 393109)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
ev = Event(self.fixtureZeroBasal)
|
||||
self.assertEqual(
|
||||
ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2026-04-30T00:00:00",
|
||||
)
|
||||
|
||||
def test_commanded_basal_rate_zero(self):
|
||||
ev = Event(self.fixtureZeroBasal)
|
||||
self.assertEqual(ev.commandedBasalRate, 0)
|
||||
|
||||
def test_commanded_basal_rate_fractional(self):
|
||||
ev = Event(self.fixtureFractionalBasal)
|
||||
self.assertEqual(ev.commandedBasalRate, 0.763)
|
||||
|
||||
def test_commanded_basal_rate_unit(self):
|
||||
ev = Event(self.fixtureUnitBasal)
|
||||
self.assertEqual(ev.commandedBasalRate, 1)
|
||||
|
||||
def test_features_bitmask_holds_list_verbatim(self):
|
||||
# No transform on the schema: the captured list is stored as-is.
|
||||
ev = Event(self.fixtureZeroBasal)
|
||||
self.assertEqual(ev.featuresBitmask, [])
|
||||
self.assertIsInstance(ev.featuresBitmask, list)
|
||||
|
||||
def test_feature_bitmask_index_holds_list_verbatim(self):
|
||||
ev = Event(self.fixtureZeroBasal)
|
||||
self.assertEqual(ev.featureBitmaskIndex, [])
|
||||
self.assertIsInstance(ev.featureBitmaskIndex, list)
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
ev = Event(self.fixtureFractionalBasal)
|
||||
d = ev.todict()
|
||||
json.dumps(d) # must not raise
|
||||
self.assertEqual(d["id"], 90)
|
||||
self.assertEqual(d["name"], "LID_NEW_DAY")
|
||||
self.assertEqual(d["seqNum"], 396368)
|
||||
self.assertEqual(d["commandedBasalRate"], 0.763)
|
||||
self.assertEqual(d["featuresBitmask"], [])
|
||||
self.assertEqual(d["featureBitmaskIndex"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/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 TestLidPumpingResumed(unittest.TestCase):
|
||||
"""12: LID_PUMPING_RESUMED. Real captures all share preResumeState:100 and
|
||||
only differ in insulinAmount; two fixtures show that field varying."""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
# Real captured pump-log events (verbatim).
|
||||
self.fixtureA = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 12,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 394429,
|
||||
"pumpDateTime": "2026-04-30T10:16:31",
|
||||
"eventProperties": {"preResumeState": 100, "insulinAmount": 105},
|
||||
"estimatedDateTime": "2026-04-30T10:16:31Z",
|
||||
}
|
||||
self.fixtureB = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 12,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 478545,
|
||||
"pumpDateTime": "2026-05-24T11:41:19",
|
||||
"eventProperties": {"preResumeState": 100, "insulinAmount": 13},
|
||||
"estimatedDateTime": "2026-05-24T11:41:19Z",
|
||||
}
|
||||
|
||||
def test_dispatches_to_correct_class(self):
|
||||
self.assertIsInstance(Event(self.fixtureA), eventtypes.LidPumpingResumed)
|
||||
self.assertNotIsInstance(Event(self.fixtureA), RawEvent)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixtureA)
|
||||
self.assertEqual(ev.eventId, 12)
|
||||
self.assertEqual(ev.seqNum, 394429)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
ev = Event(self.fixtureA)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2026-04-30T10:16:31")
|
||||
|
||||
def test_plain_fields(self):
|
||||
ev = Event(self.fixtureA)
|
||||
self.assertEqual(ev.preResumeState, 100)
|
||||
self.assertEqual(ev.insulinAmount, 105)
|
||||
|
||||
def test_insulin_amount_varies_across_captures(self):
|
||||
self.assertEqual(Event(self.fixtureA).insulinAmount, 105)
|
||||
self.assertEqual(Event(self.fixtureB).insulinAmount, 13)
|
||||
# preResumeState is constant across real captures.
|
||||
self.assertEqual(Event(self.fixtureB).preResumeState, 100)
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
ev = Event(self.fixtureB)
|
||||
d = ev.todict()
|
||||
json.dumps(d) # must not raise
|
||||
self.assertEqual(d["id"], 12)
|
||||
self.assertEqual(d["name"], "LID_PUMPING_RESUMED")
|
||||
self.assertEqual(d["seqNum"], 478545)
|
||||
self.assertEqual(d["preResumeState"], 100)
|
||||
self.assertEqual(d["insulinAmount"], 13)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/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 TestLidPumpingSuspended(unittest.TestCase):
|
||||
"""11: LID_PUMPING_SUSPENDED. All real captures share suspendReason:0
|
||||
(UserAborted); only insulinAmount varies, so two captures suffice."""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
# Real captured pump-log events (verbatim), eventCode 11.
|
||||
self.fixtureA = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 11,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 394335,
|
||||
"pumpDateTime": "2026-04-30T10:01:49",
|
||||
"eventProperties": {
|
||||
"preSuspendState": 106, "insulinAmount": 120,
|
||||
"suspendReason": 0, "rpaTimeout": 15,
|
||||
},
|
||||
"estimatedDateTime": "2026-04-30T10:01:49Z",
|
||||
}
|
||||
self.fixtureB = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 11,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 401271,
|
||||
"pumpDateTime": "2026-05-02T11:49:43",
|
||||
"eventProperties": {
|
||||
"preSuspendState": 106, "insulinAmount": 150,
|
||||
"suspendReason": 0, "rpaTimeout": 15,
|
||||
},
|
||||
"estimatedDateTime": "2026-05-02T11:49:43Z",
|
||||
}
|
||||
|
||||
def test_dispatches_to_correct_class(self):
|
||||
self.assertIsInstance(Event(self.fixtureA), eventtypes.LidPumpingSuspended)
|
||||
self.assertNotIsInstance(Event(self.fixtureA), RawEvent)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixtureA)
|
||||
self.assertEqual(ev.eventId, 11)
|
||||
self.assertEqual(ev.seqNum, 394335)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
ev = Event(self.fixtureA)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2026-04-30T10:01:49")
|
||||
|
||||
def test_plain_fields_round_trip(self):
|
||||
ev = Event(self.fixtureA)
|
||||
self.assertEqual(ev.preSuspendState, 106)
|
||||
self.assertEqual(ev.insulinAmount, 120)
|
||||
self.assertEqual(ev.rpaTimeout, 15)
|
||||
|
||||
def test_suspendreason_resolves_to_enum(self):
|
||||
# suspendReason:0 -> UserAborted
|
||||
ev = Event(self.fixtureA)
|
||||
self.assertEqual(ev.suspendReasonRaw, 0)
|
||||
self.assertEqual(ev.suspendReason,
|
||||
eventtypes.LidPumpingSuspended.SuspendreasonEnum.UserAborted)
|
||||
|
||||
def test_second_capture_distinct_insulin_amount(self):
|
||||
ev = Event(self.fixtureB)
|
||||
self.assertEqual(ev.seqNum, 401271)
|
||||
self.assertEqual(ev.insulinAmount, 150)
|
||||
self.assertEqual(ev.suspendReason,
|
||||
eventtypes.LidPumpingSuspended.SuspendreasonEnum.UserAborted)
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
ev = Event(self.fixtureA)
|
||||
d = ev.todict()
|
||||
json.dumps(d) # must not raise
|
||||
self.assertEqual(d["id"], 11)
|
||||
self.assertEqual(d["name"], "LID_PUMPING_SUSPENDED")
|
||||
self.assertEqual(d["insulinAmount"], 120)
|
||||
self.assertEqual(d["suspendReasonRaw"], 0)
|
||||
|
||||
|
||||
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 TestLidTimeChanged(unittest.TestCase):
|
||||
"""13: LID_TIME_CHANGED. Real captures from the clockChanges array.
|
||||
timePrior/timeAfter are ms-of-day; compare them to see whether the clock
|
||||
moved forward or backward."""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
# Real capture: clock moved forward (timeAfter > timePrior).
|
||||
self.fixtureForward = {
|
||||
"deviceAssignmentId": "73aeb403-1d22-4d12-a3fd-229e5b6641ee",
|
||||
"eventCode": 13,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 182932,
|
||||
"pumpDateTime": "2025-09-11T10:28:11",
|
||||
"eventProperties": {
|
||||
"timePrior": 12513657, "timeAfter": 37691000,
|
||||
"rawRtcTime": 1380540797,
|
||||
},
|
||||
"estimatedDateTime": "2025-09-11T10:28:11Z",
|
||||
}
|
||||
# Real capture: clock moved backward (timeAfter < timePrior).
|
||||
self.fixtureBackward = {
|
||||
"deviceAssignmentId": "73aeb403-1d22-4d12-a3fd-229e5b6641ee",
|
||||
"eventCode": 13,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 364365,
|
||||
"pumpDateTime": "2025-11-02T15:27:37",
|
||||
"eventProperties": {
|
||||
"timePrior": 59278139, "timeAfter": 55657000,
|
||||
"rawRtcTime": 1625159580,
|
||||
},
|
||||
"estimatedDateTime": "2025-11-02T15:27:37Z",
|
||||
}
|
||||
|
||||
def test_dispatches_to_lidtimechanged(self):
|
||||
self.assertIsInstance(Event(self.fixtureForward), eventtypes.LidTimeChanged)
|
||||
self.assertIsInstance(Event(self.fixtureBackward), eventtypes.LidTimeChanged)
|
||||
self.assertNotIsInstance(Event(self.fixtureForward), RawEvent)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixtureForward)
|
||||
self.assertEqual(ev.eventId, 13)
|
||||
self.assertEqual(ev.seqNum, 182932)
|
||||
|
||||
ev = Event(self.fixtureBackward)
|
||||
self.assertEqual(ev.eventId, 13)
|
||||
self.assertEqual(ev.seqNum, 364365)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
# eventTimestamp keeps pumpDateTime's wall-clock (tz forced to the
|
||||
# configured TIMEZONE_NAME), so the naive portion round-trips exactly.
|
||||
ev = Event(self.fixtureForward)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2025-09-11T10:28:11")
|
||||
|
||||
ev = Event(self.fixtureBackward)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2025-11-02T15:27:37")
|
||||
|
||||
def test_time_fields_round_trip(self):
|
||||
ev = Event(self.fixtureForward)
|
||||
self.assertEqual(ev.timePrior, 12513657)
|
||||
self.assertEqual(ev.timeAfter, 37691000)
|
||||
self.assertEqual(ev.rawRtcTime, 1380540797)
|
||||
|
||||
ev = Event(self.fixtureBackward)
|
||||
self.assertEqual(ev.timePrior, 59278139)
|
||||
self.assertEqual(ev.timeAfter, 55657000)
|
||||
self.assertEqual(ev.rawRtcTime, 1625159580)
|
||||
|
||||
def test_forward_and_backward_direction(self):
|
||||
# timeAfter > timePrior means the clock jumped forward, and vice versa.
|
||||
fwd = Event(self.fixtureForward)
|
||||
self.assertGreater(fwd.timeAfter, fwd.timePrior)
|
||||
|
||||
bwd = Event(self.fixtureBackward)
|
||||
self.assertLess(bwd.timeAfter, bwd.timePrior)
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
for fixture in (self.fixtureForward, self.fixtureBackward):
|
||||
ev = Event(fixture)
|
||||
d = ev.todict()
|
||||
json.dumps(d) # must not raise
|
||||
self.assertEqual(d["id"], 13)
|
||||
self.assertEqual(d["name"], "LID_TIME_CHANGED")
|
||||
self.assertEqual(d["timePrior"], fixture["eventProperties"]["timePrior"])
|
||||
self.assertEqual(d["timeAfter"], fixture["eventProperties"]["timeAfter"])
|
||||
self.assertEqual(d["rawRtcTime"], fixture["eventProperties"]["rawRtcTime"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/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 TestLidTubingFilled(unittest.TestCase):
|
||||
"""63: LID_TUBING_FILLED, parsed from real captured pump-log events.
|
||||
|
||||
Every captured code-63 event carries primeSize=-1 (a sentinel, not a real
|
||||
fill volume) and completionStatus=3 (Completed); fixtures differ in
|
||||
seqNum/position/wall-clock.
|
||||
"""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
# Real capture: primeSize sentinel (-1), completionStatus Completed (3).
|
||||
self.fixtureNegativePrimeSize = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 63,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 394428,
|
||||
"pumpDateTime": "2026-04-30T10:16:09",
|
||||
"eventProperties": {"primeSize": -1, "completionStatus": 3, "position": 631224},
|
||||
"estimatedDateTime": "2026-04-30T10:16:09Z",
|
||||
}
|
||||
# Second real capture with a different seqNum/position/timestamp.
|
||||
self.fixtureCompleted = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 63,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 401423,
|
||||
"pumpDateTime": "2026-05-02T12:27:17",
|
||||
"eventProperties": {"primeSize": -1, "completionStatus": 3, "position": 594056},
|
||||
"estimatedDateTime": "2026-05-02T12:27:17Z",
|
||||
}
|
||||
|
||||
def test_dispatches_to_lidtubingfilled(self):
|
||||
ev = Event(self.fixtureNegativePrimeSize)
|
||||
self.assertIsInstance(ev, eventtypes.LidTubingFilled)
|
||||
self.assertNotIsInstance(ev, RawEvent)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixtureNegativePrimeSize)
|
||||
self.assertEqual(ev.eventId, 63)
|
||||
self.assertEqual(ev.seqNum, 394428)
|
||||
self.assertEqual(ev.NAME, "LID_TUBING_FILLED")
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
ev = Event(self.fixtureNegativePrimeSize)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2026-04-30T10:16:09")
|
||||
|
||||
def test_negative_primesize_sentinel_round_trips(self):
|
||||
# -1 is a sentinel and must survive verbatim (not treated as missing).
|
||||
ev = Event(self.fixtureNegativePrimeSize)
|
||||
self.assertEqual(ev.primeSize, -1)
|
||||
|
||||
def test_position_round_trips(self):
|
||||
ev = Event(self.fixtureNegativePrimeSize)
|
||||
self.assertEqual(ev.position, 631224)
|
||||
|
||||
def test_completionstatus_resolves_to_completed(self):
|
||||
ev = Event(self.fixtureNegativePrimeSize)
|
||||
self.assertEqual(ev.completionStatusRaw, 3)
|
||||
self.assertEqual(ev.completionStatus,
|
||||
eventtypes.LidTubingFilled.CompletionstatusEnum.Completed)
|
||||
|
||||
def test_second_capture_distinct_values(self):
|
||||
ev = Event(self.fixtureCompleted)
|
||||
self.assertEqual(ev.seqNum, 401423)
|
||||
self.assertEqual(ev.position, 594056)
|
||||
self.assertEqual(ev.primeSize, -1)
|
||||
self.assertEqual(ev.completionStatus,
|
||||
eventtypes.LidTubingFilled.CompletionstatusEnum.Completed)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2026-05-02T12:27:17")
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
ev = Event(self.fixtureNegativePrimeSize)
|
||||
td = ev.todict()
|
||||
json.dumps(td) # must not raise
|
||||
self.assertEqual(td["id"], 63)
|
||||
self.assertEqual(td["name"], "LID_TUBING_FILLED")
|
||||
self.assertEqual(td["seqNum"], 394428)
|
||||
self.assertEqual(td["primeSize"], -1)
|
||||
self.assertEqual(td["completionStatusRaw"], 3)
|
||||
self.assertEqual(td["position"], 631224)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/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 TestLidVersionsA(unittest.TestCase):
|
||||
"""307 / LID_VERSIONS_A: four plain uint32 version/part-number fields.
|
||||
Only one distinct capture shape exists, so a single real fixture suffices."""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
# Real captured LID_VERSIONS_A event, copied verbatim.
|
||||
self.fixtureVersions = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 307,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 393111,
|
||||
"pumpDateTime": "2026-04-30T00:00:06",
|
||||
"eventProperties": {
|
||||
"armPartNumber": 1016587,
|
||||
"armSwVersion": 1108201743,
|
||||
"blePartNumber": 1016587,
|
||||
"bleSwVersion": 1108201743,
|
||||
},
|
||||
"estimatedDateTime": "2026-04-30T00:00:06Z",
|
||||
}
|
||||
|
||||
def test_dispatches_to_lidversionsa(self):
|
||||
ev = Event(self.fixtureVersions)
|
||||
self.assertIsInstance(ev, eventtypes.LidVersionsA)
|
||||
self.assertNotIsInstance(ev, RawEvent)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(self.fixtureVersions)
|
||||
self.assertEqual(ev.eventId, 307)
|
||||
self.assertEqual(ev.seqNum, 393111)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
ev = Event(self.fixtureVersions)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2026-04-30T00:00:06")
|
||||
|
||||
def test_plain_fields_round_trip(self):
|
||||
ev = Event(self.fixtureVersions)
|
||||
self.assertEqual(ev.armPartNumber, 1016587)
|
||||
self.assertEqual(ev.armSwVersion, 1108201743)
|
||||
self.assertEqual(ev.blePartNumber, 1016587)
|
||||
self.assertEqual(ev.bleSwVersion, 1108201743)
|
||||
|
||||
def test_todict_is_json_serializable(self):
|
||||
ev = Event(self.fixtureVersions)
|
||||
d = ev.todict()
|
||||
json.dumps(d) # must not raise
|
||||
self.assertEqual(d["id"], 307)
|
||||
self.assertEqual(d["name"], "LID_VERSIONS_A")
|
||||
self.assertEqual(d["seqNum"], 393111)
|
||||
self.assertEqual(d["armPartNumber"], 1016587)
|
||||
self.assertEqual(d["armSwVersion"], 1108201743)
|
||||
self.assertEqual(d["blePartNumber"], 1016587)
|
||||
self.assertEqual(d["bleSwVersion"], 1108201743)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
|
||||
from tconnectsync.eventparser.generic import Event, Events
|
||||
from tconnectsync.eventparser import events as eventtypes
|
||||
from tconnectsync.eventparser.raw_event import RawEvent
|
||||
|
||||
# Trimmed real pump-log events (values from a captured account response).
|
||||
BASAL_279 = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 279,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 393131,
|
||||
"pumpDateTime": "2026-04-30T00:03:29",
|
||||
"eventProperties": {
|
||||
"commandedRateSource": 3, "reservedA2": 0, "spareA3": 0,
|
||||
"commandedRate": 0, "profileBasalRate": 1000, "algorithmRate": 0,
|
||||
"tempRate": 65535,
|
||||
},
|
||||
"estimatedDateTime": "2026-04-30T00:03:29Z",
|
||||
}
|
||||
|
||||
ALARM_5 = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 5,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 500001,
|
||||
"pumpDateTime": "2026-04-30T01:00:00",
|
||||
"eventProperties": {"alarmId": 18, "faultLocatorData": 8311, "param1": 3993668, "param2": 0},
|
||||
"estimatedDateTime": "2026-04-30T01:00:00Z",
|
||||
}
|
||||
|
||||
# Real LID_CGM_DATA_G7 event: enum (glucoseValueStatus), ratio (rate ×0.1),
|
||||
# raw egv seconds (egvTimeStamp), and bitmask arrays (cgmDataType, egvInfoBitmask).
|
||||
CGM_399 = {
|
||||
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
|
||||
"eventCode": 399,
|
||||
"sequenceGroup": 0,
|
||||
"sequenceNumber": 441314,
|
||||
"pumpDateTime": "2026-05-14T00:01:31",
|
||||
"eventProperties": {
|
||||
"glucoseValueStatus": 0, "cgmDataType": [0], "rate": -6,
|
||||
"algorithmState": 32, "rssi": -78, "currentGlucoseDisplayValue": 167,
|
||||
"egvTimeStamp": 579571288, "egvInfoBitmask": [0, 5, 6, 7, 8, 11, 12],
|
||||
"interval": 0, "reservedD15": 0,
|
||||
},
|
||||
"estimatedDateTime": "2026-05-14T00:01:31Z",
|
||||
}
|
||||
|
||||
# Real LID_AA_USER_MODE_CHANGE event: enum (requestedAction) and a bitmask
|
||||
# array (activeSleepSchedule).
|
||||
UMC_229 = {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
class TestBuildFromJson(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def test_dispatches_to_correct_class(self):
|
||||
self.assertIsInstance(Event(BASAL_279), eventtypes.LidBasalDelivery)
|
||||
self.assertIsInstance(Event(ALARM_5), eventtypes.LidAlarmActivated)
|
||||
|
||||
def test_plain_fields(self):
|
||||
ev = Event(BASAL_279)
|
||||
self.assertEqual(ev.commandedRate, 0)
|
||||
self.assertEqual(ev.profileBasalRate, 1000)
|
||||
self.assertEqual(ev.tempRate, 65535)
|
||||
|
||||
def test_envelope_fields(self):
|
||||
ev = Event(BASAL_279)
|
||||
self.assertEqual(ev.seqNum, 393131)
|
||||
self.assertEqual(ev.eventId, 279)
|
||||
|
||||
def test_timestamp_preserves_wall_clock(self):
|
||||
# eventTimestamp keeps pumpDateTime's wall-clock (tz forced to the
|
||||
# configured TIMEZONE_NAME), so the naive portion round-trips exactly.
|
||||
ev = Event(BASAL_279)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-04-30T00:03:29")
|
||||
|
||||
def test_missing_plain_field_defaults_to_none(self):
|
||||
event = dict(BASAL_279)
|
||||
event["eventProperties"] = {k: v for k, v in BASAL_279["eventProperties"].items() if k != "tempRate"}
|
||||
ev = Event(event)
|
||||
self.assertIsNone(ev.tempRate)
|
||||
self.assertEqual(ev.commandedRate, 0) # others still parse
|
||||
|
||||
def test_extra_keys_are_ignored(self):
|
||||
event = dict(BASAL_279)
|
||||
event["eventProperties"] = dict(BASAL_279["eventProperties"], someFutureField=42)
|
||||
ev = Event(event) # must not raise
|
||||
self.assertFalse(hasattr(ev, "someFutureField"))
|
||||
|
||||
def test_events_from_json_yields_in_order(self):
|
||||
out = list(Events([BASAL_279, ALARM_5]))
|
||||
self.assertEqual([type(e).__name__ for e in out],
|
||||
["LidBasalDelivery", "LidAlarmActivated"])
|
||||
|
||||
def test_unknown_eventcode_falls_back_to_rawevent(self):
|
||||
ev = Event({
|
||||
"eventCode": 99999,
|
||||
"sequenceNumber": 7,
|
||||
"pumpDateTime": "2026-04-30T00:00:00",
|
||||
"eventProperties": {},
|
||||
})
|
||||
self.assertIs(type(ev), RawEvent)
|
||||
self.assertEqual(ev.eventId, 99999)
|
||||
self.assertEqual(ev.seqNum, 7)
|
||||
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-04-30T00:00:00")
|
||||
|
||||
|
||||
class TestEnumAndRatioFields(unittest.TestCase):
|
||||
"""#13: enum/dictionary fields arrive as raw ints and resolve through the
|
||||
generated {field}Raw attr; ratio fields (×0.1) still compute."""
|
||||
maxDiff = None
|
||||
|
||||
def test_enum_resolves_from_raw_int(self):
|
||||
# commandedRateSource:3 -> Algorithm
|
||||
ev = Event(BASAL_279)
|
||||
self.assertEqual(ev.commandedRateSourceRaw, 3)
|
||||
self.assertEqual(ev.commandedRateSource,
|
||||
eventtypes.LidBasalDelivery.CommandedratesourceEnum.Algorithm)
|
||||
|
||||
def test_dictionary_enum_resolves_from_raw_int(self):
|
||||
# alarmId:18 -> ResumePumpAlarm (stored on the alarmidRaw attr)
|
||||
ev = Event(ALARM_5)
|
||||
self.assertEqual(ev.alarmIdRaw, 18)
|
||||
self.assertEqual(ev.alarmId,
|
||||
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm)
|
||||
|
||||
def test_multiple_enums_on_one_event(self):
|
||||
# requestedAction:2 -> StopSleep; previousUserMode:1 -> Sleeping
|
||||
ev = Event(UMC_229)
|
||||
self.assertEqual(ev.requestedAction,
|
||||
eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
|
||||
self.assertEqual(ev.previousUserMode,
|
||||
eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
|
||||
|
||||
def test_ratio_field_scales(self):
|
||||
# rate:-6 -> -0.6 mg/dL/min (rateRaw ×0.1)
|
||||
ev = Event(CGM_399)
|
||||
self.assertEqual(ev.rateRaw, -6)
|
||||
self.assertAlmostEqual(ev.rate, -0.6)
|
||||
|
||||
def test_enum_zero_value_resolves(self):
|
||||
# glucoseValueStatus:0 -> PreciseValue (0 must not be treated as missing)
|
||||
ev = Event(CGM_399)
|
||||
self.assertEqual(ev.glucoseValueStatusRaw, 0)
|
||||
self.assertEqual(ev.glucoseValueStatus,
|
||||
eventtypes.LidCgmDataG7.GlucosevaluestatusEnum.PreciseValue)
|
||||
|
||||
|
||||
class TestBitmaskFields(unittest.TestCase):
|
||||
"""#14: bitmask fields arrive as arrays of set-bit indices and must be
|
||||
folded back into the raw int the {field}Raw attr / IntFlag expects."""
|
||||
maxDiff = None
|
||||
|
||||
def test_single_bit_array(self):
|
||||
# activeSleepSchedule:[0] -> 1<<0 == 1
|
||||
ev = Event(UMC_229)
|
||||
self.assertEqual(ev.activeSleepScheduleRaw, 1)
|
||||
self.assertEqual(ev.activeSleepSchedule,
|
||||
eventtypes.LidAaUserModeChange.ActivesleepscheduleBitmask.SleepSchedule1IsActive)
|
||||
|
||||
def test_cgm_datatype_array(self):
|
||||
# cgmDataType:[0] -> 1<<0 == 1 -> Fmr
|
||||
ev = Event(CGM_399)
|
||||
self.assertEqual(ev.cgmDataTypeRaw, 1)
|
||||
self.assertEqual(ev.cgmDataType,
|
||||
eventtypes.LidCgmDataG7.CgmdatatypeBitmask.Fmr)
|
||||
|
||||
def test_multi_bit_array_round_trips_to_int(self):
|
||||
# egvInfoBitmask:[0,5,6,7,8,11,12] -> sum(1<<i) == 6625
|
||||
ev = Event(CGM_399)
|
||||
self.assertEqual(ev.egvInfoBitmaskRaw,
|
||||
sum(1 << i for i in [0, 5, 6, 7, 8, 11, 12]))
|
||||
self.assertEqual(ev.egvInfoBitmaskRaw, 6625)
|
||||
|
||||
def test_empty_bitmask_array(self):
|
||||
# An empty array must fold to 0, not None (matches the byte path).
|
||||
event = dict(UMC_229)
|
||||
event["eventProperties"] = dict(UMC_229["eventProperties"], activeSleepSchedule=[])
|
||||
ev = Event(event)
|
||||
self.assertEqual(ev.activeSleepScheduleRaw, 0)
|
||||
|
||||
|
||||
class TestRawFieldShims(unittest.TestCase):
|
||||
"""#15: process_device_status sorts on event.raw.timestamp; ProcessCGMReading
|
||||
reads event.egvTimeStamp as raw seconds — both must survive the JSON path."""
|
||||
maxDiff = None
|
||||
|
||||
def test_raw_timestamp_shim_available(self):
|
||||
# process_device_status uses `sorted(events, key=lambda x: x.raw.timestamp)`,
|
||||
# so adapted events must expose raw.timestamp as the wall-clock instant.
|
||||
ev = Event(BASAL_279)
|
||||
self.assertEqual(ev.raw.timestamp.format('YYYY-MM-DDTHH:mm:ss'),
|
||||
"2026-04-30T00:03:29")
|
||||
|
||||
def test_cgm_egv_timestamp_is_raw_seconds(self):
|
||||
# egvTimeStamp (camelCase in the JSON) normalizes onto egvTimestamp and is
|
||||
# kept as a raw seconds int; ProcessCGMReading adds TANDEM_EPOCH to it.
|
||||
ev = Event(CGM_399)
|
||||
self.assertEqual(ev.egvTimeStamp, 579571288)
|
||||
self.assertEqual(ev.currentGlucoseDisplayValue, 167)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException, tandem_to_ns_time, tandem_to_ns_time_seconds
|
||||
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
|
||||
from .test_profile_data import DEVICE_PROFILE_A, DEVICE_SETTINGS, NS_PROFILE_A
|
||||
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException
|
||||
|
||||
class TestNightscoutEntry(unittest.TestCase):
|
||||
maxDiff = None
|
||||
@@ -189,38 +187,5 @@ class TestNightscoutEntry(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
def test_profile_store(self):
|
||||
self.assertEqual(
|
||||
NightscoutEntry.profile_store(
|
||||
profile=DEVICE_PROFILE_A,
|
||||
device_settings=DEVICE_SETTINGS
|
||||
),
|
||||
NS_PROFILE_A
|
||||
)
|
||||
|
||||
|
||||
class TestTandemNightscoutTime(unittest.TestCase):
|
||||
def test_tandem_to_ns_time(self):
|
||||
self.assertEqual(tandem_to_ns_time('12:00 AM'), '00:00')
|
||||
self.assertEqual(tandem_to_ns_time('12:30 AM'), '00:30')
|
||||
self.assertEqual(tandem_to_ns_time('6:00 AM'), '06:00')
|
||||
self.assertEqual(tandem_to_ns_time('6:30 AM'), '06:30')
|
||||
self.assertEqual(tandem_to_ns_time('11:30 AM'), '11:30')
|
||||
self.assertEqual(tandem_to_ns_time('12:00 PM'), '12:00')
|
||||
self.assertEqual(tandem_to_ns_time('12:30 PM'), '12:30')
|
||||
self.assertEqual(tandem_to_ns_time('06:30 PM'), '18:30')
|
||||
self.assertEqual(tandem_to_ns_time('11:30 PM'), '23:30')
|
||||
|
||||
def test_tandem_to_ns_time_seconds(self):
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:00 AM'), 0)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:30 AM'), 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('6:00 AM'), 6*60*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('6:30 AM'), 6*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('11:30 AM'), 11*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:00 PM'), 12*60*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('12:30 PM'), 12*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('06:30 PM'), 12*60*60 + 6*60*60 + 30*60)
|
||||
self.assertEqual(tandem_to_ns_time_seconds('11:30 PM'), 12*60*60 + 11*60*60 + 30*60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,270 +0,0 @@
|
||||
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
|
||||
from tconnectsync.secret import NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE, TIMEZONE_NAME
|
||||
|
||||
|
||||
DEVICE_PROFILE_A = Profile(
|
||||
title='A',
|
||||
active=False,
|
||||
segments=[
|
||||
ProfileSegment(
|
||||
display_time='Midnight',
|
||||
time='12:00 AM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='6:00 AM',
|
||||
time='6:00 AM',
|
||||
basal_rate=1.25,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='11:00 AM',
|
||||
time='11:00 AM',
|
||||
basal_rate=1.0,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='Noon',
|
||||
time='12:00 PM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=6.0,
|
||||
target_bg_mgdl=110.0)
|
||||
],
|
||||
calculated_total_daily_basal=21.65,
|
||||
insulin_duration_min=300,
|
||||
carbs_enabled=True
|
||||
)
|
||||
|
||||
DEVICE_PROFILE_B = Profile(
|
||||
title='B',
|
||||
active=False,
|
||||
segments=[
|
||||
ProfileSegment(
|
||||
display_time='Midnight',
|
||||
time='12:00 AM',
|
||||
basal_rate=0.8,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='6:00 AM',
|
||||
time='6:00 AM',
|
||||
basal_rate=1.25,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='11:00 AM',
|
||||
time='11:00 AM',
|
||||
basal_rate=1.0,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0),
|
||||
ProfileSegment(
|
||||
display_time='Noon',
|
||||
time='12:00 PM',
|
||||
basal_rate=0.9,
|
||||
correction_factor=30.0,
|
||||
carb_ratio=12.0,
|
||||
target_bg_mgdl=110.0)
|
||||
],
|
||||
calculated_total_daily_basal=22.85,
|
||||
insulin_duration_min=300,
|
||||
carbs_enabled=True
|
||||
)
|
||||
|
||||
DEVICE_SETTINGS = DeviceSettings(
|
||||
low_bg_threshold=80,
|
||||
high_bg_threshold=200,
|
||||
raw_settings={}
|
||||
)
|
||||
|
||||
NS_PROFILE_A = {
|
||||
"dia": "5.0",
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 6.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 6.0
|
||||
}
|
||||
],
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
"sens": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 30.0
|
||||
}
|
||||
],
|
||||
"basal": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 0.8
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 1.25
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 1.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 0.8
|
||||
}
|
||||
],
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 200
|
||||
}
|
||||
],
|
||||
"timezone": TIMEZONE_NAME,
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
NS_PROFILE_B = {
|
||||
"dia": "5.0",
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 12.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 12.0
|
||||
}
|
||||
],
|
||||
|
||||
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
|
||||
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
|
||||
"sens": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 30.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 30.0
|
||||
}
|
||||
],
|
||||
"basal": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 0.8
|
||||
},
|
||||
{
|
||||
"time": "06:00",
|
||||
"timeAsSeconds": 6*60*60,
|
||||
"value": 1.25
|
||||
},
|
||||
{
|
||||
"time": "11:00",
|
||||
"timeAsSeconds": 11*60*60,
|
||||
"value": 1.0
|
||||
},
|
||||
{
|
||||
"time": "12:00",
|
||||
"timeAsSeconds": 12*60*60,
|
||||
"value": 0.9
|
||||
}
|
||||
],
|
||||
"target_low": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"target_high": [
|
||||
{
|
||||
"time": "00:00",
|
||||
"timeAsSeconds": 0,
|
||||
"value": 200
|
||||
}
|
||||
],
|
||||
"timezone": TIMEZONE_NAME,
|
||||
"startDate": "1970-01-01T00:00:00.000Z",
|
||||
"units": "mg/dl"
|
||||
}
|
||||
|
||||
NS_PROFILE_STORE = {
|
||||
'A': NS_PROFILE_A,
|
||||
'B': NS_PROFILE_B
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user