mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-28 02:34:16 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d2c2dc280 | ||
|
|
e2b55d55af | ||
|
|
6150619d1a | ||
|
|
7cba6bb0e6 | ||
|
|
d2190db45d | ||
|
|
d928c0ca72 | ||
|
|
5c09748fdb | ||
|
|
2bfc5a7860 | ||
|
|
73a0e2924d | ||
|
|
f973f374e7 | ||
|
|
8fb2ced9c5 | ||
|
|
d902cd7aab | ||
|
|
a781e458c3 | ||
|
|
3fe131bcf3 | ||
|
|
3260442893 | ||
|
|
e3542bc002 | ||
|
|
fb5f89e051 | ||
|
|
017e8845b9 | ||
|
|
30f1a8cbcd | ||
|
|
22006f7c0f | ||
|
|
166a142de5 | ||
|
|
b68425c5d3 | ||
|
|
9ab6906e81 | ||
|
|
86b6b28803 | ||
|
|
4409b78890 | ||
|
|
e52cd73549 | ||
|
|
10fca69038 | ||
|
|
1f95dd99f6 | ||
|
|
1e16b7ad01 | ||
|
|
cffee9cdca | ||
|
|
ee85226c2f | ||
|
|
db7d34e62e |
+4
-2
@@ -5,10 +5,12 @@ coverage:
|
||||
project:
|
||||
default: false
|
||||
tconnectsync:
|
||||
paths: "tconnectsync/"
|
||||
paths:
|
||||
- "tconnectsync/"
|
||||
target: '75%'
|
||||
threshold: '5%'
|
||||
tests:
|
||||
paths: "tests/"
|
||||
paths:
|
||||
- "tests/"
|
||||
target: '95%'
|
||||
threshold: '5%'
|
||||
@@ -22,6 +22,16 @@ jobs:
|
||||
repository: jwoglom/tconnectsync/tconnectsync
|
||||
tag_with_ref: true
|
||||
|
||||
- name: Push latest tag to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: docker.pkg.github.com
|
||||
repository: jwoglom/tconnectsync/tconnectsync
|
||||
tags: latest
|
||||
push: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
push_to_dockerhub:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -30,8 +30,19 @@ jobs:
|
||||
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 # DDoS attack in wheel package, which is unsupported in python 3.7
|
||||
--ignore 51499 \
|
||||
--ignore 52495 \
|
||||
--ignore 52365 \
|
||||
--ignore 59956 \
|
||||
--ignore 58755 \
|
||||
--ignore 67895 \
|
||||
--ignore 61893 \
|
||||
--ignore 61601 \
|
||||
--ignore 62044 \
|
||||
--ignore 67599
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
@@ -41,6 +52,14 @@ jobs:
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pytest
|
||||
- name: Check codecov configuration
|
||||
run: |
|
||||
curl -X POST --data-binary @.codecov.yml https://codecov.io/validate
|
||||
|
||||
if [[ "$(curl -s -o /dev/null -w "%{http_code}" -X POST --data-binary @.codecov.yml https://codecov.io/validate)" != "200" ]]; then
|
||||
echo Error parsing codecov file
|
||||
exit 1
|
||||
fi
|
||||
- name: Generate Coverage Report
|
||||
run: |
|
||||
pip install coverage
|
||||
@@ -48,4 +67,4 @@ jobs:
|
||||
- name: Upload Coverage to Codecov
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -55,7 +55,7 @@ The following synchronization features can be optionally enabled:
|
||||
* Sleep Mode (in Nightscout, appears with a start and end time)
|
||||
* `IOB`: Insulin-on-board data. Only the most recent IOB entry is saved to Nightscout, as an "activity". The Nightscout UI does not currently display this information. In order to read this value, you need to query the Nightscout activity API endpoint. If you don't know what that means, then there is no reason to enable this option.
|
||||
|
||||
The following synchronization features are under development, [**but are not yet ready for use**](https://github.com/jwoglom/tconnectsync/issues/16):
|
||||
The following synchronization features are considered to be in alpha, and haven't been widely tested. If you want to use them, [set `ENABLE_TESTING_MODES=true` for them to show up](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/features.py#L29):
|
||||
* `BOLUS_BG`: Adds BG readings which are associated with boluses on the pump into the Nightscout treatment object. It will determine whether the BG reading was automatically filled via the Dexcom connection on the pump or was manually entered by seeing if the BG reading matches the current CGM reading as known to the pump at that time. Support for this is nearly complete.
|
||||
* `CGM`: Adds Dexcom CGM readings from the pump to Nightscout as SGV (sensor glucose value) entries. This should only be used in a situation where xDrip/Dexcom Share/etc. is not used and the pump connection to the CGM will be the only source of CGM data to Nightscout. This requires additional testing before it should be considered ready.
|
||||
|
||||
@@ -122,9 +122,11 @@ First, ensure that you have **Python 3** with **Pip** installed:
|
||||
- For CentOS/Rocky Linux 8:
|
||||
- `sudo dnf install python39-pip`
|
||||
- `sudo alternatives --set python /usr/bin/python3.9`
|
||||
* **On Windows:** Install Ubuntu under the [Windows Subsystem for Linux](https://ubuntu.com/wsl).
|
||||
Open the Ubuntu Terminal, then run `sudo apt install python3 python3-pip`.
|
||||
Perform the remainder of the steps under the Ubuntu environment.
|
||||
* **On Windows:**
|
||||
- **With WSL:** Install Ubuntu under the [Windows Subsystem for Linux](https://ubuntu.com/wsl).
|
||||
Open the Ubuntu Terminal, then run `sudo apt install python3 python3-pip`.
|
||||
Perform the remainder of the steps under the Ubuntu environment.
|
||||
- **Native:** Alternatively, you can run tconnectsync in native Windows with no modifications. However, this is less well-tested (open a GitHub issue if you experience any problems).
|
||||
|
||||
Now install the `tconnectsync` package with pip:
|
||||
|
||||
@@ -136,7 +138,7 @@ To install into a user environment instead of system-wide for a more contained i
|
||||
$ pip3 install --user tconnectsync
|
||||
````
|
||||
- This will place the tconnectsync binary file at ``/home/<username>/.local/bin/tconnectsync``
|
||||
|
||||
- For non-WSL Windows, it will be in ``<PYTHON DIRECTORY>\Lib\site-packages\tconnectsync``
|
||||
|
||||
If the pip3 command is not found, run `python3 -m pip install tconnectsync` instead.
|
||||
|
||||
@@ -167,7 +169,7 @@ Move the `.env` file you created to the following folder:
|
||||
|
||||
* **MacOS:** `/Users/<username>/.config/tconnectsync/.env`
|
||||
* **Linux:** `$HOME/.config/tconnectsync/.env`
|
||||
* **Windows:** `$HOME/.config/tconnectsync/.env` (inside WSL)
|
||||
* **Windows:** `$HOME/.config/tconnectsync/.env` (inside WSL) OR `C:\Users\<username>\.config\tconnectsync` (native Windows)
|
||||
|
||||
```
|
||||
$ tconnectsync --check-login
|
||||
@@ -402,6 +404,20 @@ An example of a user crontab `crontab -e` if not running system-wide, which runs
|
||||
|
||||
You can use one of the same `run.sh` files referenced above, but remove the `--auto-update` flag since you are handling the functionality for running the script periodically yourself.
|
||||
|
||||
### For Native Windows
|
||||
|
||||
Create a batch file 'tconnectsync.bat' file containing:
|
||||
|
||||
```
|
||||
python "C:\Users\<USERNAME>\AppData\Local\Programs\Python\<PYTHONVERSIONDIRECTORY>\Lib\site-packages\tconnectsync\main.py" --auto-update
|
||||
```
|
||||
|
||||
If `python` does not exist in your path, specify the full path to `python.exe`.
|
||||
|
||||
If main.py doesn't exist in `C:\Users\<USERNAME>\AppData\Local\Programs\Python\<PYTHONVERSIONDIRECTORY>\Lib\site-packages\tconnectsync\`, create it to match the copy in this repository.
|
||||
|
||||
[Use Windows Task Scheduler](https://www.windowscentral.com/how-create-automated-task-using-task-scheduler-windows-10) to run this batch file on a scheduled basis.
|
||||
|
||||
## Tandem APIs
|
||||
|
||||
This application utilizes three separate Tandem APIs for obtaining t:connect data, referenced here by the identifying part of their URLs:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.9.1
|
||||
version = 0.9.8
|
||||
author = James Woglom
|
||||
author_email = j@wogloms.net
|
||||
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
|
||||
|
||||
@@ -18,11 +18,13 @@ try:
|
||||
TCONNECT_PASSWORD,
|
||||
NS_URL,
|
||||
NS_SECRET,
|
||||
NS_SKIP_TLS_VERIFY
|
||||
NS_SKIP_TLS_VERIFY,
|
||||
PUMP_SERIAL_NUMBER,
|
||||
NS_IGNORE_CONN_ERRORS
|
||||
)
|
||||
from . import secret
|
||||
except Exception:
|
||||
print('Unable to read secret.py')
|
||||
except Exception as e:
|
||||
print('Unable to read secrets from secret.py', e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -73,9 +75,19 @@ def main(*args, **kwargs):
|
||||
if time_end < time_start:
|
||||
raise Exception('time_start must be before time_end')
|
||||
|
||||
|
||||
if TCONNECT_EMAIL == 'email@email.com':
|
||||
logging.warn('NO USERNAME WAS PROVIDED. Ensure you have set TCONNECT_EMAIL appropriately.')
|
||||
if TCONNECT_PASSWORD == 'password':
|
||||
logging.warn('NO PASSWORD WAS PROVIDED. Ensure you have set TCONNECT_PASSWORD appropriately.')
|
||||
if NS_URL == 'https://yournightscouturl/':
|
||||
logging.warn('NO NIGHTSCOUT URL WAS PROVIDED. Ensure your have set NS_URL appropriately.')
|
||||
if PUMP_SERIAL_NUMBER == '11111111':
|
||||
logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.')
|
||||
|
||||
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
|
||||
|
||||
nightscout = NightscoutApi(NS_URL, NS_SECRET, NS_SKIP_TLS_VERIFY)
|
||||
nightscout = NightscoutApi(NS_URL, NS_SECRET, skip_verify=NS_SKIP_TLS_VERIFY, ignore_conn_errors=NS_IGNORE_CONN_ERRORS)
|
||||
|
||||
if args.check_login:
|
||||
return check_login(tconnect, time_start, time_end)
|
||||
|
||||
@@ -127,7 +127,7 @@ def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[str, str]]:
|
||||
class ApiException(Exception):
|
||||
def __init__(self, status_code, text, *args, **kwargs):
|
||||
self.status_code = status_code
|
||||
super().__init__('%s (HTTP %s)' % (text, status_code), *args, **kwargs)
|
||||
super().__init__('%s%s' % (text, ' (HTTP %s)' % status_code if status_code else ''), *args, **kwargs)
|
||||
|
||||
class ApiLoginException(ApiException):
|
||||
pass
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from ..util import timeago
|
||||
from ..util import timeago, cap_length
|
||||
from .common import parse_date, base_headers, base_session, ApiException, ApiLoginException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,7 +14,7 @@ class ControlIQApi:
|
||||
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
|
||||
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
|
||||
|
||||
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.14.0.1'
|
||||
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.17.1.2'
|
||||
|
||||
userGuid = None
|
||||
accessToken = None
|
||||
@@ -34,12 +34,20 @@ class ControlIQApi:
|
||||
data = self._build_login_data(email, password, soup)
|
||||
|
||||
req = s.post(self.LOGIN_URL, data=data, headers={'Referer': self.LOGIN_URL, **base_headers()}, allow_redirects=False)
|
||||
# HTTP 200 is reported when credentials are incorrect
|
||||
if req.status_code == 200:
|
||||
login_error = self._find_login_error(req.text)
|
||||
if not login_error:
|
||||
login_error = 'Check your login credentials.'
|
||||
raise ApiLoginException(None, 'Error logging in to t:connect: %s' % login_error)
|
||||
|
||||
if req.status_code != 302:
|
||||
raise ApiLoginException(req.status_code, 'Error logging in to t:connect. Check your login credentials.')
|
||||
raise ApiLoginException(req.status_code, 'Error logging in to t:connect')
|
||||
|
||||
|
||||
fwd = s.post(urllib.parse.urljoin(self.LOGIN_URL, req.headers['Location']), cookies=req.cookies, headers=base_headers())
|
||||
if fwd.status_code != 200:
|
||||
logger.warn("Received non-HTTP 200: %s" % req.text)
|
||||
raise ApiException(fwd.status_code, 'Error retrieving t:connect login cookies.')
|
||||
|
||||
self.userGuid = req.cookies['UserGUID']
|
||||
@@ -79,6 +87,14 @@ class ControlIQApi:
|
||||
"txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password)
|
||||
}
|
||||
|
||||
def _find_login_error(self, text):
|
||||
try:
|
||||
soup = BeautifulSoup(text, features='lxml')
|
||||
notice_error = soup.select_one(".notice_error").text.strip()
|
||||
return notice_error
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def needs_relogin(self):
|
||||
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
|
||||
return (diff.seconds <= 5 * 60)
|
||||
|
||||
@@ -30,10 +30,14 @@ class WebUIScraper:
|
||||
return self.controliq.needs_relogin()
|
||||
|
||||
def _get(self, endpoint):
|
||||
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers())
|
||||
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers(), allow_redirects=True)
|
||||
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "WebUIScraper HTTP %s response: %s" % (str(r.status_code), r.text))
|
||||
|
||||
if 'login.aspx' in r.url:
|
||||
raise ApiException(401, "WebUIScraper HTTP %s response for login page, returning 401: %s" % (str(r.status_code), r.url))
|
||||
|
||||
return r
|
||||
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class Autoupdate:
|
||||
# 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. " % (datetime.datetime.now(), (now - self.last_successful_process_time_range)//60) +
|
||||
"tconnectsync might not be functioning properly."))
|
||||
|
||||
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
|
||||
@@ -19,6 +19,9 @@ class TherapyEvent:
|
||||
self.eventDateTime = json['eventDateTime']
|
||||
self.sourceRecId = json['sourceRecId']
|
||||
self.rawJson = json
|
||||
|
||||
def __str__(self):
|
||||
return "%s(%s)" % (self.type, self.rawJson)
|
||||
|
||||
class CGMTherapyEvent(TherapyEvent):
|
||||
eventID = None
|
||||
|
||||
+43
-24
@@ -30,10 +30,11 @@ def time_range(field_name, start_time, end_time, t_to_space=False):
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class NightscoutApi:
|
||||
def __init__(self, url, secret, skip_verify=False):
|
||||
def __init__(self, url, secret, skip_verify=False, ignore_conn_errors=False):
|
||||
self.url = url
|
||||
self.secret = secret
|
||||
self.verify = False if skip_verify else None
|
||||
self.ignore_conn_errors = ignore_conn_errors
|
||||
|
||||
|
||||
def upload_entry(self, ns_format, entity='treatments'):
|
||||
@@ -76,22 +77,27 @@ class NightscoutApi:
|
||||
if j and len(j) > 0:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
ret = None
|
||||
try:
|
||||
ret = internal(False)
|
||||
except ApiException as e:
|
||||
logger.warning("last_uploaded_entry with no t_to_space: %s", e)
|
||||
ret = None
|
||||
if ret is None and (time_start or time_end):
|
||||
try:
|
||||
ret = internal(True)
|
||||
ret = internal(False)
|
||||
except ApiException as e:
|
||||
logger.warning("last_uploaded_entry with t_to_space: %s", e)
|
||||
logger.warning("last_uploaded_entry with no t_to_space: %s", e)
|
||||
ret = None
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
|
||||
return ret
|
||||
if ret is None and (time_start or time_end):
|
||||
try:
|
||||
ret = internal(True)
|
||||
except ApiException as e:
|
||||
logger.warning("last_uploaded_entry with t_to_space: %s", e)
|
||||
ret = None
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
|
||||
return ret
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
if self.ignore_conn_errors:
|
||||
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
def last_uploaded_bg_entry(self, time_start=None, time_end=None):
|
||||
def internal(t_to_space):
|
||||
@@ -107,12 +113,19 @@ class NightscoutApi:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_bg_entry with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
|
||||
return ret
|
||||
try:
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_bg_entry with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
|
||||
|
||||
return ret
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
if self.ignore_conn_errors:
|
||||
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
def last_uploaded_activity(self, activityType, time_start=None, time_end=None):
|
||||
def internal(t_to_space):
|
||||
@@ -128,12 +141,18 @@ class NightscoutApi:
|
||||
return j[0]
|
||||
return None
|
||||
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_activity with activityType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (activityType, time_start, time_end))
|
||||
return ret
|
||||
try:
|
||||
ret = internal(False)
|
||||
if ret is None and (time_start or time_end):
|
||||
ret = internal(True)
|
||||
if ret is not None:
|
||||
logger.warning("last_uploaded_activity with activityType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (activityType, time_start, time_end))
|
||||
return ret
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
if self.ignore_conn_errors:
|
||||
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
|
||||
else:
|
||||
raise e
|
||||
|
||||
"""
|
||||
Returns general status information about the Nightscout server.
|
||||
|
||||
@@ -49,6 +49,7 @@ if not get('NS_SECRET') and get('API_SECRET'):
|
||||
NS_SECRET = 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')
|
||||
|
||||
TIMEZONE_NAME = get('TIMEZONE_NAME', 'America/New_York')
|
||||
|
||||
|
||||
@@ -35,11 +35,14 @@ def process_bolus_events(bolusdata, cgmEvents=None, source=""):
|
||||
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.start_time:
|
||||
logger.warning("Skipping non-completed %s extended bolus data with no start time: %s parsed: %s" % (source, b, parsed))
|
||||
elif not parsed.bolex_start_time and parsed.start_time:
|
||||
logger.warning("Setting bolex_start_time to start_time for non-completed %s extended bolus: %s parsed: %s" % (source, b, parsed))
|
||||
parsed.bolex_start_time = parsed.start_time
|
||||
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
|
||||
|
||||
@@ -90,7 +90,7 @@ def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSett
|
||||
|
||||
if not current_pump_profile:
|
||||
logger.error('No current pump profile, so skipping profile update: device: %s', device_profiles)
|
||||
return False, current_ns_profile
|
||||
return False, ns_profile_obj
|
||||
|
||||
current_ns_profile = ns_profile_obj.get('defaultProfile')
|
||||
if current_pump_profile != current_ns_profile:
|
||||
|
||||
@@ -192,11 +192,11 @@ def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, prete
|
||||
|
||||
add_count = 0
|
||||
for event in events:
|
||||
created_at = event["time"]
|
||||
if last_upload_time and arrow.get(created_at) <= last_upload_time:
|
||||
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 == last_upload["created_at"] and float(event["duration_mins"]) > float(last_upload["duration"]):
|
||||
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"])
|
||||
|
||||
@@ -28,4 +28,9 @@ def removesuffix(input_string, suffix):
|
||||
def removeprefix(input_string, prefix):
|
||||
if prefix and input_string.startswith(prefix):
|
||||
return input_string[len(prefix):]
|
||||
return input_string
|
||||
return input_string
|
||||
|
||||
def cap_length(text, maxlen):
|
||||
if not text or len(text) <= maxlen:
|
||||
return text
|
||||
return '%s[...]%s' % (text[:maxlen//2], text[maxlen//-2:])
|
||||
@@ -111,12 +111,65 @@ class TestControlIQApi(unittest.TestCase):
|
||||
|
||||
text=post_callback)
|
||||
|
||||
self.assertRaises(ApiLoginException, ciq.login, 'email@email.com', 'password')
|
||||
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: Check your login credentials.', ciq.login, 'email@email.com', 'password')
|
||||
|
||||
self.assertIsNone(ciq.userGuid)
|
||||
self.assertIsNone(ciq.accessToken)
|
||||
self.assertIsNone(ciq.accessTokenExpiresAt)
|
||||
|
||||
def test_login_invalid_credentials_parsed_message(self):
|
||||
ciq = ControlIQApi()
|
||||
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
|
||||
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
|
||||
request_headers=base_headers(),
|
||||
|
||||
text=self.LOGIN_HTML)
|
||||
|
||||
def post_callback(request, context):
|
||||
context.status_code = 200
|
||||
return '<html><body><div class="notice_error" id="literalMessage" style="">The email address or password you entered is invalid. Please re-enter and try again.</div></body></html>'
|
||||
|
||||
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
|
||||
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
|
||||
|
||||
text=post_callback)
|
||||
|
||||
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: The email address or password you entered is invalid. Please re-enter and try again.', ciq.login, 'email@email.com', 'password')
|
||||
|
||||
self.assertIsNone(ciq.userGuid)
|
||||
self.assertIsNone(ciq.accessToken)
|
||||
self.assertIsNone(ciq.accessTokenExpiresAt)
|
||||
|
||||
def test_login_unexpected_http_code(self):
|
||||
ciq = ControlIQApi()
|
||||
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
|
||||
ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
|
||||
request_headers=base_headers(),
|
||||
|
||||
text=self.LOGIN_HTML)
|
||||
|
||||
def post_callback(request, context):
|
||||
context.status_code = 500
|
||||
return '<html><body>...</body></html>'
|
||||
|
||||
m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f',
|
||||
request_headers={'Referer': ciq.LOGIN_URL, **base_headers()},
|
||||
|
||||
text=post_callback)
|
||||
|
||||
self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect \(HTTP 500\)', ciq.login, 'email@email.com', 'password')
|
||||
|
||||
self.assertIsNone(ciq.userGuid)
|
||||
self.assertIsNone(ciq.accessToken)
|
||||
self.assertIsNone(ciq.accessTokenExpiresAt)
|
||||
|
||||
|
||||
def fake_get_with_http_code(self, http_code, expected_endpoint, num_times):
|
||||
tries = 0
|
||||
def fake_get(endpoint, query):
|
||||
|
||||
+59
-1
@@ -18,7 +18,7 @@ from .fake import ControlIQApi
|
||||
from tconnectsync.api.controliq import ControlIQApi as RealControlIQApi
|
||||
from tconnectsync.api.common import ApiException, ApiLoginException, base_headers
|
||||
|
||||
class TestWebUIScraper(unittest.TestCase):
|
||||
class TestWebUIScraperMyDevices(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
DEVICES_HTML = """
|
||||
@@ -319,6 +319,64 @@ class TestWebUIScraper(unittest.TestCase):
|
||||
'guid': None
|
||||
})})
|
||||
|
||||
REDIRECT_HTML = """<html><head><title>Object moved</title></head><body>
|
||||
<h2>Object moved to <a href="/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx">here</a>.</h2>
|
||||
</body></html>"""
|
||||
|
||||
LOGIN_HTML = """<html></html>"""
|
||||
|
||||
def test_relogin_when_redirected_to_login_page(self):
|
||||
ciq = ControlIQApi()
|
||||
ciq.needs_relogin = lambda: False
|
||||
ciq.loginSession = requests.Session()
|
||||
ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL
|
||||
ciq._email = 'EMAIL'
|
||||
ciq._password = 'PASSWORD'
|
||||
|
||||
login_times = []
|
||||
def fake_login(email, password):
|
||||
self.assertEqual(email, ciq._email)
|
||||
self.assertEqual(password, ciq._password)
|
||||
|
||||
login_times.append(1)
|
||||
return True
|
||||
|
||||
ciq.login = fake_login
|
||||
webui = WebUIScraper(ciq)
|
||||
|
||||
ciq.login(ciq._email, ciq._password)
|
||||
self.assertEqual(len(login_times), 1)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/myaccount/my_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
status_code=302,
|
||||
headers={'Location': 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx'},
|
||||
text=self.REDIRECT_HTML)
|
||||
|
||||
m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2fmyaccount%2fmy_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
status_code=200,
|
||||
text=self.LOGIN_HTML)
|
||||
|
||||
self.assertRaises(ApiException, webui.my_devices)
|
||||
self.assertEqual(len(login_times), 2)
|
||||
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get('https://tconnect.tandemdiabetes.com/myaccount/my_devices.aspx',
|
||||
request_headers=base_headers(),
|
||||
|
||||
text=self.DEVICES_HTML)
|
||||
|
||||
devices = webui.my_devices()
|
||||
self.assertEqual(len(login_times), 2)
|
||||
|
||||
|
||||
class TestWebUIScraperPumpSettings(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
PUMP_SETTINGS_HTML = """
|
||||
<!DOCTYPE html
|
||||
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
|
||||
@@ -20,7 +20,7 @@ class NightscoutApi(tconnectsync.nightscout.NightscoutApi):
|
||||
def put_entry(self, ns_format, entity):
|
||||
self.put_entries[entity].append(ns_format)
|
||||
|
||||
def last_uploaded_entry(self, eventType, start_date=None, end_date=None):
|
||||
def last_uploaded_entry(self, eventType, time_start=None, time_end=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def last_uploaded_activity(self, activityType):
|
||||
|
||||
@@ -442,6 +442,71 @@ class TestTConnectEntryBolus(unittest.TestCase):
|
||||
"bolex_start_time": None
|
||||
}))
|
||||
|
||||
|
||||
entryExtendedComplete = {
|
||||
"Type": "Bolus",
|
||||
"Description": "Extended 50.00%/0.00",
|
||||
"BG": "131",
|
||||
"IOB": "5.87",
|
||||
"BolusRequestID": "3636.000",
|
||||
"BolusCompletionID": "3636.000",
|
||||
"CompletionDateTime": "2022-08-09T23:20:04",
|
||||
"InsulinDelivered": "0.20",
|
||||
"FoodDelivered": "0.00",
|
||||
"CorrectionDelivered": "0.00",
|
||||
"CompletionStatusID": "3",
|
||||
"CompletionStatusDesc": "Completed",
|
||||
"BolusIsComplete": "1",
|
||||
"BolexCompletionID": "16757133",
|
||||
"BolexSize": "0.20",
|
||||
"BolexStartDateTime": "2022-08-09T23:20:04",
|
||||
"BolexCompletionDateTime": "2022-08-09T23:35:03",
|
||||
"BolexInsulinDelivered": "0.20",
|
||||
"BolexIOB": "5.7",
|
||||
"BolexCompletionStatusID": "3.00",
|
||||
"BolexCompletionStatusDesc": "Completed",
|
||||
"ExtendedBolusIsComplete": "1",
|
||||
"EventDateTime": "2022-08-09T23:19:15",
|
||||
"RequestDateTime": "2022-08-09T23:19:15",
|
||||
"BolusType": "Carb",
|
||||
"BolusRequestOptions": "Extended",
|
||||
"StandardPercent": "50.00",
|
||||
"Duration": "15",
|
||||
"CarbSize": "0",
|
||||
"UserOverride": "1",
|
||||
"TargetBG": "110",
|
||||
"CorrectionFactor": "30.00",
|
||||
"FoodBolusSize": "0.00",
|
||||
"CorrectionBolusSize": "0.00",
|
||||
"ActualTotalBolusRequested": "0.40",
|
||||
"IsQuickBolus": "0",
|
||||
"EventHistoryReportEventDesc": "0",
|
||||
"EventHistoryReportDetails": "Food Bolus: 50% Extended 15 mins",
|
||||
"NoteID": "CF 1:30 - Carb Ratio 1:6 - Target BG 110 | Override: Pump calculated Bolus = 0.0 units",
|
||||
"IndexID": "0",
|
||||
"Note": "631597"
|
||||
}
|
||||
def test_parse_bolus_entry_extended_complete(self):
|
||||
self.assertEqual(
|
||||
TConnectEntry.parse_bolus_entry(self.entryExtendedComplete),
|
||||
Bolus(**{
|
||||
"description": "Extended 50.00%/0.00",
|
||||
"complete": "1",
|
||||
"completion": "Completed",
|
||||
"request_time": None,
|
||||
"completion_time": None,
|
||||
"insulin": "0.20",
|
||||
"requested_insulin": "0.40",
|
||||
"carbs": "0",
|
||||
"bg": "131",
|
||||
"user_override": "1",
|
||||
"extended_bolus": "1",
|
||||
"bolex_completion_time": "2022-08-09 23:35:03-04:00",
|
||||
"bolex_start_time": "2022-08-09 23:20:04-04:00"
|
||||
}))
|
||||
|
||||
|
||||
|
||||
class TestTConnectEntryReading(unittest.TestCase):
|
||||
entry1 = {
|
||||
"DeviceType": "t:slim X2 Insulin Pump",
|
||||
|
||||
@@ -172,5 +172,23 @@ class TestBolusSync(unittest.TestCase):
|
||||
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()
|
||||
@@ -94,7 +94,7 @@ DEVICE_SETTINGS = DeviceSettings(
|
||||
)
|
||||
|
||||
NS_PROFILE_A = {
|
||||
"dia": 5.0,
|
||||
"dia": "5.0",
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
@@ -184,7 +184,7 @@ NS_PROFILE_A = {
|
||||
}
|
||||
|
||||
NS_PROFILE_B = {
|
||||
"dia": 5.0,
|
||||
"dia": "5.0",
|
||||
"carbratio": [
|
||||
{
|
||||
"time": "00:00",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/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'}
|
||||
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'}
|
||||
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'}
|
||||
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'}
|
||||
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'}
|
||||
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'}
|
||||
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'}
|
||||
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()
|
||||
Reference in New Issue
Block a user