Compare commits

...
69 Commits
Author SHA1 Message Date
James Woglom aea3f6bd6b v2.3.3 2025-11-23 00:03:34 -05:00
James Woglom 2bf40cac28 fix TypeError: can't compare offset-naive and offset-aware datetimes 2025-11-23 00:03:16 -05:00
James Woglom 73dabe5ead v2.3.2 2025-11-18 14:01:18 -05:00
James Woglom e848a8482d add new process test for tz change 2025-11-18 14:00:54 -05:00
James Woglom 9c06efb045 when pump TZ changes moves date into the future, do not add "current" basal to extend to that timestamp 2025-11-18 13:59:17 -05:00
James Woglom 64aeb4d752 v2.3.1 2025-06-08 17:17:13 -04:00
James Woglom 6a9a426b97 explicit time_start/time_end for autoupdate 2025-06-08 11:07:57 -04:00
samfundevandJames Woglom 5a76a4cfd1 Fix auto-update only running for 24 hours
Fixes #11
2025-06-08 11:06:24 -04:00
James Woglom 4837ac1e86 v2.3.0 2025-06-07 23:34:42 -04:00
James Woglom 8b15ec5c90 add test_profile_data 2025-06-07 23:31:10 -04:00
James Woglom 37b008f6c3 fix test path 2025-06-07 23:29:09 -04:00
James Woglom 8c40fdb533 fix test import 2025-06-07 23:27:42 -04:00
James Woglom d51080f6d0 Fix profile test 2025-06-07 23:24:41 -04:00
James Woglom b32415aaac Delete dead non-tandem source tests 2025-06-07 23:21:38 -04:00
James Woglom ffd3c5b1ea Delete dead non-tandem source code 2025-06-07 23:21:18 -04:00
James Woglom 0b4674bae9 update check script 2025-06-07 23:19:23 -04:00
James Woglom a1202decbe ignore name never assigned in scope flake8 2025-06-07 22:53:18 -04:00
Adam BovillandJames Woglom 4888426034 feat: Add EU region support for Tandem t:connect API
Add comprehensive support for European Tandem t:connect servers alongside existing US support.

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

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

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

This enables EU Tandem pump users to sync their data using:
  --region EU or TCONNECT_REGION=EU
2025-06-07 22:48:43 -04:00
James Woglom 803b15b886 v2.2.4 2025-05-06 13:14:01 -04:00
JoshandJames Woglom 9e78fe770f chore: add main py for module finding in vscode 2025-05-06 13:13:19 -04:00
JoshandJames Woglom dfb39e07ef warn: add warning if selected pump serial has no recent events 2025-05-06 13:13:19 -04:00
JoshandJames Woglom 9fad0d71ef chore: update gitignore with venv and vscode dirs 2025-05-06 13:13:19 -04:00
James Woglom f5cd7c0151 v2.2.3 2025-03-29 23:37:15 -04:00
James Woglom 13bb106924 defaults: failure time to 75 min, turn off auto restart on failure 2025-03-29 23:36:53 -04:00
James Woglom fd96b08aab stop running pipenv check 2025-01-15 19:14:20 -05:00
James Woglom 4a8da39b44 process: automatically fetch all event types for devicestatus feature 2025-01-15 18:56:36 -05:00
James Woglom 7a0f9477f2 eventparser: add todict function on all event classes 2025-01-15 18:56:08 -05:00
James Woglom 934d90e3b9 log found ProcessDeviceStatus 2025-01-02 20:05:31 -05:00
James Woglom ea84b0b9cc v2.2.2 2024-12-30 22:05:22 -05:00
James Woglom 07aaea9e1c fix tests 2024-12-30 22:05:11 -05:00
James Woglom 67b9f42f9b v2.2.1 2024-12-30 22:02:54 -05:00
James Woglom 997ab7bac8 attempt to fix batteryChargePercent 2024-12-30 22:02:36 -05:00
James Woglom b1f8263ceb v2.2.0 2024-12-13 19:47:29 -05:00
James Woglom 29d08a7f1b fix non-autoupdate 2024-12-13 19:47:01 -05:00
James Woglom c7ade49eea devicestatus: tune nightscout output to look more like Loops uploader 2024-12-13 02:09:36 -05:00
James Woglom 1f0fbd7788 less noisy logs in update profiles 2024-12-13 01:42:58 -05:00
James Woglom 4bda0a21f2 more explicit logging for last update seen 2024-12-13 01:41:16 -05:00
James Woglom d1341e09c0 devicestatus: set pump.battery.percent 2024-12-12 22:20:13 -05:00
James Woglom fb9588ff2c catch iso8601 date errors 2024-12-12 00:41:51 -05:00
James Woglom 2339b4543e v2.1.9 2024-12-12 00:36:16 -05:00
James Woglom 3f6010fafc add processdevicestatus 2024-12-12 00:36:06 -05:00
James Woglom a028cdf3fe v2.1.8 2024-12-12 00:29:19 -05:00
James Woglom 093b42ff94 fix codecov 2024-12-12 00:29:11 -05:00
James Woglom 00023c6aba fix flake8 2024-12-12 00:27:35 -05:00
James Woglom 1ed3d35965 v2.1.7 2024-12-12 00:23:45 -05:00
James Woglom 9493b6546f process_alarm exclude resumepumpalarm and test 2024-12-12 00:23:17 -05:00
James Woglom 24a635ca21 add process_device_status and process_user_mode tests 2024-12-12 00:09:28 -05:00
James Woglom 80824906d9 add unit test 2024-12-11 22:41:58 -05:00
James Woglom e5259a7981 attempt to mutate battery charge percent 2024-12-11 00:56:01 -05:00
James Woglom 650fb60988 support excluding event id filter 2024-12-11 00:50:40 -05:00
James Woglom 2b367d45f3 eventparser: use eventID consistently as seqNum 2024-12-10 20:35:17 -05:00
James Woglom dd2dcd17bd eventparser: add custom events 2024-12-10 17:48:51 -05:00
James Woglom 98b7edc904 eventparser: handle unknown ids in Event() 2024-12-10 17:44:54 -05:00
James Woglom e82d311fd6 eventparser: move baseevent 2024-12-10 17:44:32 -05:00
James Woglom 9a2a4ca771 v2.1.6 2024-10-16 21:34:22 -04:00
James Woglom 54ba2ed7df profiles: sort and set enteredBy 2024-10-16 21:34:11 -04:00
James Woglom 2892eb8ace exclude empty pump profile segments 2024-10-16 21:13:41 -04:00
James Woglom 1d432982ad v2.1.5 with pypi upload fix 2024-10-07 21:59:01 -04:00
James Woglom 891c2e2c10 fix printf 2024-10-07 21:30:50 -04:00
James Woglom 7dc479c84b v2.1.4 2024-10-05 12:56:17 -04:00
James Woglom 0417fb9fd8 add IGNORE_ZERO_UNIT_BASAL 2024-10-05 12:55:59 -04:00
James Woglom b80d52662b v2.1.3 2024-10-03 01:00:16 -04:00
Zack FernandesandJames Woglom 51ac469f00 Fix choose_device import class name 2024-10-03 00:59:45 -04:00
James Woglom 81b13f236a v2.1.2 2024-10-02 23:07:43 -04:00
James Woglom d57b3872f1 fix process_user_mode again 2024-10-02 23:07:34 -04:00
James Woglom 0609a46ed2 v2.1.1 2024-10-02 22:23:39 -04:00
James Woglom e63d725c2b process_user_mode: fix bug when exercise concludes 2024-10-02 22:23:24 -04:00
James Woglom fec964e767 v2.1.0 2024-10-02 02:14:28 -04:00
James Woglom 366182f65b fix timezone transformation logic 2024-10-02 02:14:01 -04:00
68 changed files with 2797 additions and 3796 deletions
+2 -2
View File
@@ -5,9 +5,9 @@ coverage:
project:
default: false
tconnectsync:
paths:
paths:
- "tconnectsync/"
target: '75%'
target: '60%'
threshold: '5%'
tests:
paths:
+3 -3
View File
@@ -38,13 +38,13 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
@@ -55,7 +55,7 @@ jobs:
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
+1 -1
View File
@@ -28,6 +28,6 @@ jobs:
--outdir dist/
.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@master
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
+20 -20
View File
@@ -28,29 +28,29 @@ jobs:
python -m pip install --upgrade pip
python -m pip install flake8 pytest pipenv
pipenv install --system
- name: Run pipenv check
run: |
# DDoS attacks in wheel and setuptools packages, not relevant
# root certificate store, not relevant
pipenv check \
--ignore 51499 \
--ignore 52495 \
--ignore 52365 \
--ignore 59956 \
--ignore 58755 \
--ignore 67895 \
--ignore 61893 \
--ignore 61601 \
--ignore 62044 \
--ignore 67599 \
--ignore 72083 \
--ignore 71064 \
--ignore 71608 \
--ignore 72236
# - name: Run pipenv check
# run: |
# # DDoS attacks in wheel and setuptools packages, not relevant
# # root certificate store, not relevant
# pipenv check \
# --ignore 51499 \
# --ignore 52495 \
# --ignore 52365 \
# --ignore 59956 \
# --ignore 58755 \
# --ignore 67895 \
# --ignore 61893 \
# --ignore 61601 \
# --ignore 62044 \
# --ignore 67599 \
# --ignore 72083 \
# --ignore 71064 \
# --ignore 71608 \
# --ignore 72236
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --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
- name: Run tconnectsync --help
+3 -1
View File
@@ -8,4 +8,6 @@ build
*.egg-info
.env
tconnectsync-check-output.log
ignore_*
ignore_*
.venv/
.vscode/
+1
View File
@@ -27,3 +27,4 @@ typing-extensions = "*"
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'"
+4
View File
@@ -89,6 +89,9 @@ You should specify the following parameters:
TCONNECT_EMAIL='email@email.com'
TCONNECT_PASSWORD='password'
# OPTIONAL: Your region (US or EU)
TCONNECT_REGION=US
# URL of your Nightscout site
NS_URL='https://yournightscouturl/'
# Your Nightscout API_SECRET value
@@ -99,6 +102,7 @@ TIMEZONE_NAME='America/New_York'
# OPTIONAL: Your pump's serial number (numeric)
PUMP_SERIAL_NUMBER=11111111
```
This file contains your t:connect username and password, Tandem pump serial number (which is utilized in API calls to t:connect), your Nightscout URL and secret token (for uploading data to Nightscout), and local timezone (the timezone used in t:connect). When specifying the timezone, enter a [TZ database name value](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
-6
View File
@@ -1,6 +0,0 @@
coverage:
status:
project:
default:
target: 50%
threshold: null
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = tconnectsync
version = 2.0.9
version = 2.3.3
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
+11 -9
View File
@@ -12,8 +12,6 @@ if sys.version_info < (3, 8):
typing.Protocol = typing_extensions.Protocol
from .api import TConnectApi
from .process import process_time_range
from .autoupdate import Autoupdate
from .sync.tandemsource.autoupdate import TandemSourceAutoupdate
from .sync.tandemsource.choose_device import ChooseDevice as TandemSourceChooseDevice
from .sync.tandemsource.process import ProcessTimeRange as TandemSourceProcessTimeRange
@@ -25,6 +23,7 @@ try:
from .secret import (
TCONNECT_EMAIL,
TCONNECT_PASSWORD,
TCONNECT_REGION,
NS_URL,
NS_SECRET,
NS_SKIP_TLS_VERIFY,
@@ -53,7 +52,8 @@ def parse_args(*args, **kwargs):
parser.add_argument('--auto-update', dest='auto_update', action='store_const', const=True, default=False, help='If set, continuously checks for updates from t:connect and syncs with Nightscout.')
parser.add_argument('--check-login', dest='check_login', action='store_const', const=True, default=False, help='If set, checks that the provided t:connect credentials can be used to log in.')
parser.add_argument('--features', dest='features', nargs='+', default=DEFAULT_FEATURES, choices=ALL_FEATURES, help='Specifies what data should be synchronized between tconnect and Nightscout.')
parser.add_argument('--tandem-source', dest='tandem_source', action='store_const', const=True, default=False, help='FOR TESTING: Use Tandem Source')
parser.add_argument('--tandem-source', dest='tandem_source', action='store_const', const=True, default=True, help=argparse.SUPPRESS) # no longer used
parser.add_argument('--region', dest='region', type=str, choices=['US', 'EU'], default=None, help='Tandem t:connect server region (US or EU). If not specified, uses TCONNECT_REGION from configuration or defaults to US.')
return parser.parse_args(*args, **kwargs)
@@ -85,6 +85,8 @@ def main(*args, **kwargs):
if time_end < time_start:
raise Exception('time_start must be before time_end')
# Determine region: command line arg takes precedence, then config, then default to US
region = args.region if args.region else TCONNECT_REGION
if TCONNECT_EMAIL == 'email@email.com':
logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.')
@@ -98,18 +100,18 @@ def main(*args, **kwargs):
else:
logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.')
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, region)
nightscout = NightscoutApi(NS_URL, NS_SECRET, skip_verify=NS_SKIP_TLS_VERIFY, ignore_conn_errors=NS_IGNORE_CONN_ERRORS)
# NOT YET MIGRATED
# if args.check_login:
# return check_login(tconnect, time_start, time_end)
if args.check_login:
return check_login(tconnect, time_start, time_end)
logging.warning("THIS VERSION OF TCONNECTSYNC READS DATA FROM TANDEM SOURCE, AND MAY CONTAIN BUGS!")
logging.info("You may notice different behavior compared to older versions which utilized t:connect data sources.")
logging.info("To report a bug or to get help, see https://github.com/jwoglom/tconnectsync/issues")
logging.info(f"Using Tandem t:connect region: {region}")
logging.info("Enabled features: " + ", ".join(args.features))
if args.check_login:
@@ -117,10 +119,10 @@ def main(*args, **kwargs):
if args.auto_update:
u = TandemSourceAutoupdate(secret)
sys.exit(u.process(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features))
sys.exit(u.process(tconnect, nightscout, args.pretend, features=args.features))
else:
tconnectDevice = TandemSourceChooseDevice(secret, tconnect).choose()
added, last_event_id = TandemSourceProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend=args.pretend, features=args.features).process(time_start, time_end)
added, last_event_id = TandemSourceProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend=args.pretend, secret=secret, features=args.features).process(time_start, time_end)
# return exit code 0 if processed events
sys.exit(0 if added>0 else 1)
+4
View File
@@ -0,0 +1,4 @@
from . import main
if __name__ == "__main__":
main()
+4 -3
View File
@@ -13,9 +13,10 @@ class TConnectApi:
email = None
password = None
def __init__(self, email, password):
def __init__(self, email, password, region='US'):
self.email = email
self.password = password
self.region = region
self._ciq = None
self._ws2 = None
self._android = None
@@ -27,9 +28,9 @@ class TConnectApi:
if self._tandemsource and not self._tandemsource.needs_relogin():
return self._tandemsource
logger.debug("Instantiating new TandemSourceApi")
logger.debug(f"Instantiating new TandemSourceApi for region {self.region}")
self._tandemsource = TandemSourceApi(self.email, self.password)
self._tandemsource = TandemSourceApi(self.email, self.password, self.region)
return self._tandemsource
+96 -19
View File
@@ -19,27 +19,78 @@ from jwt.algorithms import RSAAlgorithm
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
logger = logging.getLogger(__name__)
class TandemSourceApi:
# Common URLs that are shared between regions
LOGIN_PAGE_URL = 'https://sso.tandemdiabetes.com/'
LOGIN_API_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/login'
TDC_AUTH_CALLBACK_URL = 'https://sso.tandemdiabetes.com/auth/callback'
TDC_OAUTH_AUTHORIZE_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/oauth2/v1/authorize'
TDC_OIDC_JWKS_URL = 'https://tdcservices.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks'
TDC_OIDC_ISSUER = 'https://tdcservices.tandemdiabetes.com/accounts/api' # openid_config['issuer']
TDC_OIDC_CLIENT_ID = '0oa27ho9tpZE9Arjy4h7'
SOURCE_URL = 'https://source.tandemdiabetes.com/'
# US Region URLs (default)
_US_URLS = {
'LOGIN_API_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/login',
'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/oauth2/v1/authorize',
'TDC_OIDC_JWKS_URL': 'https://tdcservices.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks',
'TDC_OIDC_ISSUER': 'https://tdcservices.tandemdiabetes.com/accounts/api',
'TDC_OIDC_CLIENT_ID': '0oa27ho9tpZE9Arjy4h7',
'SOURCE_URL': 'https://source.tandemdiabetes.com/',
'REDIRECT_URI': 'https://sso.tandemdiabetes.com/auth/callback',
'TOKEN_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/token',
'AUTHORIZATION_ENDPOINT': 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/authorize'
}
# EU Region URLs
_EU_URLS = {
'LOGIN_API_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/login',
'TDC_OAUTH_AUTHORIZE_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/oauth2/v1/authorize',
'TDC_OIDC_JWKS_URL': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/.well-known/openid-configuration/jwks',
'TDC_OIDC_ISSUER': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api',
'TDC_OIDC_CLIENT_ID': '1519e414-eeec-492e-8c5e-97bea4815a10',
'SOURCE_URL': 'https://source.eu.tandemdiabetes.com/',
'REDIRECT_URI': 'https://source.eu.tandemdiabetes.com/authorize/callback',
'TOKEN_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/token',
'AUTHORIZATION_ENDPOINT': 'https://tdcservices.eu.tandemdiabetes.com/accounts/api/connect/authorize'
}
def __init__(self, email, password):
def __init__(self, email, password, region='US'):
self.region = region.upper()
if self.region not in ['US', 'EU']:
raise ValueError(f"Invalid region '{region}'. Must be 'US' or 'EU'.")
self._region_urls = self._US_URLS if self.region == 'US' else self._EU_URLS
self.login(email, password)
self._email = email
self._password = password
@property
def LOGIN_API_URL(self):
return self._region_urls['LOGIN_API_URL']
@property
def TDC_OAUTH_AUTHORIZE_URL(self):
return self._region_urls['TDC_OAUTH_AUTHORIZE_URL']
@property
def TDC_OIDC_JWKS_URL(self):
return self._region_urls['TDC_OIDC_JWKS_URL']
@property
def TDC_OIDC_ISSUER(self):
return self._region_urls['TDC_OIDC_ISSUER']
@property
def TDC_OIDC_CLIENT_ID(self):
return self._region_urls['TDC_OIDC_CLIENT_ID']
@property
def SOURCE_URL(self):
return self._region_urls['SOURCE_URL']
def login(self, email, password):
logger.info("Logging in to TandemSourceApi...")
logger.info(f"Logging in to TandemSourceApi ({self.region} region)...")
if self.try_load_cached_creds(email):
logger.info("Successfully used cached credentials")
return True
@@ -69,11 +120,10 @@ class TandemSourceApi:
# oidc
client_id = self.TDC_OIDC_CLIENT_ID
redirect_uri = 'https://sso.tandemdiabetes.com/auth/callback' # must be an allowlisted URI
redirect_uri = self._region_urls['REDIRECT_URI']
scope = 'openid profile email'
token_endpoint = 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/token' #openid_config['token_endpoint']
token_endpoint = self._region_urls['TOKEN_ENDPOINT']
def generate_code_verifier():
"""Generates a high-entropy code verifier."""
@@ -90,7 +140,7 @@ class TandemSourceApi:
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
authorization_endpoint = 'https://tdcservices.tandemdiabetes.com/accounts/api/connect/authorize' #openid_config['authorization_endpoint']
authorization_endpoint = self._region_urls['AUTHORIZATION_ENDPOINT']
oidc_step1_params = {
'client_id': client_id,
@@ -223,6 +273,12 @@ class TandemSourceApi:
logger.warning(f"Cached credentials are for a different email ({_saved_blob['cache_creds_email']} in cache, but using {email}), skipping")
return False
# Check if cached region matches current region
cached_region = _saved_blob.get('cache_creds_region', 'US') # Default to US for backward compatibility
if cached_region != self.region:
logger.warning(f"Cached credentials are for a different region ({cached_region} in cache, but using {self.region}), skipping")
return False
at_expiry = _saved_blob['accessTokenExpiresAt']
if arrow.get().int_timestamp >= arrow.get(at_expiry).int_timestamp:
logger.info(f"Cached credentials have expired ({_saved_blob['accessTokenExpiresAt']}), skipping")
@@ -277,6 +333,7 @@ class TandemSourceApi:
'cache_creds_version': 1.0,
'cache_creds_saved_at': arrow.get(),
'cache_creds_email': email,
'cache_creds_region': self.region, # Store the region in cache
'jwtData': self.jwtData,
'pumperId': self.pumperId,
'accountId': self.accountId,
@@ -357,22 +414,42 @@ class TandemSourceApi:
def pump_event_metadata(self):
return self.get('api/reports/reportsfacade/%s/pumpeventmetadata' % (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]
"""
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):
def pump_events_raw(self, tconnect_device_id, min_date=None, max_date=None, event_ids_filter=DEFAULT_EVENT_IDS):
minDate = parse_ymd_date(min_date)
maxDate = parse_ymd_date(max_date)
logger.debug(f'pump_events_raw({minDate}, {maxDate})')
logger.debug(f'pump_events_raw({tconnect_device_id}, {minDate}, {maxDate})')
# 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 = '229%2C5%2C28%2C4%2C26%2C99%2C279%2C3%2C16%2C59%2C21%2C55%2C20%2C280%2C64%2C65%2C66%2C61%2C33%2C371%2C171%2C369%2C460%2C172%2C370%2C461%2C372%2C399%2C256%2C213%2C406%2C394%2C212%2C404%2C214%2C405%2C447%2C313%2C60%2C14%2C6%2C90%2C230%2C140%2C12%2C11%2C53%2C13%2C63%2C203%2C307%2C191'
return self.get('api/reports/reportsfacade/pumpevents/%s/%s?minDate=%s&maxDate=%s&eventIds=%s' % (
# 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,
eventIdsFilter
'&eventIds=%s' % eventIdsFilter if eventIdsFilter else ''
), {})
"""
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.
"""
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
)
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)
-207
View File
@@ -1,207 +0,0 @@
import time
import logging
import datetime
import sys
from .process import process_time_range
from .features import DEFAULT_FEATURES
from . import secret
logger = logging.getLogger(__name__)
class Autoupdate:
"""Wrap access to secrets for easier testing."""
def __init__(self, secret):
self.secret = secret
self.autoupdate_invocations = 0
self.last_event_index = None
self.last_event_time = None
self.last_successful_process_time_range = None
self.time_diffs_between_updates = []
self.last_attempt_time = None
self.time_diffs_between_attempts = []
"""
Performs the auto-update functionality. Runs indefinitely in a loop
until stopped (ctrl+c), or a maximum of AUTOUPDATE_MAX_LOOP_INVOCATIONS times.
Stops if AUTOUPDATE_RESTART_ON_FAILURE is set and an error occurs.
"""
def process(self, tconnect, nightscout, time_start, time_end, pretend, features=None):
if features is None:
features = DEFAULT_FEATURES
# Read from android api, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
self.autoupdate_start = time.time()
while True:
logger.debug("autoupdate loop")
now = time.time()
last_event = tconnect.android.last_event_uploaded(self.secret.PUMP_SERIAL_NUMBER)
if not self.last_event_index or last_event['maxPumpEventIndex'] > self.last_event_index:
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], self.last_event_index))
if pretend:
logger.info('Would update now if not in pretend mode')
else:
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
logger.info('Added %d items from process_time_range' % added)
if added == 0:
# If we've been unable to find new events, but the last_event_index is increasing,
# suggesting there are more events being added, we might be in a bugged
# situation where we can't get any more data without restarting.
# We skip this check on the first process cycle, since we might
# just already be in sync with tconnect's pump data.
if self.last_event_index:
# Find the timestamp of the last time we've successfully obtained data,
# or the time when the autoupdate run started, if we haven't at all.
last_action_or_start = self.last_successful_process_time_range
if not last_action_or_start:
last_action_or_start = self.autoupdate_start
# If it's been AUTOUPDATE_FAILURE_MINUTES in the state of not seeing
# event index changes reflected in the tconnect data we're pulling,
# raise an error and potentially restart.
# This is likely a tconnectsync problem, not a problem with the pump or app
# (we can see the indexes increasing, so we know something's happening!)
if (now - last_action_or_start) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateFailureError(
("%s: An event index change was recorded, but no new data was found via the API. " % datetime.datetime.now()) +
"The %s was %d minutes ago. This is a problem with tconnectsync." %
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
return 1
else:
logger.warning(AutoupdateFailureWarning(("%s: An event index change was recorded, but no new data was found via the API. " % datetime.datetime.now()) +
"The %s was %d minutes ago. Resetting TConnectApi to attempt to solve this problem." %
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
# As a stop-gap, try to re-initialize TConnectApi (triggering a re-login)
# Use __class__ instead of direct TConnectApi invocation to avoid initializing a real TConnectApi over a fake
tconnect = tconnect.__class__(self.secret.TCONNECT_EMAIL, self.secret.TCONNECT_PASSWORD)
else:
# Mark the last successful time we got data from tconnect
self.last_successful_process_time_range = now
# Track the time it took to find a new event between runs,
# but skip this calculation the first process cycle (since
# we don't know at what exact point the event index changed)
if self.last_event_index:
self.time_diffs_between_updates.append(now - self.last_event_time)
logger.debug('Updating tracking of time since last update: %s' % self.time_diffs_between_updates)
# Mark the last event index uploaded from the pump and timestamp
self.last_event_index = last_event['maxPumpEventIndex']
self.last_event_time = now
self.last_attempt_time = now
self.time_diffs_between_attempts = []
else:
logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex'])
# If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# then trigger an error and potentially restart.
# The most likely case here is that the pump isn't uploading right now.
if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES:
logger.error(AutoupdateNoEventIndexesDetectedError(
"%s: No new data event indexes have been detected for %d minutes. " % (datetime.datetime.now(), (now - self.last_event_time)//60) +
"The t:connect app might no longer be functioning."))
# TODO: restarting doesn't really help anything here.
# Should we notify the user?
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
return 1
# Similarly, if we HAVE seen pump event indexes update but have not successfully
# found any associated data updates from the tconnect API for AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# trigger an error and potentially restart. This could either be a tconnectsync problem,
# where we can see the indexes increasing, but it takes us until a period of no index
# update to reach our AUTOUPDATE_FAILURE_MINUTES threshold; or, a side effect of the
# above no indexes warning.
elif self.last_successful_process_time_range and (now - self.last_successful_process_time_range) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateNoNewDataDetectedError(
"%s: No new data has been detected via the API for %d minutes. " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60) +
"tconnectsync might not be functioning properly."))
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
logger.error("%s: Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE" % datetime.datetime.now())
return 1
# Track how long we've been retrying
if self.last_attempt_time:
self.time_diffs_between_attempts.append(now - self.last_attempt_time)
self.last_attempt_time = now
# If it's been 3 loops since the last time we found new data,
# then we're not in sync with the rate at which pump data is being
# uploaded, so
if len(self.time_diffs_between_attempts) >= 3:
# The pump hasn't sent us data that, based on previous cadence, we were expecting
logger.warning(AutoupdateNoIndexChangeWarning("Sleeping %d seconds after unexpected no index change based on previous cadence. (New data might be delayed.)" %
int(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)))
logger.debug("Last event time: %s, time diffs between attempts: %s" % (self.last_event_time, self.time_diffs_between_attempts))
time.sleep(self.secret.AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS)
# Since we bail early, update the invocations count and potentially exit after sleeping.
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
continue
sleep_secs = self.secret.AUTOUPDATE_DEFAULT_SLEEP_SECONDS
# Sleep for a rolling average of time between updates
if self.secret.AUTOUPDATE_USE_FIXED_SLEEP != 1:
logger.debug("Time diffs between updates: %s" % self.time_diffs_between_updates)
# Only keep the 10 latest time diffs
if len(self.time_diffs_between_updates) > 10:
self.time_diffs_between_updates = self.time_diffs_between_updates[1:]
# If we have less than 3 data points,
if len(self.time_diffs_between_updates) > 2:
sleep_secs = sum(self.time_diffs_between_updates) / len(self.time_diffs_between_updates)
# At minimum, update every AUTOUPDATE_MAX_SLEEP_SECONDS regardless
# of how often we're seeing new data appear
if sleep_secs > self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS:
sleep_secs = self.secret.AUTOUPDATE_MAX_SLEEP_SECONDS
logger.info('Sleeping for %0.01f sec' % sleep_secs)
time.sleep(sleep_secs)
self.autoupdate_invocations += 1
if self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS > 0 and self.autoupdate_invocations >= self.secret.AUTOUPDATE_MAX_LOOP_INVOCATIONS:
return 0
class AutoupdateError(RuntimeError):
def __str__(self):
return "%s: %s" % (self.__class__.__name__, super().__str__())
class AutoupdateWarning(RuntimeWarning):
def __str__(self):
return "%s: %s" % (self.__class__.__name__, super().__str__())
class AutoupdateFailureError(AutoupdateError):
pass
class AutoupdateFailureWarning(AutoupdateWarning):
pass
class AutoupdateNoEventIndexesDetectedError(AutoupdateError):
pass
class AutoupdateNoNewDataDetectedError(AutoupdateError):
pass
class AutoupdateNoIndexChangeWarning(AutoupdateWarning):
pass
+67 -98
View File
@@ -4,13 +4,15 @@ import arrow
import logging
import traceback
import pkg_resources
import collections
from datetime import datetime
from pprint import pformat as pformat_base
from .nightscout import NightscoutApi
from .parser.nightscout import BASAL_EVENTTYPE, BOLUS_EVENTTYPE
from .parser.tconnect import TConnectEntry
from .sync.basal import process_ciq_basal_events
from .domain.tandemsource.event_class import EventClass
from .sync.tandemsource.choose_device import ChooseDevice
try:
__version__ = pkg_resources.require("tconnectsync")[0].version
@@ -51,27 +53,29 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
log("Loading secrets...")
try:
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
from . import secret
except ImportError as e:
log("Error: Unable to load config file. Please check your .env file or environment variables")
log_err(e)
log(f"Using {TCONNECT_REGION=}")
if not TCONNECT_EMAIL or TCONNECT_EMAIL == 'email@email.com':
log("Error: You have not specified a TCONNECT_EMAIL")
errors += 1
if not TCONNECT_PASSWORD or TCONNECT_PASSWORD == 'password':
log("Error: You have not specified a TCONNECT_PASSWORD")
errors += 1
if not PUMP_SERIAL_NUMBER or PUMP_SERIAL_NUMBER == '11111111':
log("Error: You have not specified a PUMP_SERIAL_NUMBER")
errors += 1
log("Warning: You have not specified a PUMP_SERIAL_NUMBER, so the pump with most recent activity will be automatically used.")
if not NS_URL or NS_URL == 'https://yournightscouturl/':
log("Error: You have not specified a NS_URL")
errors += 1
if not NS_SECRET or NS_SECRET == 'apisecret':
log("Error: You have not specified a NS_SECRET")
errors += 1
@@ -80,89 +84,62 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
log("-----")
log("Logging in to t:connect ControlIQ API...")
serialNumberToPump = None
try:
summary = tconnect.controliq.dashboard_summary(time_start, time_end)
debug("ControlIQ dashboard summary: \n%s" % pformat(summary))
log("tconnect_software_ver: %s" % tconnect.controliq.tconnect_software_ver)
except Exception as e:
log("Error occurred querying ControlIQ API for dashboard_summary:")
log_err(e)
if e and "HTTP 404" in str(e):
log("<!> The API returns a 404 error if there is no data for the provided dates (currently %s - %s). Try re-running with an earlier start date using --start-date and --end-date arguments." % (time_start, time_end))
errors += 1
log("Querying ControlIQ therapy_timeline...")
lastBasalTime = None
lastBasalDuration = None
try:
tt = tconnect.controliq.therapy_timeline(time_start, time_end)
debug("ControlIQ therapy_timeline: \n%s" % pformat(tt))
if tt:
processed_tt = process_ciq_basal_events(tt)
debug("ControlIQ processed therapy_timeline: \n%s" % pformat(processed_tt))
if processed_tt:
log("Last ControlIQ processed therapy_timeline event: \n%s" % pformat(processed_tt[-1]))
lastBasalTime = processed_tt[-1]['time']
lastBasalDuration = processed_tt[-1]['duration_mins']
except Exception as e:
log("Error occurred querying ControlIQ therapy_timeline:")
log_err(e)
errors += 1
log("Querying ControlIQ therapy_events...")
try:
androidevents = tconnect.controliq.therapy_events(time_start, time_end)
debug("controliq therapy_events: \n%s" % pformat(androidevents))
except Exception as e:
log("Error occurred querying ControlIQ therapy_events:")
log_err(e)
errors += 1
log("-----")
log("Fetching pump metadata...")
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
log("Initializing t:connect WS2 API...")
ws2_loggedin = False
try:
summary = tconnect.ws2.basaliqtech(time_start, time_end)
debug("WS2 basaliq status: \n%s" % pformat(summary))
ws2_loggedin = True
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
log(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
for pumpSerial, pumpDetails in serialNumberToPump.items():
log(f'Pump {pumpSerial=}: {pumpDetails=}')
log("Running ChooseDevice...")
tconnectDevice = ChooseDevice(secret, tconnect).choose()
log(f'ChooseDevice selected: {tconnectDevice}')
tconnectDeviceId = tconnectDevice['tconnectDeviceId']
log(f'Fetching pump events for {tconnectDeviceId=} {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 = list(events)
log(f"Found raw events count: {len(events)}")
events_first_time = None
events_last_time = None
last_event_seqnum = None
for_eventclass = collections.defaultdict(list)
for event in events:
if not events_first_time:
events_first_time = event.eventTimestamp
if not events_last_time:
events_last_time = event.eventTimestamp
if not last_event_seqnum:
last_event_seqnum = event.seqNum
events_first_time = min(events_first_time, event.eventTimestamp)
events_last_time = max(events_last_time, event.eventTimestamp)
last_event_seqnum = max(event.seqNum, last_event_seqnum)
clazz = EventClass.for_event(event)
if clazz:
for_eventclass[clazz.name].append(event)
count_by_eventclass = {k: len(v) for k,v in for_eventclass.items()}
log(f"Found events count: {count_by_eventclass}")
log(f"Found first event time: {events_first_time}")
log(f"Found last event time: {events_last_time}")
log(f"Found last event sequence number: {last_event_seqnum}")
except Exception as e:
log("Error occurred querying WS2 API. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
log("Error occurred querying Tandem Source:")
log_err(e)
errors += 1
lastReadingTime = None
if ws2_loggedin:
log("Querying WS2 therapy_timeline_csv...")
try:
ttcsv = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
debug("therapy_timeline_csv: \n%s" % pformat(ttcsv))
if ttcsv and "readingData" in ttcsv and len(ttcsv["readingData"]) > 0:
log("Last therapy_timeline_csv reading: \n%s" % pformat(ttcsv["readingData"][-1]))
lastReadingTime = TConnectEntry._datetime_parse(ttcsv["readingData"][-1]['EventDateTime'])
except Exception as e:
log("Error occurred querying WS2 therapy_timeline_csv. This is okay so long as you are not using the PUMP_EVENTS or IOB sync features.")
log_err(e)
errors += 1
else:
log("Not able to log in to WS2 API, so skipping therapy_timeline_csv")
log("-----")
log("Logging in to t:connect Android API...")
summary = None
try:
summary = tconnect.android.user_profile()
debug("Android user profile: \n%s" % pformat(summary))
event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
debug("Android last uploaded event: \n%s" % pformat(event))
except Exception as e:
log("Error occurred querying Android API:")
log_err(e)
errors += 1
log("-----")
log("Logging in to Nightscout...")
@@ -186,17 +163,13 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
def time_ago(t):
return '%s ago' % (arrow.now() - arrow.get(t)) if t else 'n/a'
log("Last basal start time: %s (%s)" % (lastBasalTime, time_ago(lastBasalTime)))
log("Last basal duration: %s" % lastBasalDuration)
log("Last reading time: %s (%s)" % (lastReadingTime, time_ago(lastReadingTime)))
log("-----")
if errors == 0:
log("No API errors returned!")
else:
log("API errors occurred. Please check the errors above.")
with open('tconnectsync-check-output.log', 'w') as f:
@@ -208,14 +181,10 @@ def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
'NS_URL': NS_URL,
'NS_SECRET': NS_SECRET
}
if summary:
sanitizedData.update({
'ANDROID_PROFILE_USERID': summary.get('userID'),
'ANDROID_PROFILE_PATIENT_FULLNAME': summary.get('patientFullName'),
'ANDROID_PROFILE_CAREGIVER_FULLNAME': summary.get('caregiverFullName')
})
if serialNumberToPump:
for i, (pumpSerial, pumpDetails) in enumerate(serialNumberToPump.items()):
sanitizedData[f'PUMP_SERIAL_{i}'] = pumpSerial
sanitizedData[f'TCONNECT_DEVICE_ID_{i}'] = pumpDetails['tconnectDeviceId']
loglines = [run_sanitize(i, sanitizedData) for i in loglines]
f.writelines(loglines)
@@ -24,6 +24,7 @@ class EventClass(set, Enum):
CGM_START_JOIN_STOP = {*_CGM_START, *_CGM_JOIN, *_CGM_STOP}
CGM_READING = {events.LidCgmDataGxb, events.LidCgmDataG7, events.LidCgmDataFsl2}
USER_MODE = {events.LidAaUserModeChange}
DEVICE_STATUS = {events.LidDailyBasal}
@staticmethod
def for_event(evt):
@@ -25,6 +25,9 @@ class PumpProfile:
carbEntry: int # 1 / 0
maxBolus: int # milliunits
def __post_init__(self):
self.tDependentSegs = [i for i in self.tDependentSegs if not i.skip]
@dataclass_json
@dataclass
class PumpProfiles:
+38 -14
View File
@@ -3,18 +3,12 @@ import struct
import logging
from dataclasses import dataclass
from enum import Enum, IntFlag
from .raw_event import RawEvent
from .raw_event import RawEvent, BaseEvent
logger = logging.getLogger(__name__)
EVENT_LEN = 26
@dataclass
class BaseEvent:
@staticmethod
def build(raw):
raise NotImplemented
'''
TYPE_TO_STRUCT = {
@@ -67,9 +61,22 @@ class {name}(BaseEvent):
return self.raw.timestamp
@property
def eventId(self):
def seqNum(self):
return self.raw.seqNum
@property
def eventId(self):
return self.ID
def todict(self):
return dict(
id=self.ID,
name=self.NAME,
seqNum=self.seqNum,
eventTimestamp=str(self.eventTimestamp),
{fields_dict}
)
'''
def firstLower(text):
@@ -85,13 +92,13 @@ 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(' ', ''))
return firstLower(text.replace('_', ' ').title().replace(' ', '')).replace('raw', 'Raw')
def build_fields(event_def):
ret = []
for name, field in event_def["data"].items():
suffix = 'Raw' if "transform" in field else ''
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
f = f'{fieldNameFormat(name)}{suffix}: {TYPE_TO_PYOBJ[field["type"]]}'
if "uom" in field:
f += ' # ' + field['uom']
@@ -99,6 +106,14 @@ def build_fields(event_def):
return '\n'.join([f'{" "*4}{f}' for f in ret])
def build_fields_dict(event_def):
ret = []
for name, field in event_def["data"].items():
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
f = f'{fieldNameFormat(name)}{suffix}=self.{fieldNameFormat(name)}{suffix},'
ret.append(f)
return '\n'.join([f'{" "*12}{f}' for f in ret])
def build_decode(event_def):
p1s = []
@@ -107,7 +122,7 @@ def build_decode(event_def):
p1 = f'{fieldNameFormat(name)}, = {unpack_command_for(field)}'
p1s.append(p1)
suffix = 'Raw' if "transform" in field else ''
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
p2 = f'{fieldNameFormat(name)}{suffix} = {fieldNameFormat(name)},'
p2s.append(p2)
@@ -136,6 +151,7 @@ def build_event(event_id, event_def):
return TEMPLATE.format(
name = eventNameFormat(event_def["name"]),
fields = build_fields(event_def),
fields_dict = build_fields_dict(event_def),
build_p1 = build_decode(event_def)[0],
build_p2 = build_decode(event_def)[1],
transform_funcs = build_transform_funcs(event_def),
@@ -160,13 +176,21 @@ def build_events_map(events):
if __name__ == '__main__':
import json
merged_events = {}
output = f'{header}'
with open("events.json", "r") as f:
j = json.loads(f.read())
for event_id, event_def in j["events"].items():
output += build_event(event_id, event_def)
merged_events.update(j["events"])
with open("custom_events.json", "r") as f:
j = json.loads(f.read())
merged_events.update(j["events"])
for event_id, event_def in merged_events.items():
output += build_event(event_id, event_def)
output += build_events_map(merged_events)
output += build_events_map(j["events"])
print(output)
@@ -0,0 +1,67 @@
{
"events": {
"81": {
"name": "LID_DAILY_BASAL",
"data": {
"dailyTotalBasal": {
"type": "float32",
"offset": 0,
"uom": "units"
},
"lastBasalRate": {
"type": "float32",
"offset": 4,
"uom": "units/hour"
},
"iob": {
"type": "float32",
"offset": 8,
"uom": "units"
},
"batteryChargePercentMSBRaw": {
"type": "uint8",
"offset": 12
},
"batteryChargePercentLSBRaw": {
"type": "uint8",
"offset": 13,
"transform": [["battery_charge_percent", ""]]
},
"batteryLipoMilliVolts": {
"type": "uint16",
"offset": 14
}
}
},
"48": {
"name": "LID_CARBS_ENTERED",
"data": {
"carbs": {
"type": "float32",
"offset": 0,
"uom": "carbs"
}
}
},
"36": {
"name": "LID_USB_CONNECTED",
"data": {
"negotiatedCurrent": {
"type": "float32",
"offset": 0,
"uom": "mA"
}
}
},
"37": {
"name": "LID_USB_DISCONNECTED",
"data": {
"negotiatedCurrent": {
"type": "float32",
"offset": 0,
"uom": "mA"
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -8,7 +8,13 @@ from .events import EVENT_IDS
from .utils import batched
Event = lambda x: EVENT_IDS[RawEvent.build(x).id].build(x)
def Event(x):
raw_event = RawEvent.build(x)
if not raw_event.id in EVENT_IDS:
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 decode_raw_events(raw):
+33 -1
View File
@@ -10,6 +10,21 @@ UINT16 = '>H'
UINT32 = '>I'
TANDEM_EPOCH = 1199145600
@dataclass
class BaseEvent:
@staticmethod
def build(raw):
raise NotImplemented
@property
def eventTimestamp(self):
raise NotImplemented
@property
def eventId(self):
raise NotImplemented
@dataclass
class RawEvent:
source: int
@@ -38,4 +53,21 @@ class RawEvent:
# but represent the user's time zone setting. So we keep the time
# referenced on them, but force the timezone to what the user
# requests via the TZ secret.
return arrow.get(TANDEM_EPOCH + self.timestampRaw, tzinfo=TIMEZONE_NAME)
return arrow.get(TANDEM_EPOCH + self.timestampRaw, tzinfo='UTC').replace(tzinfo=TIMEZONE_NAME)
@property
def eventId(self):
return self.id
@property
def eventTimestamp(self):
return self.timestamp
def todict(self):
return dict(
id=self.id,
name="RawEvent",
seqNum=self.seqNum,
eventTimestamp=str(self.eventTimestamp),
raw=''.join('{:02x}'.format(x) for x in self.raw),
)
+12 -1
View File
@@ -111,11 +111,22 @@ 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
'ratio': transform_ratio,
'battery_charge_percent': transform_battery_charge_percent
}
+2
View File
@@ -10,6 +10,7 @@ PUMP_EVENTS = "PUMP_EVENTS"
PUMP_EVENTS_BASAL_SUSPENSION = "PUMP_EVENTS_BASAL_SUSPENSION"
PROFILES = "PROFILES"
CGM_ALERTS = "CGM_ALERTS"
DEVICE_STATUS = "DEVICE_STATUS"
DEFAULT_FEATURES = [
BASAL,
@@ -27,6 +28,7 @@ ALL_FEATURES = [
PROFILES,
CGM,
CGM_ALERTS,
DEVICE_STATUS,
]
+39
View File
@@ -71,6 +71,9 @@ class NightscoutApi:
'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()
@@ -106,6 +109,9 @@ class NightscoutApi:
'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()
@@ -134,6 +140,9 @@ class NightscoutApi:
'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()
@@ -154,6 +163,36 @@ class NightscoutApi:
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)
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)
else:
raise e
"""
Returns general status information about the Nightscout server.
"""
+27 -8
View File
@@ -194,6 +194,22 @@ class NightscoutEntry:
"pump_event_id": pump_event_id
}
@staticmethod
def devicestatus(created_at, batteryVoltage, batteryPercent, pump_event_id=""):
return {
"device": ENTERED_BY,
"created_at": created_at,
"pump": {
"clock": created_at,
"battery": {
"voltage": float(batteryVoltage),
"percent": int(batteryPercent) if batteryPercent else None,
"status": "%.0f%s" % (batteryPercent, '%')
},
},
"pump_event_id": pump_event_id
}
# Tandem-scraped profile to Nightscout profile store entry
@staticmethod
def profile_store(profile: Profile, device_settings: DeviceSettings) -> dict:
@@ -251,30 +267,33 @@ class NightscoutEntry:
return {
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
"dia": "%s" % (profile.insulinDuration / 60),
"carbratio": [
"carbratio": list(sorted([
{
"time": minutes_to_ns_time(segment.startTime),
"timeAsSeconds": segment.startTime * 60,
"value": segment.carbRatio / 1000 # milliunits->units
} for segment in profile.tDependentSegs
],
} for segment in profile.tDependentSegs if not segment.skip
], key=lambda x: x["timeAsSeconds"])),
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [ # Correction factor / isf
"sens": list(sorted([ # Correction factor / isf
{
"time": minutes_to_ns_time(segment.startTime),
"timeAsSeconds": segment.startTime * 60,
"value": segment.isf
} for segment in profile.tDependentSegs
],
"basal": [
} for segment in profile.tDependentSegs if not segment.skip
], key=lambda x: x["timeAsSeconds"])),
"basal": list(sorted([
{
"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"])),
"target_low": [
{
"time": "00:00",
+8 -2
View File
@@ -41,6 +41,7 @@ def get_bool(name, default):
TCONNECT_EMAIL = get('TCONNECT_EMAIL', 'email@email.com')
TCONNECT_PASSWORD = get('TCONNECT_PASSWORD', 'password')
TCONNECT_REGION = get_one_of('TCONNECT_REGION', 'US', ['US', 'EU'])
PUMP_SERIAL_NUMBER = int(get_number('PUMP_SERIAL_NUMBER', '11111111'))
@@ -54,6 +55,7 @@ if not get('NS_SECRET') and get('API_SECRET'):
NS_SKIP_TLS_VERIFY = get_bool('NS_SKIP_TLS_VERIFY', 'false')
NS_IGNORE_CONN_ERRORS = get_bool('NS_IGNORE_CONN_ERRORS', 'false')
# This should be the timezone your pump is set to.
TIMEZONE_NAME = get('TIMEZONE_NAME', 'America/New_York')
if not get('TIMEZONE_NAME') and get('TZ'):
@@ -69,15 +71,19 @@ AUTOUPDATE_MAX_SLEEP_SECONDS = get_number('AUTOUPDATE_MAX_SLEEP_SECONDS', '1500'
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS = get_number('AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS', '60') # 1 minute
AUTOUPDATE_USE_FIXED_SLEEP = get_bool('AUTOUPDATE_USE_FIXED_SLEEP', 'false')
AUTOUPDATE_NO_DATA_FAILURE_MINUTES = get_number('AUTOUPDATE_NO_DATA_FAILURE_MINUTES', '180') # 3 hours
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15 minutes
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true')
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '75') # 75 minutes
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
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'])
# When set, all possible history log event types are fetched from Tandem Source
FETCH_ALL_EVENT_TYPES = get_bool('FETCH_ALL_EVENT_TYPES', 'false')
# Default Nightscout profile segment fields which aren't stored by Tandem
NIGHTSCOUT_PROFILE_CARBS_HR_VALUE = get('NIGHTSCOUT_PROFILE_CARBS_HR_VALUE', '20')
NIGHTSCOUT_PROFILE_DELAY_VALUE = get('NIGHTSCOUT_PROFILE_DELAY_VALUE', '20')
IGNORE_ZERO_UNIT_BASAL = get_bool('IGNORE_ZERO_UNIT_BASAL', 'false')
ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false')
SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false')
-161
View File
@@ -1,161 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
BASAL_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
logger = logging.getLogger(__name__)
"""
Merges together input from the therapy timeline API
into a digestable format of basal data.
"""
def process_ciq_basal_events(data):
if data is None:
return []
suspensionEvents = {}
for s in data["suspensionDeliveryEvents"]:
entry = TConnectEntry.parse_suspension_entry(s)
suspensionEvents[entry["time"]] = entry
basalEvents = []
for b in data["basal"]["tempDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="tempDelivery"))
for b in data["basal"]["algorithmDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="algorithmDelivery"))
for b in data["basal"]["profileDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="profileDelivery"))
# Suspensions with suspendReason 'control-iq' will match a basal event found above.
for i in basalEvents:
if i["time"] in suspensionEvents:
i["delivery_type"] += " (" + suspensionEvents[i["time"]]["suspendReason"] + " suspension)"
del suspensionEvents[i["time"]]
# Suspensions with suspendReason 'manual' do not have an associated basal event,
# and require extra processing.
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
unprocessedSuspensions = list(suspensionEvents.values())
unprocessedSuspensions.sort(key=lambda x: arrow.get(x["time"]))
# For the remaining suspensions which did not match with an existing basal event,
# add a new event manually. This means we need to calculate the duration of the
# suspension.
newEvents = []
for i in range(len(basalEvents)):
if len(unprocessedSuspensions) == 0:
break
existingTime = arrow.get(basalEvents[i]["time"])
unprocessedTime = arrow.get(unprocessedSuspensions[0]["time"])
# If we've found an event which occurs after the suspension, then the
# difference in their timestamps is the duration of the suspension.
if i > 0 and existingTime > unprocessedTime:
suspension = unprocessedSuspensions.pop(0)
# TConnect's internal duration object tracks the duration in seconds
seconds = (existingTime - unprocessedTime).seconds
newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds)
logger.debug("Adding basal event for unprocessed suspension: %s" % newEvent)
newEvents.append(newEvent)
# Any remaining suspensions which have not been processed have not ended,
# which means we do not know their duration; so we will skip them (for now)
# Add any new events and re-sort
if newEvents:
basalEvents += newEvents
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
return basalEvents
"""
Processes basal data input from the therapy timeline CSV (which only
exists for pre Control-IQ data) into a digestable format.
"""
def add_csv_basal_events(basalEvents, data):
last_entry = {}
for row in data:
entry = TConnectEntry.parse_csv_basal_entry(row)
if last_entry:
diff_mins = (arrow.get(entry["time"]) - arrow.get(last_entry["time"])).seconds // 60
entry["duration_mins"] = diff_mins
basalEvents.append(entry)
last_entry = entry
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
return basalEvents
"""
Given processed basal data, adds basal events to Nightscout.
"""
def ns_write_basal_events(nightscout, basalEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_basal_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BASAL_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout basal upload: %s" % last_upload_time)
if SKIP_NS_LAST_UPLOADED_CHECK:
logger.warning("Overriding last upload check")
last_upload = None
last_upload_time = None
add_count = 0
for event in basalEvents:
if last_upload_time and arrow.get(event["time"]) < last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
recent_needs_update = False
if last_upload_time and arrow.get(event["time"]) == last_upload_time:
# If this entry has the same time as the most recent upload, but
# has newer info, then delete and recreate it.
recent_needs_update = (round(last_upload["duration"]) < round(event["duration_mins"]))
# If the timestamps are identical, and the duration is identical,
# then don't upload a duplicate entry of what we already have.
if not recent_needs_update:
continue
reason = event["delivery_type"]
if "suspendReason" in reason:
reason += " (" + reason["suspendReason"] + ")"
entry = NightscoutEntry.basal(
value=event["basal_rate"],
duration_mins=event["duration_mins"],
created_at=event["time"],
reason=reason
)
add_count += 1
logger.info(" Processing basal: %s entry: %s" % (event, entry))
if recent_needs_update:
logger.info("Replacing last uploaded entry: %s" % last_upload)
if not pretend:
entry['_id'] = last_upload['_id']
nightscout.put_entry(entry, entity='treatments')
elif not pretend:
nightscout.upload_entry(entry)
logger.debug("ns_write_basal_events: added %d events" % add_count)
return add_count
-120
View File
@@ -1,120 +0,0 @@
import arrow
import logging
from tconnectsync.domain.bolus import Bolus
from tconnectsync.sync.cgm import find_event_at
from ..parser.nightscout import (
BOLUS_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
logger = logging.getLogger(__name__)
"""
Given bolus data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_bolus_events(bolusdata, cgmEvents=None, source=""):
bolusEvents = []
for b in bolusdata:
parsed = None
if source == "ciq":
parsed = b.to_bolus()
else:
parsed = TConnectEntry.parse_bolus_entry(b)
assert type(parsed) == Bolus
if parsed.completion != "Completed":
if parsed.insulin and float(parsed.insulin) > 0:
# Count non-completed bolus if any insulin was delivered (vs. the amount of insulin requested)
parsed.description += " (%s: requested %s units)" % (parsed.completion, parsed.requested_insulin)
else:
logger.warning("Skipping non-completed %s bolus data (was a bolus in progress?): %s parsed: %s" % (source, b, parsed))
continue
if parsed.is_extended_bolus:
if not parsed.bolex_start_time and not parsed.request_time:
logger.warning("Skipping non-completed %s extended bolus data with no request_time: %s parsed: %s" % (source, b, parsed))
elif not parsed.bolex_start_time and parsed.request_time:
logger.warning("Setting bolex_start_time to request_time for non-completed %s extended bolus: %s parsed: %s" % (source, b, parsed))
parsed.bolex_start_time = parsed.request_time
logger.debug("process_bolus_events for incomplete bolus: %s parsed: %s" % (b, parsed))
elif parsed.is_extended_bolus:
logger.debug("process_bolus_events for complete extended bolus: %s parsed: %s" % (b, parsed))
if parsed.bg and cgmEvents:
requested_at = parsed.request_time if not parsed.extended_bolus else parsed.bolex_start_time
parsed.bg_type = guess_bolus_bg_type(parsed.bg, requested_at, cgmEvents)
bolusEvents.append(parsed)
bolusEvents.sort(key=lambda event: arrow.get(event.request_time if not event.is_extended_bolus else event.bolex_start_time))
return bolusEvents
"""
Determine whether the given BG specified in the bolus is identical to the
most recent CGM reading at that time. If it is, return SENSOR.
Otherwise, return FINGER.
"""
def guess_bolus_bg_type(bg, created_at, cgmEvents):
if not cgmEvents:
return NightscoutEntry.FINGER
event = find_event_at(cgmEvents, created_at)
if event and str(event["bg"]) == str(bg):
return NightscoutEntry.SENSOR
return NightscoutEntry.FINGER
"""
Given processed bolus data, adds bolus events to Nightscout.
"""
def ns_write_bolus_events(nightscout, bolusEvents, pretend=False, include_bg=False, reading_events=None, time_start=None, time_end=None):
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout bolus upload: %s" % last_upload_time)
if SKIP_NS_LAST_UPLOADED_CHECK:
logger.warning("Overriding last upload check")
last_upload = None
last_upload_time = None
add_count = 0
for event in bolusEvents:
created_at = event.completion_time if not event.is_extended_bolus else event.bolex_start_time
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
logger.info("Skipping basal event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
if include_bg and event.bg:
entry = NightscoutEntry.bolus(
bolus=event.insulin,
carbs=event.carbs,
created_at=created_at,
notes="{}{}{}".format(event.description, " (Override)" if event.user_override == "1" else "", " (Extended)" if event.extended_bolus == "1" else ""),
bg=event.bg,
bg_type=event.bg_type
)
else:
entry = NightscoutEntry.bolus(
bolus=event.insulin,
carbs=event.carbs,
created_at=created_at,
notes="{}{}{}".format(event.description, " (Override)" if event.user_override == "1" else "", " (Extended)" if event.extended_bolus == "1" else "")
)
add_count += 1
logger.info(" Processing bolus: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry)
return add_count
-69
View File
@@ -1,69 +0,0 @@
import json
import arrow
import logging
from ..parser.tconnect import TConnectEntry
from ..parser.nightscout import NightscoutEntry
logger = logging.getLogger(__name__)
def process_cgm_events(readingData):
data = []
for r in readingData:
data.append(TConnectEntry.parse_reading_entry(r))
return data
"""
Given reading data and a time, finds the BG reading event which would have
been the current one at that time. e.g., it looks before the given time,
not after.
This is a heuristic for checking whether the BG component of a bolus was
manually entered or inferred based on the pump's CGM.
"""
def find_event_at(cgmEvents, find_time):
find_t = arrow.get(find_time)
events = list(map(lambda x: (arrow.get(x["time"]), x), cgmEvents))
events.sort()
closestReading = None
for t, r in events:
if t > find_t:
break
closestReading = r
return closestReading
"""
Given processed CGM data, adds reading entries to Nightscout.
"""
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_cgm_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_bg_entry(time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["dateString"])
logger.info("Last Nightscout CGM upload: %s" % last_upload_time)
add_count = 0
for event in cgmEvents:
created_at = event["time"]
if last_upload_time and arrow.get(created_at) <= last_upload_time:
if pretend:
logger.info("Skipping CGM event before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
entry = NightscoutEntry.entry(
sgv=event["bg"],
created_at=created_at
)
add_count += 1
logger.info(" Processing cgm reading: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry, entity='entries')
return add_count
-59
View File
@@ -1,59 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
IOB_ACTIVITYTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given IOB data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_iob_events(iobdata):
iobEvents = []
for d in iobdata:
iobEvents.append(TConnectEntry.parse_iob_entry(d))
iobEvents.sort(key=lambda x: arrow.get(x["time"]))
return iobEvents
"""
Given processed IOB data, creates a single Nightscout activity definition to store IOB.
"""
def ns_write_iob_events(nightscout, iobEvents, pretend=False, time_start=None, time_end=None):
logger.debug("ns_write_iob_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_activity(IOB_ACTIVITYTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout iob upload: %s" % last_upload_time)
if not iobEvents or len(iobEvents) == 0:
logger.info("No IOB events present from API: skipping")
return 0
event = iobEvents[-1]
if last_upload_time and arrow.get(event["time"]) <= last_upload_time:
logger.info(" Skipping already uploaded iob event: %s" % event)
return 0
entry = NightscoutEntry.iob(
iob=event["iob"],
created_at=event["time"]
)
logger.info(" Processing iob: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry, entity='activity')
# Delete the previous activity
if last_upload and '_id' in last_upload:
logger.info(" Deleting old iob entry: %s" % last_upload)
if not pretend:
nightscout.delete_entry('activity/{}'.format(last_upload['_id']))
return 1
-200
View File
@@ -1,200 +0,0 @@
from typing import List, Tuple
import logging
import json
import copy
import arrow
from ..api import TConnectApi
from ..domain.device_settings import Profile, DeviceSettings
from ..parser.nightscout import NightscoutEntry
from ..nightscout import NightscoutApi
from ..secret import PUMP_SERIAL_NUMBER, NIGHTSCOUT_PROFILE_UPLOAD_MODE
logger = logging.getLogger(__name__)
def _get_default_serial_number():
return PUMP_SERIAL_NUMBER
def _get_default_upload_mode():
return NIGHTSCOUT_PROFILE_UPLOAD_MODE
def get_pump_profiles(tconnect: TConnectApi, serial_number: int = None) -> Tuple[List[Profile], DeviceSettings]:
all_devices = tconnect.webui.my_devices()
if serial_number is None:
serial_number = _get_default_serial_number()
if str(serial_number) not in all_devices:
logger.warn("Could not find entry for provided pump serial number in t:connect device list: %s, received: %s", serial_number, all_devices)
return [], None
device = all_devices[str(serial_number)]
logger.info("Getting profile settings for %s", device)
device_profiles, device_settings = tconnect.webui.device_settings_from_guid(device.guid)
logger.debug("device_profiles: %s", device_profiles)
logger.debug("device_settings: %s", device_settings)
logger.info("Found pump profiles: %s", ["%s%s" % (profile.title, " (active)" if profile.active else "") for profile in device_profiles])
return device_profiles, device_settings
"""
Compare pump device and Nightscout profiles, and return a final dictionary of
Nightscout profile objects, with the pump profile settings overriding what is
currently in Nightscout.
ns_profile_obj is the output from NightscoutApi.current_profile() and should be the most
recent profile object in mongo.
Returns the new Nightscout profile and whether it was changed.
"""
def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSettings, ns_profile_obj: dict) -> Tuple[bool, dict]:
device = {profile.title: profile for profile in device_profiles}
ns = ns_profile_obj.get('store', {})
logger.info("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys())
new_ns_profile = copy.deepcopy(ns_profile_obj)
updated_ns_profile = False
missing_profiles_in_ns = set(device.keys()) - set(ns.keys())
for profile_name in missing_profiles_in_ns:
logger.info("Missing %s profile in Nightscout: %s", profile_name, device.get(profile_name))
pump_configured_profile = device[profile_name]
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
logger.info("Will add %s profile to Nightscout: %s", profile_name, ns_translated_profile)
new_ns_profile['store'][profile_name] = ns_translated_profile
updated_ns_profile = True
existent_profiles_in_ns = set(device.keys()) & set(ns.keys())
for profile_name in existent_profiles_in_ns:
logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name)
pump_configured_profile = device[profile_name]
ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings)
ns_configured_profile = ns[profile_name]
logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile)
if nightscout_profiles_identical(ns_configured_profile, ns_translated_profile):
logger.info("Profile %s identical between pump and nightscout", profile_name)
continue
logger.info("Profile %s needs update in nightscout: %s", profile_name, ns_translated_profile)
new_ns_profile['store'][profile_name] = ns_translated_profile
updated_ns_profile = True
current_pump_profile = None
for profile in device_profiles:
if profile.active:
current_pump_profile = profile.title
if not current_pump_profile:
logger.error('No current pump profile, so skipping profile update: device: %s', device_profiles)
return False, ns_profile_obj
current_ns_profile = ns_profile_obj.get('defaultProfile')
if current_pump_profile != current_ns_profile:
logger.info("Current profile changed: pump: %s nightscout: %s", current_pump_profile, current_ns_profile)
new_ns_profile['defaultProfile'] = current_pump_profile
updated_ns_profile = True
if not updated_ns_profile:
logger.info("No Nightscout profile changes")
return False, ns_profile_obj
logger.info("New Nightscout profile object: %s", new_ns_profile)
return True, new_ns_profile
def nightscout_profiles_identical(configured: dict, translated: dict) -> bool:
if configured == translated:
logger.debug("direct dicts equal")
return True
if json.dumps(configured, sort_keys=True, indent=None) == json.dumps(translated, sort_keys=True, indent=None):
logger.debug("initial JSON dump identical")
return True
# convert all JSON values into strings
def map_nested_dicts_modify(ob, func):
for k, v in ob.items():
if isinstance(v, dict):
map_nested_dicts_modify(v, func)
elif isinstance(v, list):
map_nested_lists_modify(v, func)
else:
ob[k] = func(v)
def map_nested_lists_modify(ob, func):
for i in range(len(ob)):
v = ob[i]
if isinstance(v, dict):
map_nested_dicts_modify(v, func)
elif isinstance(v, list):
map_nested_lists_modify(v, func)
else:
ob[i] = func(v)
def to_numeric(x):
if type(x) in [int, float]:
return '%f' % x
try:
return '%f' % float(x)
except (ValueError, TypeError):
return x
convert_func = lambda x: to_numeric(x)
configured_str = json.loads(json.dumps(configured))
map_nested_dicts_modify(configured_str, convert_func)
translated_str = json.loads(json.dumps(translated))
map_nested_dicts_modify(translated_str, convert_func)
if json.dumps(configured_str, sort_keys=True, indent=None) == json.dumps(translated_str, sort_keys=True, indent=None):
logger.debug("map_nested_dicts JSON dump identical")
return True
logger.debug("profiles not identical")
return False
def setup_new_profile(ns_profile: dict) -> dict:
if '_id' in ns_profile:
del ns_profile['_id']
now = arrow.now().isoformat()
ns_profile['startDate'] = now
ns_profile['created_at'] = now
return ns_profile
def process_profiles(tconnect: TConnectApi, nightscout: NightscoutApi, pretend: bool = False, upload_mode: str = None) -> bool:
if not upload_mode:
upload_mode = _get_default_upload_mode()
logger.debug("Checking for differences between pump and nightscout profiles: %s mode", upload_mode)
ns_profile_obj = nightscout.current_profile()
pump_profiles, pump_settings = get_pump_profiles(tconnect)
diff, ns_profile_new = compare_profiles(pump_profiles, pump_settings, ns_profile_obj)
if not diff:
logger.info("Pump and Nightscout profiles up to date")
return False
if upload_mode == 'add':
profile_to_upload = setup_new_profile(ns_profile_new)
logger.info("Adding new Nightscout profiles object: %s", profile_to_upload)
if not pretend:
nightscout.upload_entry(profile_to_upload, entity='profile')
return True
elif upload_mode == 'replace':
logger.info("Replacing new Nightscout profiles object: %s", ns_profile_new)
if not pretend:
nightscout.put_entry(ns_profile_new, entity='profile')
return True
else:
raise RuntimeError('invalid upload_mode: %s' % upload_mode)
-218
View File
@@ -1,218 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
SITECHANGE_EVENTTYPE,
BASALSUSPENSION_EVENTTYPE,
EXERCISE_EVENTTYPE,
SLEEP_EVENTTYPE,
ACTIVITY_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
logger = logging.getLogger(__name__)
"""
Given a list of "activity events" from the CIQ therapy timeline endpoint,
process it into our internal events format.
These events contain a duration.
"""
def process_ciq_activity_events(data):
events = []
for event in data["events"]:
events.append(TConnectEntry.parse_ciq_activity_event(event))
return events
"""
Given a list of "basal suspension events" from the basalsuspension WS2 endpoint,
process it into our internal events format.
These events do NOT contain a duration.
"""
def process_basalsuspension_events(data):
events = []
for event in data['BasalSuspension']:
parsed = TConnectEntry.parse_basalsuspension_event(event)
if parsed:
events.append(parsed)
return events
"""
Given processed pump event data (of various types), write them to Nightscout
"""
def ns_write_pump_events(nightscout, pumpEvents, pretend=False, time_start=None, time_end=None):
count = 0
siteChangeEvents = []
emptyCartEvents = []
userSuspendedEvents = []
exerciseEvents = []
sleepEvents = []
activityEvents = []
for event in pumpEvents:
if event["event_type"] == TConnectEntry.BASALSUSPENSION_EVENTS["site-cart"]:
siteChangeEvents.append(event)
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["alarm"]:
emptyCartEvents.append(event)
elif event["event_type"] in TConnectEntry.BASALSUSPENSION_EVENTS["manual"]:
userSuspendedEvents.append(event)
elif event["event_type"] == "Exercise":
exerciseEvents.append(event)
elif event["event_type"] == "Sleep":
sleepEvents.append(event)
elif event["event_type"] in TConnectEntry.ACTIVITY_EVENTS.values():
activityEvents.append(event)
logger.debug("siteChangeEvents: %s" % siteChangeEvents)
logger.debug("emptyCartEvents: %s" % emptyCartEvents)
logger.debug("userSuspendedEvents: %s" % userSuspendedEvents)
logger.debug("exerciseEvents: %s" % exerciseEvents)
logger.debug("sleepEvents: %s" % sleepEvents)
logger.debug("activityEvents: %s" % activityEvents)
count += ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=pretend, time_start=time_start, time_end=time_end)
count += ns_write_activity_events(nightscout, activityEvents, pretend=pretend, time_start=time_start, time_end=time_end)
return count
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
siteChangeEvents,
lambda event: NightscoutEntry.sitechange(
created_at=event["time"],
reason=event["event_type"]
),
SITECHANGE_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
emptyCartEvents,
lambda event: NightscoutEntry.basalsuspension(
created_at=event["time"],
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
userSuspendedEvents,
lambda event: NightscoutEntry.basalsuspension(
created_at=event["time"],
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
exerciseEvents,
lambda event: NightscoutEntry.activity(
created_at=event["time"],
reason=event["event_type"],
duration=event["duration_mins"],
event_type=EXERCISE_EVENTTYPE
),
EXERCISE_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
sleepEvents,
lambda event: NightscoutEntry.activity(
created_at=event["time"],
reason=event["event_type"],
duration=event["duration_mins"],
event_type=SLEEP_EVENTTYPE
),
SLEEP_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def ns_write_activity_events(nightscout, activityEvents, pretend=False, time_start=None, time_end=None):
return _ns_write_pump_events(
nightscout,
activityEvents,
lambda event: NightscoutEntry.activity(
created_at=event["time"],
reason=event["event_type"],
duration=event["duration_mins"]
),
ACTIVITY_EVENTTYPE,
pretend=pretend,
time_start=time_start,
time_end=time_end)
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False, time_start=None, time_end=None):
if len(events) == 0:
logger.debug("No %s events to process" % eventType)
return 0
logger.debug("ns_write_pump_events: querying for last %s" % eventType)
last_upload = nightscout.last_uploaded_entry(eventType, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout %s: %s" % (eventType, last_upload_time))
if SKIP_NS_LAST_UPLOADED_CHECK:
logger.warning("Overriding last upload check")
last_upload = None
last_upload_time = None
add_count = 0
for event in events:
created_at = arrow.get(event["time"])
if last_upload_time and created_at <= last_upload_time:
skip = True
if "duration_mins" in event.keys() and "duration" in last_upload.keys():
if created_at == arrow.get(last_upload["created_at"]) and float(event["duration_mins"]) > float(last_upload["duration"]):
logger.info("Latest %s event needs updating: duration has increased from %s to %s: %s" % (eventType, last_upload["duration"], event["duration_mins"], event))
logger.info("Deleting previous %s: %s" % (eventType, last_upload))
nightscout.delete_entry('treatments/%s' % last_upload["_id"])
skip = False
if skip:
if pretend:
logger.info("Skipping %s pump event before last upload time: %s (time range: %s - %s)" % (eventType, event, time_start, time_end))
continue
entry = buildNsEventFunc(event)
add_count += 1
logger.info(" Processing %s: %s entry: %s" % (eventType, event, entry))
if not pretend:
nightscout.upload_entry(entry)
return add_count
+18 -10
View File
@@ -18,7 +18,7 @@ class TandemSourceAutoupdate:
self.last_max_date_with_events = None
self.last_event_time = 0
self.last_attempt_time = 0
self.last_event_index = None
self.last_event_seqnum = None
self.time_diffs_between_attempts = []
self.time_diffs_between_updates = []
@@ -27,11 +27,11 @@ class TandemSourceAutoupdate:
until stopped (ctrl+c), or a maximum of AUTOUPDATE_MAX_LOOP_INVOCATIONS times.
Stops if AUTOUPDATE_RESTART_ON_FAILURE is set and an error occurs.
"""
def process(self, tconnect, nightscout, time_start, time_end, pretend, features=None):
def process(self, tconnect, nightscout, pretend, features=None):
if features is None:
features = DEFAULT_FEATURES
# Read from android api, find exact interval to cut down on API calls
# Query for data, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
self.autoupdate_start = time.time()
@@ -40,9 +40,12 @@ class TandemSourceAutoupdate:
logger.debug("autoupdate loop")
now = time.time()
time_end = datetime.datetime.now()
time_start = time_end - datetime.timedelta(days=1)
tconnectDevice = ChooseDevice(self.secret, tconnect).choose()
event_id = None
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))
@@ -50,26 +53,31 @@ class TandemSourceAutoupdate:
if pretend:
logger.info('Would update now if not in pretend mode')
else:
added, event_id = ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, features=features).process(time_start, time_end)
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_index:
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)
# Mark the last event index uploaded from the pump and timestamp
if event_id:
self.last_event_index = event_id
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)' % cur_max_date_with_events)
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
))
# If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# then trigger an error and potentially restart.
@@ -93,7 +101,7 @@ class TandemSourceAutoupdate:
# above no indexes warning.
elif self.last_successful_process_time_range and (now - self.last_successful_process_time_range) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateNoNewDataDetectedError(
"%s: No new data has been detected via the API for %d minutes. " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60) +
"%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."))
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
@@ -24,6 +24,20 @@ class ChooseDevice:
tconnectDevice = serialNumberToPump[str(self.secret.PUMP_SERIAL_NUMBER)]
# Warn if pump is stale (no events in >3 days)
try:
max_event_date = arrow.get(tconnectDevice["maxDateWithEvents"])
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']}). "
"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.info(f'Using pump with serial: {tconnectDevice["serialNumber"]} (tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
else:
maxDateSeen = None
@@ -0,0 +1,20 @@
from ...features import DEFAULT_FEATURES
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
from .choose_device import ChooseDevice
from .process import ProcessTimeRange
from ...api import TConnectApi
from ... import secret
import datetime
import logging
logger = logging.getLogger(__name__)
def fetch_oneshot(username, password, time_start=None, time_end=None, region='US'):
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)
@@ -1,5 +1,5 @@
from ...features import DEFAULT_FEATURES
from .choose_device import TandemSourceChooseDevice
from .choose_device import ChooseDevice
from .process import ProcessTimeRange
from ... import secret
@@ -13,5 +13,5 @@ def run_oneshot(tconnect, nightscout, pretend=False, features=DEFAULT_FEATURES,
if not secret_arg:
secret_arg = secret
tconnectDevice = TandemSourceChooseDevice(secret_arg, tconnect).choose()
return ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, features).process(time_start, time_end)
tconnectDevice = ChooseDevice(secret_arg, tconnect).choose()
return ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, secret_arg, features).process(time_start, time_end)
+23 -16
View File
@@ -1,8 +1,8 @@
import logging
import collections
import arrow
from ...features import DEFAULT_FEATURES
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
from ...features import DEVICE_STATUS, DEFAULT_FEATURES
from ...eventparser import events as eventtypes
from ...domain.tandemsource.event_class import EventClass
from .process_basal import ProcessBasal
@@ -14,18 +14,20 @@ from .process_cartridge import ProcessCartridge
from .process_cgm_alert import ProcessCGMAlert
from .process_cgm_start_join_stop import ProcessCGMStartJoinStop
from .process_cgm_reading import ProcessCGMReading
from .process_device_status import ProcessDeviceStatus
from .process_user_mode import ProcessUserMode
from .update_profiles import UpdateProfiles
logger = logging.getLogger(__name__)
class ProcessTimeRange:
def __init__(self, tconnect, nightscout, tconnectDevice, pretend, features=DEFAULT_FEATURES):
def __init__(self, tconnect, nightscout, tconnectDevice, pretend, secret, features=DEFAULT_FEATURES):
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnectDevice['tconnectDeviceId']
self.max_date_with_events = tconnectDevice['maxDateWithEvents']
self.pretend = pretend
self.secret = secret
self.features = features
event_classes = {
@@ -39,6 +41,7 @@ class ProcessTimeRange:
EventClass.CGM_START_JOIN_STOP.name: ProcessCGMStartJoinStop,
EventClass.CGM_READING.name: ProcessCGMReading,
EventClass.USER_MODE.name: ProcessUserMode,
EventClass.DEVICE_STATUS.name: ProcessDeviceStatus
}
updater_classes = [
@@ -46,27 +49,25 @@ class ProcessTimeRange:
]
def process(self, time_start, time_end):
logger.info(f"ProcessTimeRange time_start={time_start} time_end={time_end} tconnect_device_id={self.tconnect_device_id} features={self.features}")
fetch_all_event_types = self.secret.FETCH_ALL_EVENT_TYPES or DEVICE_STATUS in self.features
pump_events_raw = self.tconnect.tandemsource.pump_events_raw(self.tconnect_device_id, time_start, time_end)
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)")
events = Events(pump_events_decoded)
logger.info(f"ProcessTimeRange time_start={time_start} time_end={time_end} tconnect_device_id={self.tconnect_device_id} features={self.features} fetch_all_event_types={fetch_all_event_types}")
events = self.tconnect.tandemsource.pump_events(self.tconnect_device_id, time_start, time_end, fetch_all_event_types=fetch_all_event_types)
events_first_time = None
events_last_time = None
last_event_id = None # aka seqnum
last_event_seqnum = None
for_eventclass = collections.defaultdict(list)
for event in events:
if not events_first_time:
events_first_time = event.eventTimestamp
if not events_last_time:
events_last_time = event.eventTimestamp
if not last_event_id:
last_event_id = event.eventId
if not last_event_seqnum:
last_event_seqnum = event.seqNum
events_first_time = min(events_first_time, event.eventTimestamp)
events_last_time = max(events_last_time, event.eventTimestamp)
last_event_id = max(event.eventId, last_event_id)
last_event_seqnum = max(event.seqNum, last_event_seqnum)
clazz = EventClass.for_event(event)
if clazz:
@@ -81,8 +82,14 @@ class ProcessTimeRange:
c = self.event_classes[clazz](self.tconnect, self.nightscout, self.tconnect_device_id, self.pretend, self.features)
if c.enabled():
logger.info("%s is enabled from features %s" % (clazz, self.features))
ns_entries = c.process(events, events_first_time, events_last_time)
processed_count += c.write(ns_entries)
# Cap events_last_time at time_end to handle pump clock drift
# Ensure time_end is timezone-aware for comparison
time_end_aware = arrow.get(time_end)
capped_time_end = min(events_last_time, time_end_aware) if events_last_time else time_end_aware
ns_entries = c.process(events, events_first_time, capped_time_end)
w = c.write(ns_entries)
if w:
processed_count += w
else:
logger.info("Skipping %s, is not enabled from features %s" % (clazz, self.features))
@@ -95,6 +102,6 @@ class ProcessTimeRange:
else:
logger.info("Skipping %s, is not enabled from features %s" % (updater_class.__name__, self.features))
logger.info("Processed %d events. Last event ID seen: %d" % (processed_count, last_event_id))
return processed_count, last_event_id
logger.info("Processed %d events. Last event ID seen: %d" % (processed_count if processed_count else 0, last_event_seqnum if last_event_seqnum else -1))
return processed_count, last_event_seqnum
@@ -40,11 +40,20 @@ class ProcessAlarm:
logger.info("Skipping Alarm event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
if self.skip_event(event):
continue
ns_entries.append(self.alarm_to_nsentry(event))
return ns_entries
def skip_event(self, event):
return event.alarmid in (
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm,
eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm2
)
def write(self, ns_entries):
count = 0
for entry in ns_entries:
@@ -63,11 +72,11 @@ class ProcessAlarm:
return NightscoutEntry.alarm(
created_at = event.eventTimestamp.format(),
reason = "%s" % event.alarmid.name,
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
elif type(event) == eventtypes.LidMalfunctionActivated:
return NightscoutEntry.alarm(
created_at = event.eventTimestamp.format(),
reason = "Malfunction",
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
@@ -1,6 +1,7 @@
import logging
import arrow
from ...secret import IGNORE_ZERO_UNIT_BASAL
from ...features import DEFAULT_FEATURES
from ... import features
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
@@ -54,7 +55,9 @@ class ProcessBasal:
ns_entries = []
for item in with_duration:
ns_entries.append(self.basal_to_nsentry(*item))
ns = self.basal_to_nsentry(*item)
if ns:
ns_entries.append(ns)
return ns_entries
@@ -73,18 +76,26 @@ class ProcessBasal:
def basal_to_nsentry(self, start, duration, event):
if type(event) == eventtypes.LidBasalRateChange:
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 = insulin_float_round(event.commandedbasalrate),
value = value,
duration_mins = duration.seconds / 60,
created_at = start.format(),
reason = ', '.join(bitmask_to_list(event.changetype)),
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
if type(event) == eventtypes.LidBasalDelivery:
value = insulin_milliunits_to_real(event.commandedRate)
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 = insulin_milliunits_to_real(event.commandedRate),
value = value,
duration_mins = duration.seconds / 60,
created_at = start.format(),
reason = ', '.join(bitmask_to_list(event.commandedRateSource)),
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
@@ -62,5 +62,5 @@ class ProcessBasalResume:
if type(event) == eventtypes.LidPumpingResumed:
return NightscoutEntry.basalresume(
created_at = event.eventTimestamp.format(),
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
@@ -63,5 +63,5 @@ class ProcessBasalSuspension:
return NightscoutEntry.basalsuspension(
created_at = event.eventTimestamp.format(),
reason = ', '.join(bitmask_to_list(event.suspendreason)),
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
@@ -46,7 +46,7 @@ class ProcessBolus:
if type(event) == eventtypes.LidBolusCompleted:
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping bolusCompletedEvent before last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
logger.info("Skipping bolusCompletedEvent not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
bolusCompletedEvents.append(event)
@@ -91,10 +91,10 @@ class ProcessBolus:
suffix = (' ' + (' '.join(suffixes))) if suffixes else ''
event_ids = []
seq_nums = []
for e in [bolusCompleted, bolusRequested1, bolusRequested2, bolusRequested3]:
if e:
event_ids.append(str(e.eventId))
seq_nums.append(str(e.seqNum))
notes = ''
if bolusRequested2 and str(bolusRequested2.optionsRaw) in eventtypes.LidBolusRequestedMsg2.OptionsMap:
@@ -107,6 +107,6 @@ class ProcessBolus:
created_at = bolusCompleted.eventTimestamp.format(),
notes = notes + suffix,
bg = bolusRequested1.BG if bolusRequested1 and bolusRequested1.BG > 0 else None,
pump_event_id = ",".join(event_ids)
pump_event_id = ",".join(seq_nums)
)
@@ -82,19 +82,19 @@ class ProcessCartridge:
return NightscoutEntry.sitechange(
created_at = cartFilled.eventTimestamp.format(),
reason = "Cartridge Filled" + (" (%du filled)" % round(cartFilled.v2Volume) if cartFilled.v2Volume else ""),
pump_event_id = "%s" % cartFilled.eventId
pump_event_id = "%s" % cartFilled.seqNum
)
def cannula_to_nsentry(self, cannulaFilled):
return NightscoutEntry.sitechange(
created_at = cannulaFilled.eventTimestamp.format(),
reason = "Cannula Filled" + (" (%du primed)" % round(cannulaFilled.primesize, 2) if cannulaFilled.primesize else ""),
pump_event_id = "%s" % cannulaFilled.eventId
pump_event_id = "%s" % cannulaFilled.seqNum
)
def tubing_to_nsentry(self, tubingFilled):
return NightscoutEntry.sitechange(
created_at = tubingFilled.eventTimestamp.format(),
reason = "Tubing Filled" + (" (%du primed)" % round(tubingFilled.primesize) if tubingFilled.primesize else ""),
pump_event_id = "%s" % tubingFilled.eventId
pump_event_id = "%s" % tubingFilled.seqNum
)
@@ -73,7 +73,7 @@ class ProcessCGMAlert:
return NightscoutEntry.cgm_alert(
created_at = alert.eventTimestamp.format(),
reason = ("CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "CGM Alert (Unknown)",
pump_event_id = "%s" % alert.eventId
pump_event_id = "%s" % alert.seqNum
)
elif type(alert) == eventtypes.LidCgmAlertActivatedDex:
if alert.dalertid == eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmOutOfRange:
@@ -82,11 +82,11 @@ class ProcessCGMAlert:
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)",
pump_event_id = "%s" % alert.eventId
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)",
pump_event_id = "%s" % alert.eventId
pump_event_id = "%s" % alert.seqNum
)
@@ -72,5 +72,5 @@ class ProcessCGMReading:
return NightscoutEntry.entry(
sgv = event.currentglucosedisplayvalue,
created_at = self.timestamp_for(event).format(),
pump_event_id = "%s" % event.eventId,
pump_event_id = "%s" % event.seqNum,
)
@@ -80,17 +80,17 @@ class ProcessCGMStartJoinStop:
return NightscoutEntry.cgm_start(
created_at = event.eventTimestamp.format(),
reason = "CGM Session Started",
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
elif type(event) in EventClass._CGM_JOIN:
return NightscoutEntry.cgm_join(
created_at = event.eventTimestamp.format(),
reason = "CGM Session Joined",
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
elif type(event) in EventClass._CGM_STOP:
return NightscoutEntry.cgm_stop(
created_at = event.eventTimestamp.format(),
reason = "CGM Session Stopped",
pump_event_id = "%s" % event.eventId
pump_event_id = "%s" % event.seqNum
)
@@ -0,0 +1,78 @@
import logging
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 ...parser.nightscout import (
EXERCISE_EVENTTYPE,
SLEEP_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessDeviceStatus:
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
self.tconnect = tconnect
self.nightscout = nightscout
self.tconnect_device_id = tconnect_device_id
self.pretend = pretend
self.features = features
def enabled(self):
return features.DEVICE_STATUS in self.features
def process(self, events, time_start, time_end):
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
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("ProcessDeviceStatus: Last Nightscout devicestatus upload: %s" % last_upload_time)
last_daily_basal_event = None
for event in sorted(events, key=lambda x: x.raw.timestamp):
if last_upload_time and event.raw.timestamp <= last_upload_time:
if self.pretend:
logger.info("ProcessDeviceStatus: Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
continue
if isinstance(event, eventtypes.LidDailyBasal):
last_daily_basal_event = event
if not last_daily_basal_event:
logger.info("ProcessDeviceStatus: No last_daily_basal_event found for add (time range: %s - %s)" % (time_start, time_end))
return []
logger.info("ProcessDeviceStatus: last_daily_basal_event=%s" % (last_daily_basal_event))
ns_entries = []
ns_entries.append(self.daily_basal_to_nsentry(last_daily_basal_event))
return ns_entries
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),
pump_event_id = "%s" % event.seqNum
)
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload devicestatus to Nightscout: %s" % entry)
else:
logger.info("Uploading devicestatus to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry, entity='devicestatus')
count += 1
return count
@@ -157,7 +157,7 @@ class ProcessUserMode:
reason=reason,
duration=duration_mins,
event_type=SLEEP_EVENTTYPE,
pump_event_id = "%s,%s" % (start.eventId, stop.eventId)
pump_event_id = "%s,%s" % (start.seqNum, stop.seqNum)
)
elif start:
reason = None
@@ -172,7 +172,7 @@ class ProcessUserMode:
reason=reason + " - " + NOT_ENDED if reason else NOT_ENDED,
duration=duration_mins,
event_type=SLEEP_EVENTTYPE,
pump_event_id = "%s" % start.eventId
pump_event_id = "%s" % start.seqNum
)
@@ -191,20 +191,20 @@ class ProcessUserMode:
reason=reason,
duration=duration_mins,
event_type=EXERCISE_EVENTTYPE,
pump_event_id = "%s,%s" % (start.eventId, stop.eventId)
pump_event_id = "%s,%s" % (start.seqNum, stop.seqNum)
)
elif start:
reason = "Exercise"
if start.exercisechoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
reason = "Exercise (Timed)"
duration_mins = (time_end.eventTimestamp - start.eventTimestamp).seconds / 60
duration_mins = (time_end - start.eventTimestamp).seconds / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason + " - " + NOT_ENDED,
duration=duration_mins,
event_type=EXERCISE_EVENTTYPE,
pump_event_id = "%s,%s" % (start.eventId, stop.eventId)
pump_event_id = "%s" % start.seqNum
)
def process_unended_sleep_stop(self, event, sleep_last_upload):
@@ -220,7 +220,7 @@ class ProcessUserMode:
reason=sleep_last_upload["reason"].replace(" - %s" % NOT_ENDED, ""),
duration=duration_mins,
event_type=SLEEP_EVENTTYPE,
pump_event_id="%s,%s" % (sleep_last_upload.get("pump_event_id",""), event.eventId)
pump_event_id="%s,%s" % (sleep_last_upload.get("pump_event_id",""), event.seqNum)
)
def process_unended_exercise_stop(self, event, exercise_last_upload):
@@ -240,5 +240,5 @@ class ProcessUserMode:
reason=reason,
duration=duration_mins,
event_type=EXERCISE_EVENTTYPE,
pump_event_id="%s,%s" % (exercise_last_upload.get("pump_event_id",""), event.eventId)
pump_event_id="%s,%s" % (exercise_last_upload.get("pump_event_id",""), event.seqNum)
)
@@ -8,7 +8,7 @@ from ...features import DEFAULT_FEATURES
from ... import features
from ...domain.tandemsource.pump_settings import PumpSettings
from ...parser.nightscout import (
NightscoutEntry
NightscoutEntry, ENTERED_BY
)
from ...secret import NIGHTSCOUT_PROFILE_UPLOAD_MODE
@@ -49,10 +49,12 @@ class UpdateProfiles:
logger.info("Current pump settings: %s" % pump_settings)
ns_profile_obj = self.nightscout.current_profile()
logger.info("Current Nightscout profile: %s" % ns_profile_obj)
logger.debug("Current Nightscout profile: %s" % ns_profile_obj)
if ns_profile_obj is None:
ns_profile_obj = {}
logger.info("Current Nightscout profile was authored by: %s" % (ns_profile_obj.get('enteredBy')))
diff, ns_profile_new = self.compare_profiles(pump_settings, ns_profile_obj)
if not diff:
logger.info("Pump and Nightscout profiles up to date")
@@ -93,7 +95,7 @@ class UpdateProfiles:
ns = ns_profile_obj.get('store', {})
logger.info("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys())
logger.debug("compare_profiles profile names: device: %s ns: %s", device.keys(), ns.keys())
new_ns_profile = copy.deepcopy(ns_profile_obj)
if not 'store' in new_ns_profile:
@@ -111,12 +113,12 @@ class UpdateProfiles:
existent_profiles_in_ns = set(device.keys()) & set(ns.keys())
for profile_name in existent_profiles_in_ns:
logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name)
#logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name)
pump_configured_profile = device[profile_name]
ns_translated_profile = NightscoutEntry.tandemsource_profile_store(pump_configured_profile, pump_settings)
ns_configured_profile = ns[profile_name]
logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile)
#logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile)
if self.nightscout_profiles_identical(ns_configured_profile, ns_translated_profile):
logger.info("Profile %s identical between pump and nightscout", profile_name)
continue
@@ -145,6 +147,7 @@ class UpdateProfiles:
return False, ns_profile_obj
logger.info("New Nightscout profile object: %s", new_ns_profile)
new_ns_profile['enteredBy'] = ENTERED_BY
return True, new_ns_profile
def nightscout_profiles_identical(self, configured: dict, translated: dict) -> bool:
+2 -2
View File
@@ -16,5 +16,5 @@ Returns a TConnectApi object with default secret parameters.
"""
def get_api():
from ..api import TConnectApi
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION)
+1 -1
View File
@@ -46,7 +46,7 @@ class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
def my_devices(self):
raise NotImplementedError
def device_settings(self, pump_guid):
raise NotImplementedError
+3
View File
@@ -26,6 +26,9 @@ class NightscoutApi(tconnectsync.nightscout.NightscoutApi):
def last_uploaded_activity(self, activityType):
raise NotImplementedError
def last_uploaded_devicestatus(self, time_start=None, time_end=None):
raise NotImplementedError
def api_status(self):
raise NotImplementedError
+1 -1
View File
@@ -3,8 +3,8 @@
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 ..sync.test_profile import DEVICE_PROFILE_A, DEVICE_SETTINGS, NS_PROFILE_A
class TestNightscoutEntry(unittest.TestCase):
maxDiff = None
def test_basal(self):
+270
View File
@@ -0,0 +1,270 @@
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
}
View File
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
import unittest
import arrow
from tconnectsync.sync.tandemsource.process import ProcessTimeRange
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
from ...secrets import build_secrets
# Raw event bytes for testing
# LidBasalDelivery (id=279) at 2025-11-18 13:12:40-05:00, rate=800 milliunits
BASAL_EVENT_1 = b'\x01\x17!\xa2\xeeH\x00\x01\x86\xa1\x00\x00\x00\x03\x03 \x03 \x00\x00\x03 \x00\x00\x00\x00'
# LidBasalDelivery (id=279) at 2025-11-18 13:17:40-05:00, rate=800 milliunits
BASAL_EVENT_2 = b'\x01\x17!\xa2\xeft\x00\x01\x86\xa2\x00\x00\x00\x03\x03 \x03 \x00\x00\x03 \x00\x00\x00\x00'
# LidCgmDataG7 (id=399) at 2025-11-19 03:00:00-05:00 (future timestamp for testing clock drift)
CGM_EVENT_FUTURE = b'\x01\x8f!\xa3\xb00\x00\x03\rA\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
# LidCgmDataG7 (id=399) at 2025-11-18 13:22:40-05:00 (normal timestamp)
CGM_EVENT_NORMAL = b'\x01\x8f!\xa2\xf0\xa0\x00\x03\rB\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
class FakeTandemSourceApi:
"""Fake TandemSource API for testing"""
def __init__(self):
self.events = []
def pump_events(self, device_id, time_start, time_end, fetch_all_event_types=False):
return self.events
def pump_event_metadata(self):
"""Return empty metadata for testing"""
return {}
def needs_relogin(self):
return False
class TestProcessTimeRangeBasalDuration(unittest.TestCase):
"""Test that basal duration calculation caps events_last_time at time_end"""
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.tconnect._tandemsource = FakeTandemSourceApi()
self.nightscout = NightscoutApi()
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
self.tconnectDevice = {
'tconnectDeviceId': 'test-device-123',
'maxDateWithEvents': '2025-11-18T13:00:00-05:00'
}
self.secret = build_secrets(
FETCH_ALL_EVENT_TYPES=False
)
self.process = ProcessTimeRange(
self.tconnect,
self.nightscout,
self.tconnectDevice,
pretend=False,
secret=self.secret
)
def test_basal_duration_capped_when_future_event_timestamp(self):
"""Test that basal duration is capped at time_end when last event is in the future"""
# Create events from raw bytes
basal_event_1 = Event(BASAL_EVENT_1) # 2025-11-18 13:12:40-05:00
basal_event_2 = Event(BASAL_EVENT_2) # 2025-11-18 13:17:40-05:00
future_cgm_event = Event(CGM_EVENT_FUTURE) # 2025-11-19 03:00:00-05:00 (future)
self.assertEqual(type(basal_event_1), eventtypes.LidBasalDelivery)
self.assertEqual(type(basal_event_2), eventtypes.LidBasalDelivery)
self.assertEqual(type(future_cgm_event), eventtypes.LidCgmDataG7)
# Set up the fake API to return these events
self.tconnect._tandemsource.events = [basal_event_1, basal_event_2, future_cgm_event]
# time_end is "now" at 13:29:00
time_start = arrow.get('2025-11-18T13:00:00-05:00')
time_end = arrow.get('2025-11-18T13:29:00-05:00')
# Process the events
count, last_seqnum = self.process.process(time_start, time_end)
# Verify that basal events were uploaded
self.assertEqual(len(self.nightscout.uploaded_entries['treatments']), 2)
# First basal: from 13:12:40 to 13:17:40 = 5 minutes
basal_1 = self.nightscout.uploaded_entries['treatments'][0]
self.assertEqual(basal_1['eventType'], 'Temp Basal')
self.assertEqual(basal_1['created_at'], '2025-11-18 13:12:40-05:00')
self.assertEqual(basal_1['duration'], 5.0)
# Second basal: from 13:17:40 to time_end (13:29:00) = 11.333... minutes
# NOT from 13:17:40 to future_cgm_event (03:00:00) which would be ~822 minutes
basal_2 = self.nightscout.uploaded_entries['treatments'][1]
self.assertEqual(basal_2['eventType'], 'Temp Basal')
self.assertEqual(basal_2['created_at'], '2025-11-18 13:17:40-05:00')
# Duration should be capped at time_end, not extended to future CGM event
expected_duration = (time_end - arrow.get('2025-11-18T13:17:40-05:00')).seconds / 60
self.assertAlmostEqual(basal_2['duration'], expected_duration, places=2)
# Verify it's NOT the inflated duration to the future event
self.assertLess(basal_2['duration'], 100) # Should be ~11 min, not ~822 min
def test_basal_duration_normal_when_all_events_in_past(self):
"""Test that basal duration uses events_last_time when it's <= time_end"""
# Create events from raw bytes
basal_event_1 = Event(BASAL_EVENT_1) # 2025-11-18 13:12:40-05:00
basal_event_2 = Event(BASAL_EVENT_2) # 2025-11-18 13:17:40-05:00
cgm_event = Event(CGM_EVENT_NORMAL) # 2025-11-18 13:22:40-05:00 (normal)
self.assertEqual(type(basal_event_1), eventtypes.LidBasalDelivery)
self.assertEqual(type(basal_event_2), eventtypes.LidBasalDelivery)
self.assertEqual(type(cgm_event), eventtypes.LidCgmDataG7)
# Set up the fake API to return these events
self.tconnect._tandemsource.events = [basal_event_1, basal_event_2, cgm_event]
time_start = arrow.get('2025-11-18T13:00:00-05:00')
time_end = arrow.get('2025-11-18T13:29:00-05:00')
# Process the events
count, last_seqnum = self.process.process(time_start, time_end)
# Verify that basal events were uploaded
self.assertEqual(len(self.nightscout.uploaded_entries['treatments']), 2)
# First basal: from 13:12:40 to 13:17:40 = 5 minutes
basal_1 = self.nightscout.uploaded_entries['treatments'][0]
self.assertEqual(basal_1['duration'], 5.0)
# Second basal: should use events_last_time (13:22:40) not time_end (13:29:00)
# Duration: 13:17:40 to 13:22:40 = 5 minutes
basal_2 = self.nightscout.uploaded_entries['treatments'][1]
expected_duration = (arrow.get('2025-11-18T13:22:40-05:00') - arrow.get('2025-11-18T13:17:40-05:00')).seconds / 60
self.assertAlmostEqual(basal_2['duration'], expected_duration, places=2)
self.assertEqual(basal_2['duration'], 5.0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import unittest
import arrow
from tconnectsync.sync.tandemsource.process_alarm import ProcessAlarm
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
class TestProcessAlarm(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessAlarm(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_resume_alarm_ignored(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-11-11 07:24:29-05:00
Event(b'\x00\x05\x1f\xb8.\xad\x00\x0e\x91\xee\x00\x00\x00\x12\x00\x00 w\x01N\x0b\x16\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAlarmActivated)
self.assertEqual(events[0].alarmid, eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 0)
def test_empty_cartridge_alarm(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-11-17 08:44:17-05:00
Event(b'\x00\x05\x1f\xc0*a\x00\x0e\xf5\x90\x00\x00\x00\x08\x00\x00 1\x00\x00\x00gA\x1a\x1e\x84')
]
self.assertEqual(type(events[0]), eventtypes.LidAlarmActivated)
self.assertEqual(events[0].alarmid, eventtypes.LidAlarmActivated.AlarmidEnum.EmptyCartridgeAlarm)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'eventType': 'Alarm',
'created_at': '2024-11-17 08:44:17-05:00',
'enteredBy': 'Pump (tconnectsync)',
'notes': 'EmptyCartridgeAlarm',
'reason': 'EmptyCartridgeAlarm',
'pump_event_id': '980368'
})
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
import unittest
import struct
from tconnectsync.sync.tandemsource.process_device_status import ProcessDeviceStatus
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.events import UINT16
from tconnectsync.eventparser.generic import Event
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
class TestProcessDeviceStatus(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessDeviceStatus(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_single_event_no_last_uploaded(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'created_at': '2024-12-03 23:40:23-05:00',
'device': 'Pump (tconnectsync)',
'pump': {
'clock': '2024-12-03 23:40:23-05:00',
'battery': {
'status': '32%',
'percent': 32,
'voltage': 14.08
}
},
'pump_event_id': '1046436'
})
def test_single_event_already_uploaded(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 0)
def test_multiple_event_with_last_uploaded(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'),
Event(b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
self.assertEqual(events[1].batterychargepercentmsbRaw, 14)
self.assertEqual(events[1].batterychargepercentlsbRaw, 243)
self.assertEqual(events[1].batterylipomillivolts, 13824)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'created_at': '2024-12-04 02:30:23-05:00',
'device': 'Pump (tconnectsync)',
'pump': {
'clock': '2024-12-04 02:30:23-05:00',
'battery': {
'status': '31%',
'percent': 31,
'voltage': 13.824
}
},
'pump_event_id': '1046875'
})
def test_multiple_event_only_latest_applied(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'),
Event(b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00'),
Event(b'\x00Q\x1f\xd6k\x1f\x00\x0f\xfc>A\x0e\xa3\x80?\x9a~\xfa@\x11z6\x0e\xee5\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
self.assertEqual(events[1].batterychargepercentmsbRaw, 14)
self.assertEqual(events[1].batterychargepercentlsbRaw, 243)
self.assertEqual(events[1].batterylipomillivolts, 13824)
self.assertEqual(type(events[2]), eventtypes.LidDailyBasal)
self.assertEqual(events[2].raw.timestampRaw, 534145823) # 2024-12-04 05:50:23-05:00
self.assertEqual(events[2].batterychargepercentmsbRaw, 14)
self.assertEqual(events[2].batterychargepercentlsbRaw, 238)
self.assertEqual(events[2].batterylipomillivolts, 13568)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'created_at': '2024-12-04 05:50:23-05:00',
'device': 'Pump (tconnectsync)',
'pump': {
'clock': '2024-12-04 05:50:23-05:00',
'battery': {
'status': '30%',
'percent': 30,
'voltage': 13.568
}
},
'pump_event_id': '1047614'
})
@unittest.skip
def test_device_status_battery_calculation(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
def test_percentage_about(percent_str, event):
with self.subTest(percent_str, event=event):
# p = self.process.process([event], time_start=None, time_end=None)
# self.assertEqual(len(p), 1)
# self.assertEqual(p[0]['pump']['battery']['status'], percent_str)
msb = event.batterychargepercentmsbRaw
lsb = event.batterychargepercentlsbRaw
MAX = struct.unpack(UINT16, bytearray((16, 128)))[0]
MIN = struct.unpack(UINT16, bytearray((14, 128)))[0]
val = struct.unpack(UINT16, bytearray((msb, lsb)))[0]
pct = (val - MIN)/(MAX-MIN)
# print(pct)
# print(struct.unpack(UINT16, bytearray((msb, lsb))
# (14, 100) =~ 0%
# (15, 100) =~ 50%
# (16, 98) =~ 100%
# msb*256 + lsb - 14*256
# pct = (msb*256 + lsb - 14*256 - 100) / 512
calc_str = "%.0f%s" % (100*pct, '%')
print(f'comp {calc_str=} {percent_str=}')
self.assertEqual(calc_str, percent_str)
test_percentage_about('80%', Event(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'))
test_percentage_about('55%', Event(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00'))
test_percentage_about('45%', Event(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00'))
test_percentage_about('15%', Event(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00'))
test_percentage_about('20%', Event(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00'))
test_percentage_about('10%', Event(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00'))
test_percentage_about('10%', Event(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00'))
test_percentage_about('5%', Event(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00'))
# Mobi @ MAX seen
# bytearray(b'\x00Q \x19U=\x00\x01\x0f\xa8A\xa5\x04V?L\xcc\xcd?s\x83b\x10Pd\x01')
# batterychargepercentmsbRaw=16, batterychargepercentlsbRaw=80, batterylipomillivolts=25601
# Mobi @ ~80%
# bytearray(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'):
# batterychargepercentmsbRaw=15, batterychargepercentlsbRaw=161, batterylipomillivolts=20480
# calc batteryChargePercent = 0.54296875
# Mobi @ ~55%
# bytearray(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00')
# batterychargepercentmsbRaw=15, batterychargepercentlsbRaw=29, batterylipomillivolts=15616
# calc batteryChargePercent = 0.37109375
# Mobi @ ~45%
# bytearray(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=250 batterylipomillivolts=14080
# calc batteryChargePercent = 0.3255208333333333
# Mobi @ ~15%
# bytearray(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=181 batterylipomillivolts=8192
# calc batteryChargePercent = 0.23567708333333334
# Mobi @ ~20%
# bytearray(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=193 batterylipomillivolts=9216
# calc batteryChargePercent = 0.2513020833333333
# Mobi @ ~10%
# bytearray(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=161 batterylipomillivolts=6912
# calc batteryChargePercent = 0.20963541666666666
# Mobi @ ~10%
# bytearray(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=159 batterylipomillivolts=6656
# calc batteryChargePercent = 0.20703125
# Mobi @ ~5%
# bytearray(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=136
# calc batteryChargePercent = 0.17708333333333334 batterylipomillivolts=5376
# Mobi @ ~5%
# bytearray(b'\x00Q \x1e\xb0\xcd\x00\x01C\xd3@L9W@#33@\x8b\x04\xd2\x0e\x88\x15\x00')
# batterychargepercentmsbRaw=14 batterychargepercentlsbRaw=136 batterylipomillivolts=5376
# calc 0.17708333333333334
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
import unittest
import arrow
from tconnectsync.sync.tandemsource.process_user_mode import ProcessUserMode
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
class TestProcessUserModeSleep(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessUserMode(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_single_start_sleep_active(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - sleep start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x01\x00\x01\x00\x00\x01\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled) - Not Ended',
"notes": 'Sleep (Scheduled) - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_single_stop_sleep_no_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 09:01:23-05:00 - sleep end
Event(b'\x00\xe5\x1f\xd6\x97\xe3\x00\x0f\xfe\xb0\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 0)
self.assertEqual(self.nightscout.deleted_entries, [])
def test_start_and_stop_sleep(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - sleep start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x01\x00\x01\x00\x00\x01\x00\x00\xf0\x01\x01\x00\x00\x00\x00'),
# 2024-12-05 09:01:23-05:00 - sleep end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep)
self.assertEqual(type(events[1]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[1].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
self.assertEqual(events[1].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[1].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled)',
"notes": 'Sleep (Scheduled)',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_stop_with_partial_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda eventType, **kwargs: {
'Sleep': {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled) - Not Ended',
"notes": 'Sleep (Scheduled) - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050',
"_id": "id_to_delete"
}
}.get(eventType)
events = [
# 2024-12-05 09:01:23-05:00 - sleep end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled)',
"notes": 'Sleep (Scheduled)',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, ['treatments/id_to_delete'])
class TestProcessUserModeExercise(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessUserMode(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_single_start_exercise_active(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - exercise start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x03\x00\x02\x00\x00\x01\x00\x00\xf0\x00\x01\x00\x00\x00\x00'),
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Exercising)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Exercise',
"reason": 'Exercise - Not Ended',
"notes": 'Exercise - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_single_stop_exercise_no_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 09:01:23-05:00 - exercise end
Event(b'\x00\xe5\x1f\xd6\x97\xe3\x00\x0f\xfe\xb0\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x01\x03\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 0)
self.assertEqual(self.nightscout.deleted_entries, [])
def test_start_and_stop_exercise(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - exercise start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x03\x00\x02\x00\x00\x01\x00\x00\xf0\x00\x01\x00\x00\x00\x00'),
# 2024-12-05 09:01:23-05:00 - exercise end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Exercising)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise)
self.assertEqual(type(events[1]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[1].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
self.assertEqual(events[1].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[1].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Exercise',
"reason": 'Exercise',
"notes": 'Exercise',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_stop_with_partial_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda eventType, **kwargs: {
'Exercise': {
"eventType": 'Exercise',
"reason": 'Exercise - Not Ended',
"notes": 'Exercise - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050',
"_id": 'id_to_delete'
}
}.get(eventType)
events = [
# 2024-12-05 09:01:23-05:00 - exercise end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Exercise',
"reason": 'Exercise',
"notes": 'Exercise',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, ['treatments/id_to_delete'])
if __name__ == '__main__':
unittest.main()
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env python3
import unittest
import copy
from tconnectsync.sync.basal import process_ciq_basal_events
from tconnectsync.parser.tconnect import TConnectEntry
class TestBasalSync(unittest.TestCase):
maxDiff = None
base = {
"basal": {
"profileRates": [],
"tempDeliveryEvents": [],
"algorithmDeliveryEvents": [],
"profileDeliveryEvents": []
},
"events": [],
"suspensionDeliveryEvents": [],
"softwareUpdates": [],
"pumpFeatures": []
}
@staticmethod
def get_example_ciq_basal_events():
data = copy.deepcopy(TestBasalSync.base)
data["basal"]["tempDeliveryEvents"] = [
{
"y": 0.8,
"duration": 1221,
"x": 1615878000 # 12:00:00
}
]
data["basal"]["algorithmDeliveryEvents"] = [
{
"y": 0.797,
"duration": 300,
"x": 1615879521 # 12:25:21
},
{
"y": 0,
"duration": 2693,
"x": 1615879821 # 12:30:21
},
]
data["basal"]["profileDeliveryEvents"] = [
{
"y": 0.799,
"duration": 300,
"x": 1615879221 # 12:20:21
}
]
data["suspensionDeliveryEvents"] = [
{
"suspendReason": "control-iq",
"continuation": None,
"x": 1615879821 # 12:30:21
},
]
return data
def test_process_ciq_basal_events(self):
data = TestBasalSync.get_example_ciq_basal_events()
basalEvents = process_ciq_basal_events(data)
self.assertEqual(len(basalEvents), 4)
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["tempDeliveryEvents"][0], delivery_type="tempDelivery"))
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["profileDeliveryEvents"][0], delivery_type="profileDelivery"))
self.assertEqual(basalEvents[2], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][1],
delivery_type="algorithmDelivery (control-iq suspension)")
)
@staticmethod
def get_example_ciq_basal_events_with_manual_suspension():
data = copy.deepcopy(TestBasalSync.base)
data["basal"]["tempDeliveryEvents"] = []
data["basal"]["algorithmDeliveryEvents"] = [
{
"y": 1.14,
"duration": 300,
"x": 1635187357 # 11:42:37
},
{
"y": 0.8,
"duration": 1198,
"x": 1635187657 # 11:47:37
},
{
"y": 0.8,
"duration": 599,
"x": 1635190967 # 12:42:47
}
]
data["basal"]["profileDeliveryEvents"] = []
data["suspensionDeliveryEvents"] = [
{
"suspendReason": "manual",
"continuation": None,
"x": 1635188855 # 12:07:35
},
{
"suspendReason": "manual",
"continuation": None,
"x": 1635191566 # 12:52:46
},
]
return data
def test_process_ciq_basal_events_with_manual_suspension(self):
data = TestBasalSync.get_example_ciq_basal_events_with_manual_suspension()
basalEvents = process_ciq_basal_events(data)
self.assertEqual(len(basalEvents), 4)
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][1], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[2], TConnectEntry.manual_suspension_to_basal_entry(
TConnectEntry.parse_suspension_entry(data["suspensionDeliveryEvents"][0]),
seconds=2112, # 2112 seconds between 12:07:35 and 12:42:47
))
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][2], delivery_type="algorithmDelivery"))
if __name__ == '__main__':
unittest.main()
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
import unittest
import random
from tconnectsync.sync.bolus import process_bolus_events
from tconnectsync.parser.tconnect import TConnectEntry
from tconnectsync.parser.nightscout import NightscoutEntry
from ..parser.test_tconnect import TestTConnectEntryBolus, TestTConnectEntryCGM, TestTConnectEntryReading
class TestBolusSync(unittest.TestCase):
@staticmethod
def get_example_csv_bolus_events():
return [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic,
TestTConnectEntryBolus.entryStdIncompletePartial
]
def test_process_bolus_events_standard(self):
bolusData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), len(bolusData))
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in bolusData
])
def test_process_bolus_events_cgmevents_not_matching(self):
bolusData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
cgmEvents = [
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3)
]
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
self.assertEqual(len(bolusEvents), len(bolusData))
def set_bg_type(entry, type):
entry.bg_type = type
return entry
# Expect FINGER for bolus entries with a BG because there's no matching event with the same BG
expected = [
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.FINGER),
# No BG specified for the automatic bolus
TConnectEntry.parse_bolus_entry(bolusData[2])
]
self.assertListEqual(bolusEvents, expected)
def test_process_bolus_events_cgmevents_matches(self):
bolusData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
cgmEvents = [
{
"time": "2021-04-01 12:45:30-04:00",
"bg": "100",
"type": "EGV"
},
# Matches entryStdCorrection time but with wrong BG
{
"time": "2021-04-01 12:50:30-04:00",
"bg": "105",
"type": "EGV"
},
{
"time": "2021-04-01 13:00:30-04:00",
"bg": "110",
"type": "EGV"
},
{
"time": "2021-04-01 23:15:30-04:00",
"bg": "150",
"type": "EGV"
},
# Matches entryStd time with correct BG
{
"time": "2021-04-01 23:20:30-04:00",
"bg": "159",
"type": "EGV"
},
{
"time": "2021-04-01 23:25:30-04:00",
"bg": "160",
"type": "EGV"
},
]
bolusEvents = process_bolus_events(bolusData, cgmEvents=cgmEvents)
self.assertEqual(len(bolusEvents), len(bolusData))
def set_bg_type(entry, type):
entry.bg_type = type
return entry
expected = [
# Time found but BG doesn't match
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[0]), NightscoutEntry.FINGER),
# Time found and BG matches
set_bg_type(TConnectEntry.parse_bolus_entry(bolusData[1]), NightscoutEntry.SENSOR),
# No BG specified for the automatic bolus
TConnectEntry.parse_bolus_entry(bolusData[2])
]
self.assertListEqual(bolusEvents, expected)
def test_process_bolus_events_update_partial_description(self):
stdData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
partialData = [
TestTConnectEntryBolus.entryStdIncompletePartial
]
bolusData = stdData + partialData
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), len(bolusData))
partialEntries = [
TConnectEntry.parse_bolus_entry(e) for e in partialData
]
for e in partialEntries:
e.description += " (%s: requested %s units)" % (e.completion, e.requested_insulin)
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in stdData
] + partialEntries)
def test_process_bolus_events_skip_zero(self):
stdData = [
TestTConnectEntryBolus.entryStdCorrection,
TestTConnectEntryBolus.entryStd,
TestTConnectEntryBolus.entryStdAutomatic
]
zeroData = [
TestTConnectEntryBolus.entryStdIncompleteZero
]
bolusData = stdData + zeroData
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), len(stdData))
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in stdData
])
for d in zeroData:
self.assertNotIn(TConnectEntry.parse_bolus_entry(d), bolusEvents)
def test_process_bolus_events_ciq_extended_bolus(self):
stdData = [
TestTConnectEntryBolus.entryExtendedComplete,
]
zeroData = [
]
bolusData = stdData + zeroData
bolusEvents = process_bolus_events(bolusData)
self.assertEqual(len(bolusEvents), len(stdData))
self.assertListEqual(bolusEvents, [
TConnectEntry.parse_bolus_entry(d) for d in stdData
])
for d in zeroData:
self.assertNotIn(TConnectEntry.parse_bolus_entry(d), bolusEvents)
if __name__ == '__main__':
unittest.main()
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.cgm import find_event_at, process_cgm_events
from tconnectsync.parser.tconnect import TConnectEntry
from ..parser.test_tconnect import TestTConnectEntryReading
class TestProcessCGMEvents(unittest.TestCase):
def test_process_cgm_events(self):
rawReadings = [
TestTConnectEntryReading.entry1,
TestTConnectEntryReading.entry2,
TestTConnectEntryReading.entry3,
TestTConnectEntryReading.entry4
]
self.assertListEqual(
process_cgm_events(rawReadings),
[TConnectEntry.parse_reading_entry(r) for r in rawReadings]
)
class TestFindEventAt(unittest.TestCase):
readingData = [
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry4)
]
def test_find_event_at_exact(self):
for r in self.readingData:
self.assertEqual(find_event_at(self.readingData, r["time"]), r)
def test_find_event_at_before_not_found(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-22 10:30:00-04:00"), None)
def test_find_event_at_large_gap(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 13:30:00-04:00"), self.readingData[0])
def test_find_event_at_between_close(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:17:52-04:00"), self.readingData[1])
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:21:52-04:00"), self.readingData[2])
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:25:59-04:00"), self.readingData[3])
def test_find_event_at_most_recent(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 18:00:00-04:00"), self.readingData[3])
if __name__ == '__main__':
unittest.main()
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.iob import process_iob_events
from tconnectsync.parser.tconnect import TConnectEntry
from ..parser.test_tconnect import TestTConnectEntryIOB
class TestIOBSync(unittest.TestCase):
@staticmethod
def get_example_csv_iob_events():
return [
TestTConnectEntryIOB.entry1,
TestTConnectEntryIOB.entry2,
]
def test_process_iob_events(self):
iobData = TestIOBSync.get_example_csv_iob_events()
iobEvents = process_iob_events(iobData)
self.assertEqual(len(iobEvents), len(iobData))
self.assertListEqual(iobEvents, [
TConnectEntry.parse_iob_entry(d) for d in iobData
])
if __name__ == '__main__':
unittest.main()
-524
View File
@@ -1,524 +0,0 @@
#!/usr/bin/env python3
import json
import unittest
from typing import Dict
import copy
from tconnectsync.sync.profile import get_pump_profiles, compare_profiles, nightscout_profiles_identical
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
from tconnectsync.secret import NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE, TIMEZONE_NAME
from ..parser.test_tconnect import TestTConnectEntryReading
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
}
def build_ns_profile(profiles: Dict[str, dict], current_profile: str) -> dict:
return copy.deepcopy({
"store": profiles,
"defaultProfile": current_profile,
"startDate": "1970-01-01T00:00:00.000Z",
"mills": 0,
"units": "mg/dl",
})
class TestCompareProfiles(unittest.TestCase):
maxDiff = None
def test_compare_profiles_identical_a(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertFalse(changed)
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_identical_b(self):
pump_profiles = [DEVICE_PROFILE_B.activeProfile()]
ns_profile_obj = build_ns_profile({'B': NS_PROFILE_B}, 'B')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertFalse(changed)
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_extra_profile_in_nightscout_ignored(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A, 'B': NS_PROFILE_B}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertFalse(changed)
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_current_nightscout_profile_changed(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A, 'B': NS_PROFILE_B}, 'B')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['defaultProfile'] = 'A'
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_new_pump_profile_added(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile(), DEVICE_PROFILE_B]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['B'] = new_profiles['store']['B']
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_new_pump_profile_added_and_active(self):
pump_profiles = [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()]
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['B'] = new_profiles['store']['B']
ns_profile_obj['defaultProfile'] = 'B'
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_existing_profile_edited_basal(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
pump_profiles[0].segments[0].basal_rate = 0.1
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['A']['basal'][0]['value'] = 0.1
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_existing_profile_edited_new_chunk(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
pump_profiles[0].segments.append(ProfileSegment(
display_time='06:00 PM',
time='06:00 PM',
basal_rate=0.7,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0)
)
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['A']['basal'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 0.7})
ns_profile_obj['store']['A']['carbratio'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 6})
ns_profile_obj['store']['A']['sens'].append({'time': '18:00', 'timeAsSeconds': 18*60*60, 'value': 30})
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def test_compare_profiles_existing_profile_edited_removed_chunk(self):
pump_profiles = [DEVICE_PROFILE_A.activeProfile()]
pump_profiles[0].segments.pop()
ns_profile_obj = build_ns_profile({'A': NS_PROFILE_A}, 'A')
changed, new_profiles = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
ns_profile_obj
)
self.assertTrue(changed)
self.assertNotEqual(ns_profile_obj, new_profiles)
ns_profile_obj['store']['A']['basal'].pop()
ns_profile_obj['store']['A']['carbratio'].pop()
ns_profile_obj['store']['A']['sens'].pop()
self.assertDictEqual(ns_profile_obj, new_profiles)
self.ensure_stabilized(pump_profiles, new_profiles)
def ensure_stabilized(self, pump_profiles, new_profiles):
changed, _ = compare_profiles(
pump_profiles,
DEVICE_SETTINGS,
new_profiles
)
self.assertFalse(changed, 'did not stabilize')
class TestNightscoutProfilesIdentical(unittest.TestCase):
def test_same_dict(self):
self.assertTrue(nightscout_profiles_identical(NS_PROFILE_A, NS_PROFILE_A))
def test_different_dict(self):
self.assertFalse(nightscout_profiles_identical(NS_PROFILE_A, NS_PROFILE_B))
def test_same_re_jsoned(self):
self.assertTrue(nightscout_profiles_identical(NS_PROFILE_A, json.loads(json.dumps(NS_PROFILE_A))))
def test_same_different_number_types(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = 5
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = 5.0
self.assertTrue(nightscout_profiles_identical(a, b))
def test_same_different_number_strings_1(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = 5
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = '5.0'
self.assertTrue(nightscout_profiles_identical(a, b))
def test_same_different_number_strings_2(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = '5'
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = 5.0
self.assertTrue(nightscout_profiles_identical(a, b))
def test_same_different_number_strings_3(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = '5'
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = '5.000'
self.assertTrue(nightscout_profiles_identical(a, b))
def test_different_numbers(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = 5
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = 5.1
self.assertFalse(nightscout_profiles_identical(a, b))
def test_different_numbers_strings(self):
a = b = json.loads(json.dumps(NS_PROFILE_A))
a['dia'] = '5'
b = json.loads(json.dumps(NS_PROFILE_A))
b['dia'] = '5.01'
self.assertFalse(nightscout_profiles_identical(a, b))
if __name__ == '__main__':
unittest.main()
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env python3
import json
import unittest
from typing import Dict
import copy
from tconnectsync.sync.pump_events import ns_write_pump_events
from ..nightscout_fake import NightscoutApi
def _stub_last_uploaded_entry(eventType, time_start=None, time_end=None):
return None
class TestNsWritePumpEvents(unittest.TestCase):
SITE_CHANGE_EVENT = {'time': '2024-02-04 16:27:50-05:00', 'event_type': 'Site/Cartridge Change'}
SITE_CHANGE_NS = {'created_at': '2024-02-04 16:27:50-05:00', 'enteredBy': 'Pump (tconnectsync)', 'eventType': 'Site Change', 'notes': 'Site/Cartridge Change','reason': 'Site/Cartridge Change', 'pump_event_id': ''}
def test_write_single_sitechange_event(self):
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = _stub_last_uploaded_entry
ns_write_pump_events(nightscout, [
self.SITE_CHANGE_EVENT
])
self.assertDictEqual({
'treatments': [
self.SITE_CHANGE_NS
]
}, dict(nightscout.uploaded_entries))
EMPTY_CART_EVENT = {'time': '2024-05-14 09:01:59-04:00', 'event_type': 'Empty Cartridge/Pump Shutdown'}
EMPTY_CART_NS = {'created_at': '2024-05-14 09:01:59-04:00', 'enteredBy': 'Pump (tconnectsync)', 'eventType': 'Basal Suspension', 'notes': 'Empty Cartridge/Pump Shutdown', 'reason': 'Empty Cartridge/Pump Shutdown', 'pump_event_id': ''}
def test_write_single_emptycart_event(self):
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = _stub_last_uploaded_entry
ns_write_pump_events(nightscout, [
self.EMPTY_CART_EVENT
])
self.assertDictEqual({
'treatments': [
self.EMPTY_CART_NS
]
}, dict(nightscout.uploaded_entries))
USER_SUSPENDED_EVENT = {'time': '2024-02-05 09:48:22-05:00', 'event_type': 'User Suspended'}
USER_SUSPENDED_NS = {'created_at': '2024-02-05 09:48:22-05:00', 'enteredBy': 'Pump (tconnectsync)', 'eventType': 'Basal Suspension', 'notes': 'User Suspended', 'reason': 'User Suspended', 'pump_event_id': ''}
def test_write_single_usersuspended_event(self):
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = _stub_last_uploaded_entry
ns_write_pump_events(nightscout, [
self.USER_SUSPENDED_EVENT
])
self.assertDictEqual({
'treatments': [
self.USER_SUSPENDED_NS
]
}, dict(nightscout.uploaded_entries))
EXERCISE_EVENT = {'time': '2024-03-10 08:53:20-04:00', 'duration_mins': 269.3833333333333, 'event_type': 'Exercise'}
EXERCISE_NS = {'created_at': '2024-03-10 08:53:20-04:00', 'duration': 269.3833333333333, 'enteredBy': 'Pump (tconnectsync)', 'eventType': 'Exercise', 'notes': 'Exercise', 'reason': 'Exercise', 'pump_event_id': ''}
EXERCISE_EXTENDED_EVENT = {'time': '2024-03-10 08:53:20-04:00', 'duration_mins': 585.8833333333333, 'event_type': 'Exercise'}
EXERCISE_EXTENDED_NS = {'created_at': '2024-03-10 08:53:20-04:00', 'duration': 585.8833333333333, 'enteredBy': 'Pump (tconnectsync)', 'eventType': 'Exercise', 'notes': 'Exercise', 'reason': 'Exercise', 'pump_event_id': ''}
def test_write_exercise_events(self):
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = _stub_last_uploaded_entry
ns_write_pump_events(nightscout, [
self.EXERCISE_EVENT
])
self.assertDictEqual({
'treatments': [
self.EXERCISE_NS
]
}, dict(nightscout.uploaded_entries))
_id = 'exercise_id'
def _last_uploaded_entry(eventType, time_start=None, time_end=None):
if eventType == 'Exercise':
return {
'_id': _id,
**self.EXERCISE_NS
}
nightscout.last_uploaded_entry = _last_uploaded_entry
ns_write_pump_events(nightscout, [
self.EXERCISE_EXTENDED_EVENT
])
self.assertListEqual([
'treatments/%s' % _id
], nightscout.deleted_entries)
self.assertDictEqual({
'treatments': [
self.EXERCISE_NS,
self.EXERCISE_EXTENDED_NS
]
}, dict(nightscout.uploaded_entries))
SLEEP_EVENT = {'time': '2024-02-07 00:00:19-05:00', 'duration_mins': 13.916666666666666, 'event_type': 'Sleep'}
SLEEP_NS = {'created_at': '2024-02-07 00:00:19-05:00', 'duration': 13.916666666666666, 'enteredBy': 'Pump (tconnectsync)', 'eventType': 'Sleep', 'notes': 'Sleep', 'reason': 'Sleep', 'pump_event_id': ''}
SLEEP_EXTENDED_EVENT = {'time': '2024-02-07 00:00:19-05:00', 'duration_mins': 74.0, 'event_type': 'Sleep'}
SLEEP_EXTENDED_NS = {'created_at': '2024-02-07 00:00:19-05:00', 'duration': 74.0, 'enteredBy': 'Pump (tconnectsync)', 'eventType': 'Sleep', 'notes': 'Sleep', 'reason': 'Sleep', 'pump_event_id': ''}
def test_write_sleep_events(self):
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = _stub_last_uploaded_entry
ns_write_pump_events(nightscout, [
self.SLEEP_EVENT
])
self.assertDictEqual({
'treatments': [
self.SLEEP_NS
]
}, dict(nightscout.uploaded_entries))
_id = 'sleep_id'
def _last_uploaded_entry(eventType, time_start=None, time_end=None):
if eventType == 'Sleep':
return {
'_id': _id,
**self.SLEEP_NS
}
nightscout.last_uploaded_entry = _last_uploaded_entry
ns_write_pump_events(nightscout, [
self.SLEEP_EXTENDED_EVENT
])
self.assertListEqual([
'treatments/%s' % _id
], nightscout.deleted_entries)
self.assertDictEqual({
'treatments': [
self.SLEEP_NS,
self.SLEEP_EXTENDED_NS
]
}, dict(nightscout.uploaded_entries))
if __name__ == '__main__':
unittest.main()
-368
View File
@@ -1,368 +0,0 @@
#!/usr/bin/env python3
import logging
import unittest
import datetime
import contextlib
from unittest.mock import patch
from tconnectsync.autoupdate import Autoupdate, AutoupdateFailureError, AutoupdateFailureWarning, AutoupdateNoEventIndexesDetectedError, AutoupdateNoIndexChangeWarning
from .api.fake import TConnectApi
from .nightscout_fake import NightscoutApi
from .secrets import build_secrets
logger = logging.getLogger(__name__)
def stub(*args, **kwargs):
pass
@contextlib.contextmanager
def build_mock_logger():
def fake_error(*args, **kwargs):
logger.error(*args, **kwargs)
def fake_warn(*args, **kwargs):
logger.warning(*args, **kwargs)
with patch("tconnectsync.autoupdate.logger.error") as mock_error, patch("tconnectsync.autoupdate.logger.warning") as mock_warn:
mock_error.side_effect = fake_error
mock_warn.side_effect = fake_warn
yield (mock_error, mock_warn)
def num_instances_of(cls, m):
return sum([isinstance(i[0][0], cls) for i in m.call_args_list])
class TestAutoupdate(unittest.TestCase):
maxDiff = None
# datetimes are unused
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
# each time the returned function is called, it returns the next argument
# until the end is reached, at which point it will repeat the last argument
def fake_last_event_uploaded(self, *indexes):
index = 0
def fake(*args, **kwargs):
nonlocal index
if index < len(indexes):
index += 1
data = {'maxPumpEventIndex': indexes[index-1], 'processingStatus': 1}
return data
return fake
# each time the returned function is called, it returns the next argument
# until the end is reached, at which point it will repeat the last argument
def fake_process_time_range(self, *returns):
index = 0
def fake(*args, **kwargs):
nonlocal index
if index < len(returns):
index += 1
return returns[index - 1]
return fake
"""process_time_range should always be invoked the first time"""
def test_process_time_range_called_on_start(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
mock_process_time_range.return_value = 0
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 0)
self.assertEqual(mock_process_time_range.call_count, 1)
self.assertEqual(u.autoupdate_invocations, 1)
self.assertEqual(u.last_event_index, 1)
"""process_time_range should never be called with pretend"""
def test_process_time_range_never_called_with_pretend(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
mock_process_time_range.return_value = 0
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=True)
self.assertEqual(ret, 0)
self.assertEqual(mock_process_time_range.call_count, 0)
self.assertEqual(u.autoupdate_invocations, 1)
self.assertEqual(u.last_event_index, 1)
"""
If the event index increases without process_time_range detecting new data,
AutoupdateFailureWarning should be raised.
"""
def test_autoupdate_failure_warning_on_index_process_time_range_discrepancy(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=2,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
TCONNECT_EMAIL="test@email.com",
TCONNECT_PASSWORD="testpassword"
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 0)
self.assertEqual(mock_process_time_range.call_count, 2)
self.assertEqual(u.autoupdate_invocations, 2)
self.assertEqual(u.last_event_index, 2)
self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 1)
"""
On the first attempt, an AutoupdateFailureWarning should never be raised.
"""
def test_autoupdate_no_failure_warning_on_index_process_time_range_discrepancy_first_attempt(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 0)
self.assertEqual(mock_process_time_range.call_count, 1)
self.assertEqual(u.autoupdate_invocations, 1)
self.assertEqual(u.last_event_index, 1)
self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 0)
"""
If the event index increases without process_time_range detecting new data
for AUTOUPDATE_FAILURE_MINUTES, an AutoupdateFailureError should be raised.
"""
def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=2,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
AUTOUPDATE_FAILURE_MINUTES=0,
AUTOUPDATE_RESTART_ON_FAILURE=False,
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 0)
self.assertEqual(mock_process_time_range.call_count, 2)
self.assertEqual(u.autoupdate_invocations, 2)
self.assertEqual(u.last_event_index, 2)
self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1)
"""
If the event index increases without process_time_range detecting new data
for AUTOUPDATE_FAILURE_MINUTES, and AUTOUPDATE_RESTART_ON_FAILURE is true,
then an AutoupdateFailureError should be raised AND 1 should be returned.
"""
def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes_performs_restart(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=3,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
AUTOUPDATE_FAILURE_MINUTES=0,
AUTOUPDATE_RESTART_ON_FAILURE=True,
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 1)
self.assertEqual(mock_process_time_range.call_count, 2)
self.assertEqual(u.autoupdate_invocations, 1) # exits before invocations is incremented
self.assertEqual(u.last_event_index, 1) # exits before changed to 2
self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1)
"""Validate state after first successful update"""
def test_state_after_first_successful_update(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
mock_process_time_range.side_effect = self.fake_process_time_range(1)
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 0)
self.assertEqual(mock_process_time_range.call_count, 1)
self.assertEqual(u.autoupdate_invocations, 1)
self.assertEqual(u.last_event_index, 1)
self.assertTrue(u.last_event_time == u.last_attempt_time == u.last_successful_process_time_range)
self.assertEqual(len(u.time_diffs_between_updates), 0)
"""Validate sleep occurs for the given fixed length when set"""
def test_sleep_for_fixed_length_when_set(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
nightscout = NightscoutApi()
sleep_length = 3 # sentinel
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=sleep_length
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep:
mock_process_time_range.side_effect = self.fake_process_time_range(1)
mock_sleep.side_effect = None
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 0)
self.assertEqual(mock_process_time_range.call_count, 1)
self.assertEqual(u.autoupdate_invocations, 1)
self.assertEqual(u.last_event_index, 1)
self.assertTrue(mock_sleep.called)
self.assertEqual(mock_sleep.call_args[0], (sleep_length,))
"""
If there is no event index update for AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
then a AutoupdateNoEventIndexesDetectedError should be raised and
a restart should be triggered with AUTOUPDATE_RESTART_ON_FAILURE.
"""
def test_autoupdate_no_event_indexes_detected_error_on_no_index_change_less_than_three_iterations(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=100,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1,
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2,
AUTOUPDATE_RESTART_ON_FAILURE=True,
AUTOUPDATE_NO_DATA_FAILURE_MINUTES=1/600 # 0.1 second
)
# with more than 3 iterations, the sleep iteration will change
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
mock_process_time_range.side_effect = self.fake_process_time_range(1, 0)
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 1)
self.assertEqual(num_instances_of(AutoupdateNoEventIndexesDetectedError, mock_error), 1)
"""
If there has been 3 failed attempts since the last time we found new data,
we should sleep for AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS and
a AutoupdateNoIndexChangeWarning should be logged.
"""
def test_autoupdate_no_index_change_warning_on_unexpected_no_index_sleep(self):
tconnect = TConnectApi()
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
nightscout = NightscoutApi()
secret = build_secrets(
AUTOUPDATE_MAX_LOOP_INVOCATIONS=5,
AUTOUPDATE_USE_FIXED_SLEEP=True,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1,
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2
)
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep, build_mock_logger() as (mock_error, mock_warn):
mock_process_time_range.side_effect = self.fake_process_time_range(0)
mock_sleep.side_effect = None
u = Autoupdate(secret)
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
self.assertEqual(ret, 0)
self.assertEqual(u.autoupdate_invocations, 5)
self.assertEqual(u.last_event_index, 1)
self.assertTrue(mock_sleep.called)
self.assertEqual(len(mock_sleep.call_args_list), 5)
self.assertEqual(mock_sleep.call_args_list[0][0], (0.1,))
self.assertEqual(mock_sleep.call_args_list[1][0], (0.1,))
self.assertEqual(mock_sleep.call_args_list[2][0], (0.1,))
self.assertEqual(mock_sleep.call_args_list[3][0], (0.2,))
self.assertEqual(mock_sleep.call_args_list[4][0], (0.2,))
self.assertEqual(num_instances_of(AutoupdateNoIndexChangeWarning, mock_warn), 2)
-978
View File
@@ -1,978 +0,0 @@
#!/usr/bin/env python3
import unittest
import datetime
import pprint
import copy
from unittest.mock import patch
from tconnectsync.process import process_time_range
from tconnectsync.parser.nightscout import EXERCISE_EVENTTYPE, IOB_ACTIVITYTYPE, SLEEP_EVENTTYPE, NightscoutEntry
from tconnectsync.features import BASAL, BOLUS, IOB, PROFILES, PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION
from tconnectsync.domain.device_settings import Device
from tests.secrets import build_secrets
from tests.sync.test_profile import DEVICE_PROFILE_A, DEVICE_PROFILE_B, DEVICE_SETTINGS, NS_PROFILE_A, NS_PROFILE_B, build_ns_profile
from .api.fake import TConnectApi
from .nightscout_fake import NightscoutApi
from .sync.test_basal import TestBasalSync
from .sync.test_bolus import TestBolusSync
from .sync.test_iob import TestIOBSync
from .domain.test_therapy_event import BOLUS_FULL_EXAMPLES, TestCGMTherapyEvent
class TestProcessTimeRange(unittest.TestCase):
maxDiff = None
def stub_therapy_timeline(self, time_start, time_end):
return copy.deepcopy(TestBasalSync.base)
def stub_ciq_therapy_events(self, time_start, time_end):
return {
"event": []
}
def stub_therapy_timeline_csv(self, time_start, time_end):
return {
"readingData": [],
"iobData": [],
"basalData": [],
"bolusData": []
}
def stub_ws2_basalsuspension(self, time_start, time_end):
return {"BasalSuspension": []}
def stub_last_uploaded_entry(self, event_type, **kwargs):
return None
def stub_last_uploaded_activity(self, activity_type, **kwargs):
return None
"""No data in Nightscout. Uploads all basal data from tconnect."""
def test_new_ciq_basal_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 4)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.basal(0.8, 20.35, "2021-03-16 00:00:00-04:00", reason="tempDelivery"),
NightscoutEntry.basal(0.799, 5.0, "2021-03-16 00:20:21-04:00", reason="profileDelivery"),
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 4)
"""No data in Nightscout. Nothing should be updated in Nightscout without the BASAL feature."""
def test_basal_data_not_updated_without_feature(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, IOB])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Two basal entries in Nightscout. Two new basal entries in tconnect."""
def test_partial_ciq_basal_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Temp Basal":
return {
"created_at": "2021-03-16 00:20:21-04:00",
"duration": 5
}
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 2)
"""
Two basal entries in Nightscout, the latter which needs to be updated
with a longer duration. Two entirely new entries in tconnect."""
def test_with_updated_duration_ciq_basal_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return TestBasalSync.get_example_ciq_basal_events()
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Temp Basal":
return {
"created_at": "2021-03-16 00:20:21-04:00",
"duration": 3,
"_id": "nightscout_id"
}
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(nightscout.uploaded_entries, {
"treatments": [
NightscoutEntry.basal(0.797, 5.0, "2021-03-16 00:25:21-04:00", reason="algorithmDelivery"),
NightscoutEntry.basal(0, 2693/60, "2021-03-16 00:30:21-04:00", reason="algorithmDelivery (control-iq suspension)")
]})
self.assertEqual(len(nightscout.put_entries["treatments"]), 1)
self.assertDictEqual(dict(nightscout.put_entries), {
"treatments": [
{
"_id": "nightscout_id",
**NightscoutEntry.basal(0.799, 5.0, "2021-03-16 00:20:21-04:00", reason="profileDelivery")
}
]
})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 3)
"""No data in Nightscout. Uploads all bolus data from tconnect via the WS2 API."""
def test_new_ciq_bolus_data_from_ws2(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
bolusData = TestBolusSync.get_example_csv_bolus_events()
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"bolusData": bolusData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), len(bolusData))
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.bolus(13.53, 75, "2021-04-01 12:58:26-04:00", notes="Standard/Correction"),
NightscoutEntry.bolus(1.25, 0, "2021-04-01 23:23:17-04:00", notes="Standard (Override)"),
NightscoutEntry.bolus(1.7, 0, "2021-04-02 01:00:47-04:00", notes="Automatic Bolus/Correction"),
NightscoutEntry.bolus(1.82, 0, "2021-09-06 12:24:47-04:00", notes="Standard/Correction (Terminated by Alarm: requested 2.63 units)"),
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 4)
"""No data in Nightscout. Uploads all bolus data from tconnect via CIQ therapy_events."""
def test_new_ciq_bolus_data_from_ciq_therapy_events(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2022, 8, 9, 12, 0)
end = datetime.datetime(2021, 8, 10, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
def fake_ciq_therapy_events(time_start, time_end):
return {
"event": BOLUS_FULL_EXAMPLES
}
tconnect.controliq.therapy_events = fake_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), len(BOLUS_FULL_EXAMPLES))
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
# BGs are excluded because BG is not enabled in features for this test
NightscoutEntry.bolus(2.9, 0, "2022-07-21 11:55:24-04:00", notes="Automatic Bolus/Correction"),
NightscoutEntry.bolus(4.17, 25, "2022-07-21 12:29:21-04:00", notes="Standard"),
# NOTE: the extended bolus is 0.2+0.2 but we currently only surface standard
# BUG: inconsistency: we use the completion timestamp as the event timestamp for standard boluses,
# but the standard strand time for extended
NightscoutEntry.bolus(0.2, 0, "2022-08-09 23:20:04-04:00", notes="Extended 50.00%/0.00 (Override) (Extended)"),
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 3)
"""No data in Nightscout. Nothing should be updated in Nightscout without the BOLUS feature."""
def test_bolus_data_not_updated_without_feature(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
bolusData = TestBolusSync.get_example_csv_bolus_events()
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"bolusData": bolusData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, IOB])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""No data in Nightscout. Uploads new iob reading from tconnect."""
def test_new_ciq_iob_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
iobData = TestIOBSync.get_example_csv_iob_events()
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"iobData": iobData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"activity": [
# the most recent IOB entry is added
NightscoutEntry.iob(6.80, "2021-10-12 00:10:30-04:00")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""No data in Nightscout. Nothing should be updated in Nightscout without the IOB feature."""
def test_iob_data_not_updated_without_feature(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
iobData = TestIOBSync.get_example_csv_iob_events()
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"iobData": iobData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BASAL, BOLUS])
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Existing IOB in Nightscout. Uploads new iob reading and deletes old IOB."""
def test_updates_ciq_iob_data(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 4, 20, 12, 0)
end = datetime.datetime(2021, 4, 21, 12, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
iobData = TestIOBSync.get_example_csv_iob_events()
iobData[0]["created_at"] = start
iobData[0]["_id"] = "sentinel_existing_iob_id"
def fake_therapy_timeline_csv(time_start, time_end):
return {
**self.stub_therapy_timeline_csv(time_start, time_end),
"iobData": iobData,
}
tconnect.ws2.therapy_timeline_csv = fake_therapy_timeline_csv
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
def fake_last_uploaded_activity(activityType, **kwargs):
if activityType == IOB_ACTIVITYTYPE:
return iobData[0]
return self.stub_last_uploaded_activity(activityType)
nightscout.last_uploaded_activity = fake_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[IOB])
pprint.pprint(nightscout.uploaded_entries)
self.assertEqual(len(nightscout.uploaded_entries["activity"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"activity": [
# the most recent IOB entry is added
NightscoutEntry.iob(6.80, "2021-10-12 00:10:30-04:00")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [
"activity/sentinel_existing_iob_id"
])
self.assertEqual(count, 1)
"""No pump activity events in Nightscout. New CIQ activity events."""
def test_new_ciq_activity_events(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return {
**TestBasalSync.base,
"events": [{
"duration": 1200,
"eventType": 2, # Exercise
"continuation": None,
"timeZoneId": "America/Los_Angeles",
"x": 1619901912 # 2021-05-01 13:45:12-04:00
}, {
"duration": 30661,
"eventType": 1, # Sleep
"continuation": None,
"timeZoneId": "America/Los_Angeles",
"x": 1619992000 # 2021-05-02 14:46:40-04:00
}]
}
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 2)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.activity(created_at="2021-05-01 13:45:12-04:00", duration=20, reason="Exercise", event_type=EXERCISE_EVENTTYPE),
NightscoutEntry.activity(created_at="2021-05-02 14:46:40-04:00", duration=30661/60, reason="Sleep", event_type=SLEEP_EVENTTYPE),
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 2)
"""No pump activity events in Nightscout. New CIQ activity events, but feature is disabled."""
def test_no_ciq_activity_events_without_feature(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return {
**TestBasalSync.base,
"events": [{
"duration": 1200,
"eventType": 2, # Exercise
"continuation": None,
"timeZoneId": "America/Los_Angeles",
"x": 1619901912 # 2021-05-01 13:45:12-04:00
}, {
"duration": 30661,
"eventType": 1, # Sleep
"continuation": None,
"timeZoneId": "America/Los_Angeles",
"x": 1619992000 # 2021-05-02 14:46:40-04:00
}]
}
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.controliq.therapy_events = self.stub_ciq_therapy_events
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[BOLUS, BASAL, IOB])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""
Existing Sleep event in Nightscout with shorter duration than current, as well as a past Exercise event.
Ensures that the old sleep event is deleted and a new one is created with the correct duration."""
def test_existing_ciq_activity_events(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
def fake_therapy_timeline(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return {
**TestBasalSync.base,
"events": [{
"duration": 1200,
"eventType": 2, # Exercise
"continuation": None,
"timeZoneId": "America/Los_Angeles",
"x": 1619901912 # 2021-05-01 13:45:12-04:00
}, {
"duration": 4200, # Currently 60 mins (3600), changing to 70 mins (4200)
"eventType": 1, # Sleep
"continuation": None,
"timeZoneId": "America/Los_Angeles",
"x": 1619992000 # 2021-05-02 14:46:40-04:00
}]
}
tconnect.controliq.therapy_timeline = fake_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
tconnect.ws2.basalsuspension = self.stub_ws2_basalsuspension
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Sleep":
return {
"created_at": "2021-05-02 14:46:40-04:00",
"duration": 60,
"_id": "old_sleep"
}
elif event_type == "Exercise":
return {
"created_at": "2021-05-01 13:45:12-04:00",
"duration": 20,
"_id": "exercise"
}
return self.stub_last_uploaded_entry(**kwargs)
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
# Already exists:
# NightscoutEntry.activity(created_at="2021-05-01 13:45:12-04:00", duration=20, reason="Exercise", event_type=EXERCISE_EVENTTYPE),
# Updated event duration:
NightscoutEntry.activity(created_at="2021-05-02 14:46:40-04:00", duration=70, reason="Sleep", event_type=SLEEP_EVENTTYPE),
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertEqual(len(nightscout.deleted_entries), 1)
self.assertListEqual(nightscout.deleted_entries, [
"treatments/old_sleep"
])
self.assertEqual(count, 1)
"""No pump activity events in Nightscout. New WS2 activity events."""
def test_new_ws2_activity_events(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
def fake_basalsuspension(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return {"BasalSuspension": [
{
'EventDateTime': '/Date(1638663490000-0000)/',
'SuspendReason': 'site-cart'
},
{
'EventDateTime': '/Date(1637863616000-0000)/',
'SuspendReason': 'alarm'
},
{
'EventDateTime': '/Date(1638662852000-0000)/',
'SuspendReason': 'manual'
}
]}
tconnect.ws2.basalsuspension = fake_basalsuspension
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 3)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.sitechange(created_at="2021-12-04 16:18:10-05:00", reason="Site/Cartridge Change"),
NightscoutEntry.basalsuspension(created_at="2021-11-25 10:06:56-05:00", reason="Empty Cartridge/Pump Shutdown"),
NightscoutEntry.basalsuspension(created_at="2021-12-04 16:07:32-05:00", reason="User Suspended")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 3)
"""Existing pump activity events in Nightscout. New WS2 activity events. Only adds new events."""
def test_existing_ws2_activity_events(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
def fake_basalsuspension(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return {"BasalSuspension": [
{
'EventDateTime': '/Date(1638663490000-0000)/',
'SuspendReason': 'site-cart'
},
{
'EventDateTime': '/Date(1637863616000-0000)/',
'SuspendReason': 'alarm'
},
{
'EventDateTime': '/Date(1638662852000-0000)/',
'SuspendReason': 'manual'
},
# This event is new:
{
'EventDateTime': '/Date(1638672852000-0000)/',
'SuspendReason': 'manual'
}
]}
tconnect.ws2.basalsuspension = fake_basalsuspension
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type, **kwargs):
if event_type == "Site Change":
return {
"created_at": "2021-12-04 16:18:10-05:00"
}
elif event_type == "Basal Suspension":
return {
"created_at": "2021-12-04 16:07:32-05:00"
}
return self.stub_last_uploaded_entry(**kwargs)
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS, PUMP_EVENTS_BASAL_SUSPENSION])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 1)
self.assertDictEqual(dict(nightscout.uploaded_entries), {
"treatments": [
NightscoutEntry.basalsuspension(created_at="2021-12-04 18:54:12-05:00", reason="User Suspended")
]})
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""No pump activity events in nightscout. New WS2 activity events, but only of skipped types. None should be added."""
def test_skipped_ws2_activity_events(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
tconnect.controliq.therapy_timeline = self.stub_therapy_timeline
tconnect.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
def fake_basalsuspension(time_start, time_end):
self.assertEqual(time_start, start)
self.assertEqual(time_end, end)
return {"BasalSuspension": [
{
'EventDateTime': '/Date(1638659343000-0000)/',
'SuspendReason': 'basal-profile',
},
{
'Continuation': 'continuation',
'EventDateTime': '/Date(1638604800000-0000)/',
'SuspendReason': 'previous',
},
{
'EventDateTime': '/Date(1638659343000-0000)/',
'SuspendReason': 'basal-profile',
}
]}
tconnect.ws2.basalsuspension = fake_basalsuspension
nightscout = NightscoutApi()
nightscout.last_uploaded_entry = self.stub_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
self.assertEqual(len(nightscout.uploaded_entries["treatments"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Profile present on pump and not in Nightscout with PROFILES feature enabled, adds profile."""
def test_pump_profile_added(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({}, '')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'add'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1)
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""Profile present on pump and in Nightscout with PROFILES feature enabled, does not add profile."""
def test_pump_profile_not_updated(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'add'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 0)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 0)
"""Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump, adds new profile object."""
def test_pump_profile_new_entry_added(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'add'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1)
self.assertEqual(len(nightscout.uploaded_entries["profile"][0]['store']), 2)
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A)
self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['B'], NS_PROFILE_B)
self.assertDictEqual(nightscout.put_entries, {})
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
"""
Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump,
replaces existing profile object with NIGHTSCOUT_PROFILE_UPLOAD_MODE=replace.
"""
def test_pump_profile_new_entry_replaced(self):
tconnect = TConnectApi()
# datetimes are unused by the API fake
start = datetime.datetime(2021, 5, 1, 0, 0)
end = datetime.datetime(2021, 5, 3, 0, 0)
pump_guid = '00000000-0000-0000-0000-000000000001'
serial_number = '12345'
def fake_my_devices():
return {
serial_number: Device(
name='test',
model_number=serial_number,
status='OK',
guid=pump_guid)
}
tconnect.webui.my_devices = fake_my_devices
def fake_device_settings_from_guid(guid):
if guid != pump_guid:
raise RuntimeError('invalid guid')
return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS
tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid
nightscout = NightscoutApi()
def fake_current_profile(time_start=None, time_end=None):
return build_ns_profile({'A': NS_PROFILE_A}, 'A')
nightscout.current_profile = fake_current_profile
count = None
with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \
patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number:
mock_upload_mode.return_value = 'replace'
mock_serial_number.return_value = serial_number
count = process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES])
self.assertDictEqual(nightscout.uploaded_entries, {})
self.assertEqual(len(nightscout.put_entries["profile"]), 1)
self.assertEqual(len(nightscout.put_entries["profile"][0]['store']), 2)
self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['A'], NS_PROFILE_A)
self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['B'], NS_PROFILE_B)
self.assertListEqual(nightscout.deleted_entries, [])
self.assertEqual(count, 1)
if __name__ == '__main__':
unittest.main()