mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-24 10:14:36 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68dcb25911 | ||
|
|
795361c6ad | ||
|
|
c9190df50a | ||
|
|
15160537f5 | ||
|
|
1f1e755503 | ||
|
|
aea3f6bd6b | ||
|
|
2bf40cac28 | ||
|
|
73dabe5ead | ||
|
|
e848a8482d | ||
|
|
9c06efb045 | ||
|
|
64aeb4d752 | ||
|
|
6a9a426b97 | ||
|
|
5a76a4cfd1 | ||
|
|
4837ac1e86 | ||
|
|
8b15ec5c90 | ||
|
|
37b008f6c3 | ||
|
|
8c40fdb533 | ||
|
|
d51080f6d0 | ||
|
|
b32415aaac | ||
|
|
ffd3c5b1ea | ||
|
|
0b4674bae9 | ||
|
|
a1202decbe | ||
|
|
4888426034 | ||
|
|
803b15b886 | ||
|
|
9e78fe770f | ||
|
|
dfb39e07ef | ||
|
|
9fad0d71ef | ||
|
|
f5cd7c0151 | ||
|
|
13bb106924 | ||
|
|
fd96b08aab | ||
|
|
4a8da39b44 | ||
|
|
7a0f9477f2 | ||
|
|
934d90e3b9 | ||
|
|
ea84b0b9cc | ||
|
|
07aaea9e1c | ||
|
|
67b9f42f9b | ||
|
|
997ab7bac8 | ||
|
|
b1f8263ceb | ||
|
|
29d08a7f1b | ||
|
|
c7ade49eea | ||
|
|
1f0fbd7788 | ||
|
|
4bda0a21f2 | ||
|
|
d1341e09c0 | ||
|
|
fb9588ff2c | ||
|
|
2339b4543e | ||
|
|
3f6010fafc | ||
|
|
a028cdf3fe | ||
|
|
093b42ff94 | ||
|
|
00023c6aba | ||
|
|
1ed3d35965 | ||
|
|
9493b6546f | ||
|
|
24a635ca21 | ||
|
|
80824906d9 | ||
|
|
e5259a7981 | ||
|
|
650fb60988 | ||
|
|
2b367d45f3 | ||
|
|
dd2dcd17bd | ||
|
|
98b7edc904 | ||
|
|
e82d311fd6 | ||
|
|
9a2a4ca771 | ||
|
|
54ba2ed7df | ||
|
|
2892eb8ace | ||
|
|
1d432982ad | ||
|
|
891c2e2c10 | ||
|
|
7dc479c84b | ||
|
|
0417fb9fd8 | ||
|
|
b80d52662b | ||
|
|
51ac469f00 | ||
|
|
81b13f236a | ||
|
|
d57b3872f1 | ||
|
|
0609a46ed2 | ||
|
|
e63d725c2b | ||
|
|
fec964e767 | ||
|
|
366182f65b |
+2
-2
@@ -5,9 +5,9 @@ coverage:
|
||||
project:
|
||||
default: false
|
||||
tconnectsync:
|
||||
paths:
|
||||
paths:
|
||||
- "tconnectsync/"
|
||||
target: '75%'
|
||||
target: '60%'
|
||||
threshold: '5%'
|
||||
tests:
|
||||
paths:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -8,4 +8,6 @@ build
|
||||
*.egg-info
|
||||
.env
|
||||
tconnectsync-check-output.log
|
||||
ignore_*
|
||||
ignore_*
|
||||
.venv/
|
||||
.vscode/
|
||||
@@ -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'"
|
||||
@@ -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).
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 50%
|
||||
threshold: null
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 2.0.9
|
||||
version = 2.3.4
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1114
-65
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -6,10 +6,26 @@ from ..secret import TIMEZONE_NAME
|
||||
from dataclasses import dataclass
|
||||
|
||||
EVENT_LEN = 26
|
||||
# Big endian
|
||||
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 +54,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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import arrow
|
||||
|
||||
from ...features import DEFAULT_FEATURES
|
||||
from ... import features
|
||||
from ... import secret
|
||||
from ...eventparser.generic import Events, decode_raw_events, EVENT_LEN
|
||||
from ...eventparser.utils import bitmask_to_list
|
||||
from ...eventparser.raw_event import TANDEM_EPOCH
|
||||
@@ -16,12 +17,13 @@ from ...parser.nightscout import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ProcessCGMReading:
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES):
|
||||
def __init__(self, tconnect, nightscout, tconnect_device_id, pretend, features=DEFAULT_FEATURES, timezone=None):
|
||||
self.tconnect = tconnect
|
||||
self.nightscout = nightscout
|
||||
self.tconnect_device_id = tconnect_device_id
|
||||
self.pretend = pretend
|
||||
self.features = features
|
||||
self.timezone = timezone or secret.TIMEZONE_NAME
|
||||
|
||||
def enabled(self):
|
||||
return features.CGM in self.features
|
||||
@@ -66,11 +68,11 @@ class ProcessCGMReading:
|
||||
def timestamp_for(self, event):
|
||||
# For backfills the time the event was added to the pump's event store
|
||||
# might not be the time it actually occurred, so we use the egvTimestamp
|
||||
return arrow.get(TANDEM_EPOCH + event.egvTimestamp)
|
||||
return arrow.get(TANDEM_EPOCH + event.egvTimestamp, tzinfo='UTC').replace(tzinfo=self.timezone)
|
||||
|
||||
def to_nsentry(self, event):
|
||||
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:
|
||||
|
||||
@@ -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
@@ -46,7 +46,7 @@ class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
|
||||
|
||||
def my_devices(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def device_settings(self, pump_guid):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,358 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import arrow
|
||||
|
||||
from tconnectsync.sync.tandemsource.process_cgm_reading import ProcessCGMReading
|
||||
from tconnectsync.eventparser import events as eventtypes
|
||||
from tconnectsync.eventparser.generic import Event
|
||||
|
||||
from ...api.fake import TConnectApi
|
||||
from ...nightscout_fake import NightscoutApi
|
||||
|
||||
# timestamp 2025-12-13T18:16:44-0500, value 80
|
||||
G7_EVENT_1 = b'\x01\x8f!\xc4+\x0f\x00\x07\xb8\r\xf7\x01\x00\x00\x00P\xc7 !\xc4+\x0c\x00\x00\x19\xe1'
|
||||
# timestamp 2025-12-13T18:21:44-0500, value 71
|
||||
G7_EVENT_2 = b'\x01\x8f!\xc4,;\x00\x07\xb8\x1a\xf4\x01\x00\x00\x00G\xc1 !\xc4,8\x00\x00\x19\xe1'
|
||||
# timestamp 2025-12-13T18:46:44-0500, value 119
|
||||
G7_EVENT_3 = b'\x01\x8f!\xc42\x17\x00\x07\xb8\xd6\x13\x01\x00\x00\x00w\xc1 !\xc42\x14\x00\x00\x19\xe1'
|
||||
# timestamp 2025-12-13T18:51:44-0500, value 105
|
||||
G7_EVENT_4 = b'\x01\x8f!\xc43C\x00\x07\xb8\xea\x13\x01\x00\x00\x00\x81\xbf !\xc43@\x00\x00\x19\xe1'
|
||||
# timestamp 2025-12-13T18:56:44-0500, value 98
|
||||
G7_EVENT_5 = b"\x01\x8f!\xc45\x9b\x00\x07\xb9\'\x11\x01\x00\x00\x00\x92\xbb !\xc45\x98\x00\x00\x19\xe1"
|
||||
|
||||
|
||||
class TestProcessCGMReadingG7(unittest.TestCase):
|
||||
"""Test with only G7 reading data"""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: None
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
self.process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False, timezone='America/New_York')
|
||||
|
||||
def test_single_g7_reading_no_last_uploaded(self):
|
||||
"""Test processing a single G7 CGM reading with no prior uploads"""
|
||||
events = [Event(G7_EVENT_1)]
|
||||
|
||||
self.assertEqual(type(events[0]), eventtypes.LidCgmDataG7)
|
||||
self.assertEqual(events[0].raw.timestampRaw, 566504207)
|
||||
self.assertEqual(events[0].egvTimestamp, 566504204)
|
||||
self.assertEqual(events[0].seqNum, 505869)
|
||||
self.assertEqual(events[0].currentglucosedisplayvalue, 80)
|
||||
self.assertEqual(events[0].rateRaw, -9)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertIn('sgv', p[0])
|
||||
self.assertEqual(p[0]['sgv'], 80)
|
||||
self.assertEqual(p[0]['dateString'], '2025-12-13T18:16:44-0500')
|
||||
|
||||
def test_multiple_g7_readings(self):
|
||||
"""Test processing multiple G7 CGM readings"""
|
||||
events = [
|
||||
Event(G7_EVENT_1),
|
||||
Event(G7_EVENT_2),
|
||||
Event(G7_EVENT_3)
|
||||
]
|
||||
|
||||
# Verify all events are LidCgmDataG7
|
||||
for event in events:
|
||||
self.assertEqual(type(event), eventtypes.LidCgmDataG7)
|
||||
|
||||
self.assertEqual(events[0].currentglucosedisplayvalue, 80)
|
||||
self.assertEqual(events[1].currentglucosedisplayvalue, 71)
|
||||
self.assertEqual(events[2].currentglucosedisplayvalue, 119)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 3)
|
||||
|
||||
# Check glucose values
|
||||
self.assertEqual(p[0]['sgv'], 80)
|
||||
self.assertEqual(p[0]['dateString'], '2025-12-13T18:16:44-0500')
|
||||
self.assertEqual(p[1]['sgv'], 71)
|
||||
self.assertEqual(p[1]['dateString'], '2025-12-13T18:21:44-0500')
|
||||
self.assertEqual(p[2]['sgv'], 119)
|
||||
self.assertEqual(p[2]['dateString'], '2025-12-13T18:46:44-0500')
|
||||
|
||||
def test_g7_reading_with_last_uploaded(self):
|
||||
"""Test that already uploaded readings are skipped"""
|
||||
events = [
|
||||
Event(G7_EVENT_1),
|
||||
Event(G7_EVENT_2),
|
||||
Event(G7_EVENT_3)
|
||||
]
|
||||
|
||||
# Set last upload time to be between event 1 and event 2
|
||||
event_1_time = self.process.timestamp_for(events[0])
|
||||
event_2_time = self.process.timestamp_for(events[1])
|
||||
|
||||
# Set last upload to event 1's timestamp
|
||||
self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: {
|
||||
'dateString': event_1_time.format()
|
||||
}
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
# Only events 2 and 3 should be processed (after the last upload time)
|
||||
self.assertEqual(len(p), 2)
|
||||
self.assertEqual(p[0]['sgv'], 71)
|
||||
self.assertEqual(p[0]['dateString'], '2025-12-13T18:21:44-0500')
|
||||
self.assertEqual(p[1]['sgv'], 119)
|
||||
self.assertEqual(p[1]['dateString'], '2025-12-13T18:46:44-0500')
|
||||
|
||||
|
||||
# timestamp 2022-01-28T00:01:09, value 75
|
||||
G6_EVENT_1 = b'\x01\x00\x1ay\xaf\xc5\x00\x02\x00m\xfc\x01\x00\x00\x00K\xaf\x06\x1ay\xaf\xc5\x01\x00\x01\xe1'
|
||||
|
||||
# timestamp 2022-01-28T00:06:08, value 77
|
||||
G6_EVENT_2 = b'\x01\x00\x1ay\xb0\xf0\x00\x02\x00v\xfd\x01\x00\x00\x00M\xa8\x06\x1ay\xb0\xf0\x01\x00\x01\xe1'
|
||||
|
||||
# timestamp 2022-01-28T01:51:07, value 76, BACKFILL
|
||||
G6_EVENT_3 = b'\x01\x00\x1ay\xca\xb7\x00\x02\x01>\x00\x02\x00\x00\x00L\xb5\x06\x1ay\xc9\x8b\x00\x01\x01\xe2'
|
||||
|
||||
# timestamp 2022-01-28T01:56:07, value 75
|
||||
G6_EVENT_4 = b'\x01\x00\x1ay\xca\xb7\x00\x02\x01=\x01\x01\x00\x00\x00K\xb5\x06\x1ay\xca\xb7\x02\x00\x01\xe1'
|
||||
|
||||
# timestamp 2022-01-28T02:01:06, value 74
|
||||
G6_EVENT_5 = b'\x01\x00\x1ay\xcb\xe2\x00\x02\x01G\x00\x01\x00\x00\x00J\xb5\x06\x1ay\xcb\xe2\x01\x00\x01\xe1'
|
||||
|
||||
class TestProcessCGMReadingG6(unittest.TestCase):
|
||||
"""Test with only G6 reading data"""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: None
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
self.process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False, timezone='America/New_York')
|
||||
|
||||
def test_single_g6_reading_no_last_uploaded(self):
|
||||
"""Test processing a single G6 CGM reading with no prior uploads"""
|
||||
events = [Event(G6_EVENT_1)]
|
||||
|
||||
# timestamp 2022-01-28T00:01:09 confirmed w/ tandem csv export
|
||||
self.assertEqual(type(events[0]), eventtypes.LidCgmDataGxb)
|
||||
self.assertEqual(events[0].raw.timestampRaw, 444182469)
|
||||
self.assertEqual(events[0].egvTimestamp, 444182469)
|
||||
self.assertEqual(events[0].currentglucosedisplayvalue, 75)
|
||||
self.assertEqual(events[0].rateRaw, -4)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertIn('sgv', p[0])
|
||||
self.assertEqual(p[0]['sgv'], 75)
|
||||
self.assertEqual(p[0]['dateString'], '2022-01-28T00:01:09-0500')
|
||||
|
||||
def test_multiple_g6_readings(self):
|
||||
"""Test processing multiple G6 CGM readings"""
|
||||
events = [
|
||||
Event(G6_EVENT_1),
|
||||
Event(G6_EVENT_2),
|
||||
Event(G6_EVENT_3)
|
||||
]
|
||||
|
||||
# Verify all events are LidCgmDataGxb
|
||||
for event in events:
|
||||
self.assertEqual(type(event), eventtypes.LidCgmDataGxb)
|
||||
|
||||
self.assertEqual(events[0].currentglucosedisplayvalue, 75)
|
||||
self.assertEqual(events[1].currentglucosedisplayvalue, 77)
|
||||
self.assertEqual(events[2].currentglucosedisplayvalue, 76)
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 3)
|
||||
|
||||
self.assertEqual(p[0]['sgv'], 75)
|
||||
self.assertEqual(p[0]['dateString'], '2022-01-28T00:01:09-0500')
|
||||
self.assertEqual(p[1]['sgv'], 77)
|
||||
self.assertEqual(p[1]['dateString'], '2022-01-28T00:06:08-0500')
|
||||
self.assertEqual(p[2]['sgv'], 76)
|
||||
self.assertEqual(p[2]['dateString'], '2022-01-28T01:51:07-0500')
|
||||
|
||||
def test_multiple_g6_readings_with_backfill(self):
|
||||
"""Test processing multiple G6 CGM readings"""
|
||||
events = [
|
||||
Event(G6_EVENT_1),
|
||||
Event(G6_EVENT_2),
|
||||
Event(G6_EVENT_3),
|
||||
Event(G6_EVENT_4),
|
||||
Event(G6_EVENT_5)
|
||||
]
|
||||
|
||||
# Verify all events are LidCgmDataGxb
|
||||
for event in events:
|
||||
self.assertEqual(type(event), eventtypes.LidCgmDataGxb)
|
||||
|
||||
self.assertEqual(events[0].currentglucosedisplayvalue, 75)
|
||||
self.assertEqual(events[1].currentglucosedisplayvalue, 77)
|
||||
self.assertEqual(events[2].currentglucosedisplayvalue, 76) # backfill
|
||||
self.assertEqual(events[3].currentglucosedisplayvalue, 75)
|
||||
self.assertEqual(events[4].currentglucosedisplayvalue, 74)
|
||||
|
||||
self.assertEqual(events[0].raw.timestampRaw, 444182469)
|
||||
self.assertEqual(events[1].raw.timestampRaw, 444182768)
|
||||
self.assertEqual(events[2].raw.timestampRaw, 444189367)
|
||||
self.assertEqual(events[3].raw.timestampRaw, 444189367)
|
||||
self.assertEqual(events[4].raw.timestampRaw, 444189666)
|
||||
|
||||
self.assertEqual(events[0].egvTimestamp, 444182469)
|
||||
self.assertEqual(events[1].egvTimestamp, 444182768)
|
||||
self.assertEqual(events[2].egvTimestamp, 444189067)
|
||||
self.assertEqual(events[3].egvTimestamp, 444189367)
|
||||
self.assertEqual(events[4].egvTimestamp, 444189666)
|
||||
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 5)
|
||||
|
||||
self.assertEqual(p[0]['sgv'], 75)
|
||||
self.assertEqual(p[0]['dateString'], '2022-01-28T00:01:09-0500')
|
||||
self.assertEqual(p[1]['sgv'], 77)
|
||||
self.assertEqual(p[1]['dateString'], '2022-01-28T00:06:08-0500')
|
||||
self.assertEqual(p[2]['sgv'], 76)
|
||||
self.assertEqual(p[2]['dateString'], '2022-01-28T01:51:07-0500')
|
||||
self.assertEqual(p[3]['sgv'], 75)
|
||||
self.assertEqual(p[3]['dateString'], '2022-01-28T01:56:07-0500')
|
||||
self.assertEqual(p[4]['sgv'], 74)
|
||||
self.assertEqual(p[4]['dateString'], '2022-01-28T02:01:06-0500')
|
||||
|
||||
|
||||
def test_g6_reading_with_last_uploaded(self):
|
||||
"""Test that already uploaded G6 readings are skipped"""
|
||||
events = [
|
||||
Event(G6_EVENT_1),
|
||||
Event(G6_EVENT_2),
|
||||
Event(G6_EVENT_3)
|
||||
]
|
||||
|
||||
# Set last upload time using 'date' field (milliseconds)
|
||||
event_1_time = self.process.timestamp_for(events[0])
|
||||
self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: {
|
||||
'date': event_1_time.timestamp() * 1000
|
||||
}
|
||||
|
||||
p = self.process.process(events, time_start=None, time_end=None)
|
||||
|
||||
# Only events 2 and 3 should be processed
|
||||
self.assertEqual(len(p), 2)
|
||||
self.assertEqual(p[0]['sgv'], 77)
|
||||
self.assertEqual(p[1]['sgv'], 76)
|
||||
|
||||
|
||||
class TestProcessCGMReadingWrite(unittest.TestCase):
|
||||
"""Tests for writing CGM readings to Nightscout"""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: None
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
|
||||
def test_write_entries_pretend_mode(self):
|
||||
"""Test that pretend mode doesn't actually upload"""
|
||||
process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=True, timezone='America/New_York')
|
||||
|
||||
events = [Event(G7_EVENT_1)]
|
||||
|
||||
ns_entries = process.process(events, time_start=None, time_end=None)
|
||||
count = process.write(ns_entries)
|
||||
|
||||
self.assertEqual(count, 1)
|
||||
# In pretend mode, nothing should be uploaded
|
||||
self.assertEqual(len(self.nightscout.uploaded_entries.get('entries', [])), 0)
|
||||
|
||||
def test_write_entries_real_mode(self):
|
||||
"""Test that real mode uploads entries"""
|
||||
process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False, timezone='America/New_York')
|
||||
|
||||
events = [Event(G7_EVENT_1)]
|
||||
|
||||
ns_entries = process.process(events, time_start=None, time_end=None)
|
||||
count = process.write(ns_entries)
|
||||
|
||||
self.assertEqual(count, 1)
|
||||
# In real mode, entries should be uploaded
|
||||
self.assertEqual(len(self.nightscout.uploaded_entries['entries']), 1)
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][0]['sgv'], 80)
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][0]['dateString'], '2025-12-13T18:16:44-0500')
|
||||
|
||||
def test_write_mixed_g6_and_g7(self):
|
||||
"""Test writing a mix of G6 and G7 readings"""
|
||||
process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False, timezone='America/New_York')
|
||||
|
||||
events = [
|
||||
Event(G6_EVENT_1),
|
||||
Event(G6_EVENT_2),
|
||||
Event(G7_EVENT_1),
|
||||
Event(G7_EVENT_2)
|
||||
]
|
||||
|
||||
ns_entries = process.process(events, time_start=None, time_end=None)
|
||||
count = process.write(ns_entries)
|
||||
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(len(self.nightscout.uploaded_entries['entries']), 4)
|
||||
# g6
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][0]['sgv'], 75)
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][0]['dateString'], '2022-01-28T00:01:09-0500')
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][1]['sgv'], 77)
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][1]['dateString'], '2022-01-28T00:06:08-0500')
|
||||
# g7
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][2]['sgv'], 80)
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][2]['dateString'], '2025-12-13T18:16:44-0500')
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][3]['sgv'], 71)
|
||||
self.assertEqual(self.nightscout.uploaded_entries['entries'][3]['dateString'], '2025-12-13T18:21:44-0500')
|
||||
|
||||
|
||||
class TestProcessCGMReadingMultipleTimezones(unittest.TestCase):
|
||||
"""Test timezone processing across TIMEZONE_NAME values."""
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.tconnect = TConnectApi()
|
||||
self.nightscout = NightscoutApi()
|
||||
self.nightscout.last_uploaded_bg_entry = lambda *args, **kwargs: None
|
||||
self.tconnect_device_id = 'abcdef'
|
||||
|
||||
def test_America_New_York(self):
|
||||
timezone = 'America/New_York'
|
||||
|
||||
events = [Event(G7_EVENT_1)]
|
||||
process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False, timezone=timezone)
|
||||
p = process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertEqual(p[0]['dateString'], '2025-12-13T18:16:44-0500')
|
||||
|
||||
def test_America_Chicago(self):
|
||||
timezone = 'America/Chicago'
|
||||
|
||||
events = [Event(G7_EVENT_1)]
|
||||
process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False, timezone=timezone)
|
||||
p = process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertEqual(p[0]['dateString'], '2025-12-13T18:16:44-0600')
|
||||
|
||||
def test_GMT(self):
|
||||
timezone = 'Etc/GMT'
|
||||
|
||||
events = [Event(G7_EVENT_1)]
|
||||
process = ProcessCGMReading(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False, timezone=timezone)
|
||||
p = process.process(events, time_start=None, time_end=None)
|
||||
|
||||
self.assertEqual(len(p), 1)
|
||||
self.assertEqual(p[0]['dateString'], '2025-12-13T18:16:44+0000')
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user