Compare commits

...
254 Commits
Author SHA1 Message Date
James Woglom 4837ac1e86 v2.3.0 2025-06-07 23:34:42 -04:00
James Woglom 8b15ec5c90 add test_profile_data 2025-06-07 23:31:10 -04:00
James Woglom 37b008f6c3 fix test path 2025-06-07 23:29:09 -04:00
James Woglom 8c40fdb533 fix test import 2025-06-07 23:27:42 -04:00
James Woglom d51080f6d0 Fix profile test 2025-06-07 23:24:41 -04:00
James Woglom b32415aaac Delete dead non-tandem source tests 2025-06-07 23:21:38 -04:00
James Woglom ffd3c5b1ea Delete dead non-tandem source code 2025-06-07 23:21:18 -04:00
James Woglom 0b4674bae9 update check script 2025-06-07 23:19:23 -04:00
James Woglom a1202decbe ignore name never assigned in scope flake8 2025-06-07 22:53:18 -04:00
Adam BovillandJames Woglom 4888426034 feat: Add EU region support for Tandem t:connect API
Add comprehensive support for European Tandem t:connect servers alongside existing US support.

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

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

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

This enables EU Tandem pump users to sync their data using:
  --region EU or TCONNECT_REGION=EU
2025-06-07 22:48:43 -04:00
James Woglom 803b15b886 v2.2.4 2025-05-06 13:14:01 -04:00
JoshandJames Woglom 9e78fe770f chore: add main py for module finding in vscode 2025-05-06 13:13:19 -04:00
JoshandJames Woglom dfb39e07ef warn: add warning if selected pump serial has no recent events 2025-05-06 13:13:19 -04:00
JoshandJames Woglom 9fad0d71ef chore: update gitignore with venv and vscode dirs 2025-05-06 13:13:19 -04:00
James Woglom f5cd7c0151 v2.2.3 2025-03-29 23:37:15 -04:00
James Woglom 13bb106924 defaults: failure time to 75 min, turn off auto restart on failure 2025-03-29 23:36:53 -04:00
James Woglom fd96b08aab stop running pipenv check 2025-01-15 19:14:20 -05:00
James Woglom 4a8da39b44 process: automatically fetch all event types for devicestatus feature 2025-01-15 18:56:36 -05:00
James Woglom 7a0f9477f2 eventparser: add todict function on all event classes 2025-01-15 18:56:08 -05:00
James Woglom 934d90e3b9 log found ProcessDeviceStatus 2025-01-02 20:05:31 -05:00
James Woglom ea84b0b9cc v2.2.2 2024-12-30 22:05:22 -05:00
James Woglom 07aaea9e1c fix tests 2024-12-30 22:05:11 -05:00
James Woglom 67b9f42f9b v2.2.1 2024-12-30 22:02:54 -05:00
James Woglom 997ab7bac8 attempt to fix batteryChargePercent 2024-12-30 22:02:36 -05:00
James Woglom b1f8263ceb v2.2.0 2024-12-13 19:47:29 -05:00
James Woglom 29d08a7f1b fix non-autoupdate 2024-12-13 19:47:01 -05:00
James Woglom c7ade49eea devicestatus: tune nightscout output to look more like Loops uploader 2024-12-13 02:09:36 -05:00
James Woglom 1f0fbd7788 less noisy logs in update profiles 2024-12-13 01:42:58 -05:00
James Woglom 4bda0a21f2 more explicit logging for last update seen 2024-12-13 01:41:16 -05:00
James Woglom d1341e09c0 devicestatus: set pump.battery.percent 2024-12-12 22:20:13 -05:00
James Woglom fb9588ff2c catch iso8601 date errors 2024-12-12 00:41:51 -05:00
James Woglom 2339b4543e v2.1.9 2024-12-12 00:36:16 -05:00
James Woglom 3f6010fafc add processdevicestatus 2024-12-12 00:36:06 -05:00
James Woglom a028cdf3fe v2.1.8 2024-12-12 00:29:19 -05:00
James Woglom 093b42ff94 fix codecov 2024-12-12 00:29:11 -05:00
James Woglom 00023c6aba fix flake8 2024-12-12 00:27:35 -05:00
James Woglom 1ed3d35965 v2.1.7 2024-12-12 00:23:45 -05:00
James Woglom 9493b6546f process_alarm exclude resumepumpalarm and test 2024-12-12 00:23:17 -05:00
James Woglom 24a635ca21 add process_device_status and process_user_mode tests 2024-12-12 00:09:28 -05:00
James Woglom 80824906d9 add unit test 2024-12-11 22:41:58 -05:00
James Woglom e5259a7981 attempt to mutate battery charge percent 2024-12-11 00:56:01 -05:00
James Woglom 650fb60988 support excluding event id filter 2024-12-11 00:50:40 -05:00
James Woglom 2b367d45f3 eventparser: use eventID consistently as seqNum 2024-12-10 20:35:17 -05:00
James Woglom dd2dcd17bd eventparser: add custom events 2024-12-10 17:48:51 -05:00
James Woglom 98b7edc904 eventparser: handle unknown ids in Event() 2024-12-10 17:44:54 -05:00
James Woglom e82d311fd6 eventparser: move baseevent 2024-12-10 17:44:32 -05:00
James Woglom 9a2a4ca771 v2.1.6 2024-10-16 21:34:22 -04:00
James Woglom 54ba2ed7df profiles: sort and set enteredBy 2024-10-16 21:34:11 -04:00
James Woglom 2892eb8ace exclude empty pump profile segments 2024-10-16 21:13:41 -04:00
James Woglom 1d432982ad v2.1.5 with pypi upload fix 2024-10-07 21:59:01 -04:00
James Woglom 891c2e2c10 fix printf 2024-10-07 21:30:50 -04:00
James Woglom 7dc479c84b v2.1.4 2024-10-05 12:56:17 -04:00
James Woglom 0417fb9fd8 add IGNORE_ZERO_UNIT_BASAL 2024-10-05 12:55:59 -04:00
James Woglom b80d52662b v2.1.3 2024-10-03 01:00:16 -04:00
Zack FernandesandJames Woglom 51ac469f00 Fix choose_device import class name 2024-10-03 00:59:45 -04:00
James Woglom 81b13f236a v2.1.2 2024-10-02 23:07:43 -04:00
James Woglom d57b3872f1 fix process_user_mode again 2024-10-02 23:07:34 -04:00
James Woglom 0609a46ed2 v2.1.1 2024-10-02 22:23:39 -04:00
James Woglom e63d725c2b process_user_mode: fix bug when exercise concludes 2024-10-02 22:23:24 -04:00
James Woglom fec964e767 v2.1.0 2024-10-02 02:14:28 -04:00
James Woglom 366182f65b fix timezone transformation logic 2024-10-02 02:14:01 -04:00
James Woglom d9d09dddb3 v2.0.9 2024-10-02 01:35:10 -04:00
James Woglom 04643255fe heroku_helpers 2024-10-02 01:34:47 -04:00
James Woglom c2f7070f3b no 3.7 build job 2024-10-02 00:56:38 -04:00
James Woglom 9c35f850b9 v2.0.8 2024-10-02 00:54:29 -04:00
James Woglom a7383b0796 fix return code 2024-10-02 00:52:41 -04:00
James Woglom 4f52927d2f try to fix jwt import error 2024-10-02 00:45:39 -04:00
James Woglom 5b938c010d only conditionally import typing_extensions 2024-10-02 00:39:56 -04:00
James Woglom 3e50c7018d py3.7 force pyopenssl 2024-10-01 21:02:04 -04:00
James Woglom 32f3639eb0 py3.7 actions 2024-10-01 17:29:49 -04:00
James Woglom 9a7cb1bdfd no anchors 2024-10-01 17:27:01 -04:00
James Woglom ead7ab883b check old python versions 2024-10-01 17:25:26 -04:00
James Woglom ff395ffa3e test on py3.7 2024-10-01 17:17:55 -04:00
James Woglom 8f2ad586fd v2.0.7: fix py3.7 compat 2024-10-01 17:17:33 -04:00
James Woglom 828cccaf10 v2.0.6: update install_requires packages 2024-10-01 17:05:54 -04:00
James Woglom ae26102df2 v2.0.5 2024-10-01 17:01:40 -04:00
James Woglom 152c263fe0 Use pyjwt v2.8 to allow python3.7 support 2024-10-01 16:56:19 -04:00
James Woglom 38c933a257 v2.0.4 2024-10-01 16:50:19 -04:00
James Woglom d507e47e7a convert to float 2024-10-01 16:44:20 -04:00
James Woglom 8c2f41c2d5 skip processing no basal events 2024-10-01 16:30:18 -04:00
James Woglom 018c6d2228 dont repeat the last event on a process cycle 2024-10-01 16:29:23 -04:00
James Woglom 32ba49dc8a autoupdate bugfix 2024-10-01 16:24:43 -04:00
James Woglom a5cbc5d612 v2.0.3 2024-10-01 16:08:06 -04:00
James Woglom ef515021cc check changes for last seen event id 2024-10-01 16:07:48 -04:00
James Woglom 4af54bf7cb set tzinfo (fix #100) 2024-10-01 15:59:20 -04:00
James Woglom d9d1b8a8fb v2.0.2 2024-09-30 01:13:28 -04:00
James Woglom 29ed755356 3.8+ and add 3.11 2024-09-30 01:13:06 -04:00
James Woglom b862ea9d0d pin 2024-09-30 01:12:26 -04:00
James Woglom 2677fc86be pin arrow version for py3.7 compat 2024-09-30 01:09:25 -04:00
James Woglom 1ae40e3cdb python3.7 compat 2024-09-30 01:06:21 -04:00
James Woglom 90c63c8211 py3.7 compat 2024-09-30 00:58:22 -04:00
James Woglom 60c2e08dcf py3.7 compat 2024-09-30 00:53:14 -04:00
James Woglom d6f08c61b8 v2.0.1 2024-09-30 00:50:07 -04:00
James Woglom 784387dcb8 sensor start/stop/join eventtypes 2024-09-30 00:49:59 -04:00
James Woglom 84092e1de6 formatting fixes 2024-09-30 00:32:07 -04:00
James Woglom bb3094869d fix old tests (will be removed/updated) 2024-09-30 00:14:16 -04:00
James Woglom ec497d7d00 unit bugfixes 2024-09-30 00:09:38 -04:00
James Woglom 5aa5ea5215 version 2.0.0 2024-09-29 23:18:12 -04:00
James Woglom 34853dfb36 update readme and default sync features 2024-09-29 23:17:43 -04:00
James Woglom 362888cb14 update profiles 2024-09-29 23:06:42 -04:00
James Woglom abe8ea32aa fix event id 2024-09-29 22:13:21 -04:00
James Woglom 40cccc5de5 lambda 2024-09-29 22:11:11 -04:00
James Woglom 81d1e80b59 formatting fixes 2024-09-29 22:10:41 -04:00
James Woglom 210bcddbd3 V2 substantial completion 2024-09-29 21:50:17 -04:00
James Woglom c0cddb80b9 parser fixes 2024-09-21 21:53:36 -04:00
James Woglom 254d3fbbfc sync: initial source implementations (basal, suspension, alarm) 2024-09-21 21:53:19 -04:00
James Woglom 712b8a19cf event class 2024-09-21 21:52:52 -04:00
James Woglom 9752270b6c eventparser: fix parsing 2024-09-21 17:16:40 -04:00
James Woglom 7066afef33 sync/tandemsource: stub 2024-09-21 17:16:25 -04:00
James Woglom 63fe56467e api/tandemsource: cache credentials 2024-09-21 17:15:44 -04:00
James Woglom 6b7afaba03 fix syntax 2024-09-19 00:27:35 -04:00
James Woglom 454b545e35 exclude none 2024-09-19 00:26:04 -04:00
James Woglom 4e4de23e80 exclude true/false enum vals 2024-09-19 00:20:22 -04:00
James Woglom 68384caa04 remove tconnectpatcher ref 2024-09-19 00:16:27 -04:00
James Woglom 6fc49c60d3 event parser V1 2024-09-19 00:15:38 -04:00
James Woglom 75260c6957 update readme 2024-09-19 00:14:41 -04:00
James Woglom a77ebb5429 TandemSource API 2024-09-18 22:49:40 -04:00
James Woglom 822589e4b2 more ignore 2024-09-08 15:11:23 -04:00
James Woglom 90f2406b4f ignore more unrelated pipenv check failures 2024-09-08 15:10:20 -04:00
James Woglom 9d8554bd03 to str 2024-09-08 15:08:34 -04:00
James Woglom c60b19dbed handle WAF block 2024-09-08 15:07:54 -04:00
James Woglom 492963e240 bump to v1.0.0 2024-09-08 15:02:50 -04:00
James Woglom c9f6362928 Work-around for broken web-based auth method 2024-09-08 15:01:42 -04:00
James Woglom 3d2c2dc280 bump v0.9.8 2024-06-08 20:02:49 -04:00
James Woglom e2b55d55af bump current tconnect version 2024-06-08 20:02:30 -04:00
James Woglom 6150619d1a use NS_IGNORE_CONN_ERRORS secret 2024-06-08 20:01:16 -04:00
James Woglom 7cba6bb0e6 ignore connectionerrors in pretend mode 2024-06-08 20:01:05 -04:00
James Woglom d2190db45d dont fail on codecov error 2024-05-23 02:08:30 -04:00
James Woglom d928c0ca72 version: bump to v0.9.7 2024-05-23 00:52:38 -04:00
James Woglom 5c09748fdb add more ignores 2024-05-23 00:48:42 -04:00
James Woglom 2bfc5a7860 test pump events 2024-05-23 00:44:06 -04:00
James Woglom 73a0e2924d ignore irrelevant pipenv errors 2024-05-23 00:08:16 -04:00
Matthew MazaikaandJames Woglom f973f374e7 fix: update sleep events if their duration changes 2024-05-22 22:26:41 -04:00
James Woglom 8fb2ced9c5 version: bump to 0.9.6 2023-10-18 00:05:10 -04:00
James WoglomandGitHub d902cd7aab Push latest tag 2023-10-18 00:02:32 -04:00
James WoglomandGitHub a781e458c3 Update README.md 2023-09-30 21:01:15 -04:00
James Woglom 3fe131bcf3 version: bump to 0.9.5 2023-09-30 20:48:39 -04:00
James Woglom 3260442893 Bump supported tconnect software version 2023-09-30 20:37:05 -04:00
James Woglom e3542bc002 webui: detect logged out state and trigger re-login 2023-09-30 20:33:55 -04:00
James Woglom fb5f89e051 version: bump to 0.9.4 2023-06-11 00:28:18 -04:00
James Woglom 017e8845b9 Warn when default nightscout url or serial are being used 2023-06-11 00:24:42 -04:00
James Woglom 30f1a8cbcd Warn when default username or password is being used 2023-06-11 00:24:42 -04:00
James Woglom 22006f7c0f Bump supported software version and display login error message 2023-06-11 00:24:42 -04:00
James Woglom 166a142de5 Update README.md 2023-02-04 19:04:58 -05:00
jwalbergandJames Woglom b68425c5d3 Fixed typo in batch file instructions 2023-02-04 19:04:58 -05:00
jwalbergandJames Woglom 9ab6906e81 Update Readme.md for non-WSL Windows installation
This works in Windows, as-is. Updated the installation instructions with folder paths and scheduling instructions.
2023-02-04 19:04:58 -05:00
James Woglom 86b6b28803 bump to v0.9.3 2023-02-04 00:15:12 -05:00
James Woglom 4409b78890 add completed extended bolus test 2023-02-04 00:09:35 -05:00
James Woglom e52cd73549 Fix incomplete extended bolus parsing 2023-02-03 23:40:15 -05:00
James Woglom 10fca69038 ignore unrelated pipenv check 2023-01-25 01:17:41 -05:00
James Woglom 1f95dd99f6 fix AutoupdateNoNewDataDetectedError 2023-01-25 01:13:53 -05:00
James Woglom 1e16b7ad01 bump version to v0.9.2 2023-01-16 19:59:49 -05:00
James Woglom cffee9cdca fix codecov config 2023-01-16 19:58:44 -05:00
James Woglom ee85226c2f fix test failure 2023-01-16 19:54:16 -05:00
James Woglom db7d34e62e flake8 fix 2023-01-16 19:52:22 -05:00
James Woglom 7b17304efc bump v0.9.1 2023-01-16 19:49:47 -05:00
James Woglom d0af9c5c91 Profile sync bugfix and extra validation logic 2023-01-16 19:35:01 -05:00
James Woglom 85303bca82 transparently split large time range calls to ws2 therapytimeline 2023-01-16 19:25:21 -05:00
James Woglom c6820b7a0d update log message in process when in pretend mode 2023-01-16 18:21:21 -05:00
James Woglom 04c5a37b8d separate basal suspension events into own feature to allow skipping WS2 api 2023-01-16 18:06:29 -05:00
James Woglom 31e6737ca7 bump version to 0.9.0 2023-01-16 17:37:13 -05:00
James Woglom b9fae36dda ensure permutations of the same number in different types are considered the same NS profile 2023-01-16 17:35:31 -05:00
James Woglom 1507398eb0 track count in integration tests 2023-01-16 17:12:46 -05:00
James Woglom 4208ffa709 secret bugfix 2023-01-16 17:06:40 -05:00
James Woglom 3b66718e1f update README with profile sync feature 2023-01-16 17:01:04 -05:00
James Woglom d9e49e1fc8 integration tests for profile upload 2023-01-16 16:58:19 -05:00
James Woglom dd9a9ea72c tests for profile synchronization 2023-01-16 16:00:50 -05:00
James Woglom d69a760f5b Translate Tandem profile information to Nightscout format 2023-01-16 14:25:31 -05:00
James Woglom df4e9e4ed0 fix codecov configuration 2023-01-07 01:17:56 -05:00
James Woglom 98ee986941 bump to 0.8.10 2023-01-07 00:57:52 -05:00
James Woglom 3f6a0e007d wider log output in --check-login 2023-01-07 00:56:58 -05:00
James Woglom 48ec97434f log info about failed software version check 2023-01-07 00:51:07 -05:00
James Woglom cf0a88c53a Show full error tracebacks in --check-login 2023-01-07 00:50:56 -05:00
James Woglom a4ab6398f0 bump v0.8.9 2022-12-25 13:47:11 -05:00
James Woglom f67671e1ef handle cancelled extended bolus with no timestamp: fix #73 2022-12-25 13:46:21 -05:00
James Woglom 5e4d81a618 v0.8.8 2022-12-12 22:37:30 -05:00
James Woglom cabbc783c7 catch api exceptions due to workaround with timestamp format in nightscout (#71) 2022-12-12 22:36:46 -05:00
James Woglom 62f1322ef6 v0.8.7 2022-12-02 20:48:51 -05:00
James Woglom 139ccb1878 ignore python3.7 pipenv check warning 2022-12-02 20:48:12 -05:00
James Woglom e60e9ce537 version 0.8.6 2022-12-02 20:42:35 -05:00
James Woglom 6e180d9f2a add basal therapy event type, now that Tandem is storing it along with bolus/cgm/bgs 2022-12-02 20:42:35 -05:00
Jarred YawandJames Woglom b929d1151e Moved logger.warn() statements to logger.warning() due to the deprecation msgs I was getting while testing 2022-10-02 00:18:11 -04:00
James Woglom 4d4ee7b425 v0.8.5 2022-10-02 00:07:32 -04:00
James Woglom 0c5a6d5453 when last_uploaded query returns None, try with fixed timestamp 2022-10-02 00:05:08 -04:00
James Woglom 0b7e33db96 allow self-signed SSL cert with NS_SKIP_TLS_VERIFY (fixes #63) 2022-10-01 23:49:16 -04:00
James Woglom 5d58b23d27 v0.8.4 2022-09-17 10:31:23 -04:00
James Woglom 40aea810cc pass kwargs to ws2 query 2022-09-17 10:30:01 -04:00
Ryan StutsmanandJames Woglom ec84afdb7a Fix format string in UnknownTherapyEventType.
%s was missing from the format string. This enhances the message to
report the unexpected thearpy type along with the dump of the event that
trigger the exception.
2022-08-26 10:53:50 -04:00
James Woglom fca7f573a3 set codecov threshold to 50%, to stop failing builds for 0.01% changes in code coverage 2022-08-26 10:53:38 -04:00
James WoglomandGitHub 76fc043a2b Update issue templates 2022-08-24 12:55:32 -04:00
James Woglom 86e07880a9 bump to v0.8.3 2022-08-24 12:28:43 -04:00
Jarred YawandJames Woglom 751087373c fix unknown therapy event error in ciq_therapy_event.py 2022-08-24 12:20:45 -04:00
Ryan StutsmanandJames Woglom f47421e482 Replace hand-rolled date format with isoformat().
Fixes #47.
2022-08-22 16:45:46 -04:00
t1diotacandJames Woglom dfc39d61bb Update README.md
- Added sections on creating a user-specific install and crontab.
- Added bullet points on installing on CentOS/RHEL/Rocky Linux 8
2022-08-19 10:31:39 -04:00
James Woglom e8a12e291f version bump to 0.8.2 2022-08-12 00:20:11 -04:00
James Woglom 0ebfb2b744 add 10 second timeout on ws2 requests 2022-08-12 00:19:11 -04:00
James Woglom 8f6d03797a update API section in readme, and check-login script to properly sanitize 2022-08-12 00:06:17 -04:00
James Woglom 6f15ef8857 use domain objects for webui scraping pump profiles 2022-08-11 01:22:44 -04:00
James Woglom 4da4d6e2a6 log tconnect software version 2022-08-11 00:11:46 -04:00
James Woglom 92bed13878 log times for autoupdate errors 2022-08-10 23:28:15 -04:00
James Woglom 955ac68914 improve logging 2022-08-10 02:12:19 -04:00
James Woglom 26d4c63a4d bump to v0.8.0 2022-08-10 01:54:14 -04:00
James Woglom 81a62b8902 warning messages when falling back on Ws2 2022-08-10 01:53:31 -04:00
James Woglom 2f5775cbfb internally track bolus from CIQ API identically at par with WS2 2022-08-10 01:31:45 -04:00
James Woglom f13929a45a therapy_event and tests 2022-08-10 00:22:13 -04:00
James Woglom a18c2f3d17 move Bolus internal state from dictionary to domain object 2022-08-09 22:54:51 -04:00
James Woglom 1b400d3b22 Only connect to ws2 api when requisite feature needs that data 2022-07-26 00:07:39 -04:00
James Woglom d3816b18b2 propagate time filter to all ns_writes 2022-07-20 02:43:31 -04:00
James Woglom 4a6dd5f51d include time range in last_uploaded_entry (needs tests) 2022-07-20 02:29:48 -04:00
James Woglom 51abc15512 propagate -- but do not use -- time_start and time_end in last_uploaded_entry 2022-07-20 02:17:02 -04:00
James Woglom f672a2e199 bump to v0.7.2 2022-07-20 01:36:26 -04:00
James Woglom 52031d1d8a fix GETs with data 2022-07-20 01:32:32 -04:00
James Woglom 7139247d1c ensure consistent user agent across runtime 2022-07-20 01:17:05 -04:00
James Woglom f99c2b6119 HTTP 403 workarounds 2022-07-20 01:08:52 -04:00
James Woglom 0b3808d43d fix session ordering 2022-07-20 00:39:39 -04:00
James Woglom d4918984f8 requests version upgrade: explicitly pass data for get requests 2022-07-20 00:38:48 -04:00
James Woglom 123acaf163 fix requests proxies monkey-patch 2022-07-20 00:36:22 -04:00
James Woglom 898fb63af5 fix session creation ordering 2022-07-20 00:28:07 -04:00
James Woglom 46c4d60525 YAML was a mistake
https://github.com/actions/setup-python/issues/160
2022-07-20 00:07:52 -04:00
James Woglom e0d452bdea drop python 3.6 support in setup.cfg 2022-07-20 00:04:44 -04:00
James Woglom 31d2185154 drop python 3.6 support. no longer supported by requests 2022-07-20 00:04:14 -04:00
James Woglom d7cc6f957f test proxy 2022-07-19 23:57:49 -04:00
James Woglom ae2bf9a3a6 support proxy 2022-07-19 23:23:12 -04:00
James Woglom fe57bd6c14 bump to 0.7.1 for consistency between pip package and github/package metadata 2022-04-06 17:51:32 -04:00
James WoglomandGitHub 0872c756d2 Only run publish-docker on tags 2022-03-17 16:32:45 -04:00
James Woglom 7d8dabb3ef AndroidApi: match HTTP headers with app 2022-03-17 16:18:15 -04:00
James Woglom 798225c606 api: add user_agent to AndroidApi, move improperly scoped endpoint to tconnectapi 2022-03-17 16:07:57 -04:00
James WoglomandGitHub b08b9b8c76 trigger GitHub Actions on tag push 2022-02-27 00:35:53 -05:00
James Woglom 90bbd5dc63 bump version to v0.6.6 2022-02-22 00:17:58 -05:00
James WoglomandGitHub 5e56b41b62 Update publish-docker.yml
Add tags
2022-02-22 00:16:27 -05:00
James WoglomandGitHub e8fa6c5a7c Update publish-docker.yml
Add push_to_dockerhub GitHub Action
2022-02-22 00:08:20 -05:00
James Woglom fae1d4de2c api: add WebUIScraper which can read pump settings and profile info 2022-01-09 18:36:49 -05:00
James Woglom 76085e8d65 version bump to 0.6.5 2022-01-08 03:56:24 -05:00
James Woglom b4d0dad267 remove IOB feature in defaults because it is currently not displayed in the UI. document features in readme 2022-01-08 03:55:26 -05:00
James Woglom 7b22d612c3 autoupdate: refactor into class and write test 2022-01-07 00:32:50 -05:00
James Woglom 8b1ece3ac3 readme: rephrase initial section 2022-01-04 21:23:18 -05:00
James Woglom 7b06f1c457 secret: parse API_SECRET/TZ if NS_SECRET/TIMEZONE_NAME are missing 2022-01-04 21:02:20 -05:00
James Woglom 3a400390de bump to v0.6.4 2022-01-04 00:35:44 -05:00
James Woglom e2b30687e4 autoupdate: reset API connection when event index change is recorded but no data is found, potential fix for #11 2022-01-04 00:34:46 -05:00
James Woglom 0d956516d7 update Pipfile.lock, resolve GHSA-55x5-fj6c-h6m8 2022-01-03 17:58:51 -05:00
James Woglom 50cb57f11b secret: use TZ to infer TIMEZONE_NAME if set 2022-01-03 17:58:51 -05:00
James Woglom 2e515efc54 check: add additional output to --check-login 2022-01-03 17:58:51 -05:00
James WoglomandGitHub b52006b3b0 Update README to include Heroku instructions 2022-01-03 16:52:11 -05:00
James Woglom 07e5761178 bump version to 0.6.3 2021-12-27 15:58:48 -05:00
James Woglom 631d1c7b89 parser: support temp-profile basal suspension event 2021-12-27 15:56:50 -05:00
James Woglom 0253956377 test_secret: allow test to pass even if environment variables implicitly passed to pipenv test 2021-12-27 15:56:19 -05:00
James WoglomandGitHub 59816225e0 Update README.md 2021-12-23 21:59:18 -05:00
James WoglomandGitHub be113e9cf7 Update README.md 2021-12-23 21:56:38 -05:00
James Woglom 90bede8b75 bump version to v0.6.2 2021-12-23 20:57:50 -05:00
James Woglom 7a8d00da88 secret: put environment variables at higher precedence than .env file 2021-12-23 20:46:58 -05:00
James Woglom 52cb287ce8 bump version to 0.6.1 2021-12-23 20:38:23 -05:00
James Woglom 27ba5d9a64 Update dotenv secret parsing logic, and read from .config/tconnectsync/.env path 2021-12-23 19:39:51 -05:00
James WoglomandGitHub 2140bd356e Update README.md with note about forking the repo 2021-12-15 15:31:33 -05:00
James Woglom a74b63ab05 bugfix: check for extended bolus start/completion time presence before parsing, since extended boluses can be in progress 2021-12-12 13:29:08 -05:00
96 changed files with 15667 additions and 2293 deletions
+9 -4
View File
@@ -1,11 +1,16 @@
coverage:
status:
patch: no
changes: no
project:
default: false
tconnectsync:
paths: "tconnectsync/"
target: 75%
paths:
- "tconnectsync/"
target: '60%'
threshold: '5%'
tests:
paths: "tests/"
target: 95%
paths:
- "tests/"
target: '95%'
threshold: '5%'
@@ -0,0 +1,53 @@
---
name: 'Setup help: tconnectsync-heroku'
about: When experiencing an issue setting up tconnectsync-heroku
title: ''
labels: heroku, setup help
assignees: ''
---
**Describe the problem**
A clear and concise description of the issue you're experiencing.
**To Reproduce**
Steps to reproduce the behavior:
1. ...
2. ...
**Expected behavior**
A clear and concise description of what you expected to happen.
**Have you followed the [Troubleshooting steps in the README?](https://github.com/jwoglom/tconnectsync-heroku#troubleshooting)** (Yes or No)
**Setup details**
* **On what platform are you using the t:connect mobile app?** (Android or iOS)
* **What version are you using of the t:connect mobile app?**
**Heroku log output**
If applicable, add the full output from the heroku logs in More > View Logs. ([See the instructions in the README.](https://github.com/jwoglom/tconnectsync-heroku#testing))
**Check Login output**
If applicable, [please follow the instructions to visit the `check_login` page](https://github.com/jwoglom/tconnectsync-heroku#testing), and copy and paste the output here.
This file may contain sensitive details like your Nightscout URL and pump serial number. Copy the output into a text editor like Textedit or Notepad first and find-and-replace sensitive strings, like your Nightscout URL and pump serial number, if they appear.
The output will also contain pump and CGM related information such as delivery events and blood sugars. If you would prefer to keep the contents private, send an email to tconnectsync<at>wogloms.net with the log output so @jwoglom can investigate.
**Additional context**
Add any other context about the problem here.
@@ -0,0 +1,49 @@
---
name: 'Setup help: tconnectsync'
about: When experiencing an issue setting up tconnectsync on Windows, Linux, or MacOS.
title: ''
labels: setup help
assignees: ''
---
**Describe the problem**
A clear and concise description of the issue you're experiencing.
**To Reproduce**
Steps to reproduce the behavior:
1. ...
2. ...
**Expected behavior**
A clear and concise description of what you expected to happen.
**Setup details**
* **Operating system:**
* **tconnectsync version** (from `--version`):
* **On what platform are you using the t:connect mobile app?** (Android or iOS)
* **What version are you using of the t:connect mobile app?**
**Terminal output**
If applicable, add the full output from your terminal from running tconnectsync. Copy the output into a text editor like Textedit or Notepad first and find-and-replace sensitive strings, like your Nightscout URL and pump serial number, if they appear.
**Check Login output**
If applicable, please run `--check-login` and upload the tconnectsync-check-output.log file as an attachment on the issue. This file should have sensitive details like your Nightscout URL and pump serial number automatically removed, but double-check before uploading.
This log will contain pump and CGM related information such as delivery events and blood sugars. If you would prefer to keep the contents private, send an email to tconnectsync<at>wogloms.net with the log output so @jwoglom can investigate.
**Additional context**
Add any other context about the problem here.
+45 -3
View File
@@ -1,9 +1,9 @@
name: Publish Docker image
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
tags:
- 'v*'
workflow_dispatch:
jobs:
push_to_registry:
@@ -21,3 +21,45 @@ jobs:
registry: docker.pkg.github.com
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
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
with:
images: jwoglom/tconnectsync
tags: |
type=ref,event=branch
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:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1 -1
View File
@@ -28,6 +28,6 @@ jobs:
--outdir dist/
.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@master
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
+58 -31
View File
@@ -15,36 +15,63 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
python-version: ['3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest pipenv
pipenv install --system
- name: Run pipenv check
run: |
pipenv check
- 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
# 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: Test with pytest
run: |
pytest
- name: Generate Coverage Report
run: |
pip install coverage
coverage run -m unittest
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest pipenv
pipenv install --system
# - name: Run pipenv check
# run: |
# # DDoS attacks in wheel and setuptools packages, not relevant
# # root certificate store, not relevant
# pipenv check \
# --ignore 51499 \
# --ignore 52495 \
# --ignore 52365 \
# --ignore 59956 \
# --ignore 58755 \
# --ignore 67895 \
# --ignore 61893 \
# --ignore 61601 \
# --ignore 62044 \
# --ignore 67599 \
# --ignore 72083 \
# --ignore 71064 \
# --ignore 71608 \
# --ignore 72236
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --ignore=F824 --show-source --statistics
# 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
run: |
tconnectsync --help
- 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
coverage run -m unittest
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: false
+4
View File
@@ -7,3 +7,7 @@ build
*.swa
*.egg-info
.env
tconnectsync-check-output.log
ignore_*
.venv/
.vscode/
+14 -4
View File
@@ -7,14 +7,24 @@ verify_ssl = true
ptpython = "*"
[packages]
tconnectsync = {editable = true, path = "."}
requests = "*"
tconnectsync = {path = "."}
bs4 = "*"
arrow = "*"
arrow = "==1.2.3"
lxml = "*"
python-dotenv = "*"
python-dotenv = "==0.21.1"
requests-mock = "*"
pysocks = "*"
urllib3 = "==1.26.6"
requests = {extras = ["socks"], version = "==2.31.0"}
requests-oidc = "*"
PyJWT = "==2.8.0"
cryptography = "*"
dataclasses-json = "*"
cffi = ">=1.15.1"
typing-extensions = "*"
[scripts]
tconnectsync = "python3 main.py"
test = "python3 -m unittest discover -vv"
build_events = "bash -c 'cd tconnectsync/eventparser && python3 build_events.py > events.py'"
lint = "bash -c 'flake8 . --count --select=E9,F63,F7,F82 && flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 && echo PASS'"
Generated
+549 -112
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "413f8bbd66e702656262deda1b6586d42b5a2e9fa52f6d23416b1ea1efbc2591"
"sha256": "1d28c5104c37e390b88704d1453eec15e5c6df241ca85f82a88973f8aee2d1d1"
},
"pipfile-spec": 6,
"requires": {},
@@ -16,135 +16,546 @@
"default": {
"arrow": {
"hashes": [
"sha256:16fc29bbd9e425e3eb0fef3018297910a0f4568f21116fc31771e2760a50e074",
"sha256:8fb7d9d3d4bf90e49e734c22fa077bdd0964135c4b8120de2510575a8d1f620c"
"sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1",
"sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2"
],
"index": "pypi",
"version": "==1.2.0"
"markers": "python_version >= '3.6'",
"version": "==1.2.3"
},
"beautifulsoup4": {
"hashes": [
"sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf",
"sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"
"sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051",
"sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"
],
"markers": "python_version >= '3.1'",
"version": "==4.10.0"
"markers": "python_full_version >= '3.6.0'",
"version": "==4.12.3"
},
"bs4": {
"hashes": [
"sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"
"sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925",
"sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"
],
"index": "pypi",
"version": "==0.0.1"
"version": "==0.0.2"
},
"certifi": {
"hashes": [
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
"sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8",
"sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"
],
"version": "==2021.10.8"
"markers": "python_version >= '3.6'",
"version": "==2024.8.30"
},
"cffi": {
"hashes": [
"sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8",
"sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2",
"sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1",
"sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15",
"sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36",
"sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824",
"sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8",
"sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36",
"sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17",
"sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf",
"sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc",
"sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3",
"sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed",
"sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702",
"sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1",
"sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8",
"sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903",
"sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6",
"sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d",
"sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b",
"sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e",
"sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be",
"sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c",
"sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683",
"sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9",
"sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c",
"sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8",
"sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1",
"sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4",
"sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655",
"sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67",
"sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595",
"sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0",
"sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65",
"sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41",
"sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6",
"sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401",
"sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6",
"sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3",
"sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16",
"sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93",
"sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e",
"sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4",
"sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964",
"sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c",
"sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576",
"sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0",
"sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3",
"sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662",
"sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3",
"sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff",
"sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5",
"sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd",
"sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f",
"sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5",
"sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14",
"sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d",
"sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9",
"sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7",
"sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382",
"sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a",
"sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e",
"sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a",
"sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4",
"sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99",
"sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87",
"sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.17.1"
},
"charset-normalizer": {
"hashes": [
"sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0",
"sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"
"sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027",
"sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087",
"sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786",
"sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8",
"sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09",
"sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185",
"sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574",
"sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e",
"sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519",
"sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898",
"sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269",
"sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3",
"sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f",
"sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6",
"sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8",
"sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a",
"sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73",
"sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc",
"sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714",
"sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2",
"sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc",
"sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce",
"sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d",
"sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e",
"sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6",
"sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269",
"sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96",
"sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d",
"sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a",
"sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4",
"sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77",
"sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d",
"sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0",
"sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed",
"sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068",
"sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac",
"sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25",
"sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8",
"sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab",
"sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26",
"sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2",
"sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db",
"sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f",
"sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5",
"sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99",
"sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c",
"sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d",
"sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811",
"sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa",
"sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a",
"sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03",
"sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b",
"sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04",
"sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c",
"sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001",
"sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458",
"sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389",
"sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99",
"sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985",
"sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537",
"sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238",
"sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f",
"sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d",
"sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796",
"sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a",
"sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143",
"sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8",
"sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c",
"sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5",
"sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5",
"sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711",
"sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4",
"sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6",
"sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c",
"sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7",
"sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4",
"sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b",
"sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae",
"sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12",
"sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c",
"sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae",
"sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8",
"sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887",
"sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b",
"sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4",
"sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f",
"sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5",
"sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33",
"sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519",
"sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"
],
"markers": "python_version >= '3'",
"version": "==2.0.7"
"markers": "python_full_version >= '3.7.0'",
"version": "==3.3.2"
},
"click": {
"hashes": [
"sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28",
"sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"
],
"markers": "python_version >= '3.7'",
"version": "==8.1.7"
},
"cryptography": {
"hashes": [
"sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494",
"sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806",
"sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d",
"sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062",
"sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2",
"sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4",
"sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1",
"sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85",
"sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84",
"sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042",
"sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d",
"sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962",
"sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2",
"sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa",
"sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d",
"sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365",
"sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96",
"sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47",
"sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d",
"sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d",
"sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c",
"sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb",
"sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277",
"sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172",
"sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034",
"sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a",
"sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==43.0.1"
},
"dataclasses-json": {
"hashes": [
"sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a",
"sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"
],
"index": "pypi",
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==0.6.7"
},
"idna": {
"hashes": [
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
"sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
"sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
],
"markers": "python_version >= '3'",
"version": "==3.3"
"markers": "python_version >= '3.6'",
"version": "==3.10"
},
"lxml": {
"hashes": [
"sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d",
"sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3",
"sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2",
"sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae",
"sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f",
"sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927",
"sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3",
"sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7",
"sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59",
"sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f",
"sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade",
"sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96",
"sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468",
"sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b",
"sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4",
"sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354",
"sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83",
"sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04",
"sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16",
"sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4",
"sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791",
"sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a",
"sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51",
"sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1",
"sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a",
"sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f",
"sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee",
"sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec",
"sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969",
"sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28",
"sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a",
"sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa",
"sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106",
"sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d",
"sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d",
"sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617",
"sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4",
"sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92",
"sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0",
"sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4",
"sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24",
"sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2",
"sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e",
"sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0",
"sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654",
"sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2",
"sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23",
"sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"
"sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e",
"sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229",
"sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3",
"sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5",
"sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70",
"sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15",
"sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002",
"sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd",
"sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22",
"sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf",
"sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22",
"sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832",
"sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727",
"sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e",
"sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30",
"sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f",
"sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f",
"sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51",
"sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4",
"sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de",
"sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875",
"sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42",
"sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e",
"sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6",
"sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391",
"sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc",
"sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b",
"sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237",
"sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4",
"sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86",
"sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f",
"sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a",
"sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8",
"sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f",
"sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903",
"sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03",
"sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e",
"sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99",
"sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7",
"sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab",
"sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d",
"sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22",
"sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492",
"sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b",
"sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3",
"sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be",
"sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469",
"sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f",
"sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a",
"sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c",
"sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a",
"sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4",
"sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94",
"sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442",
"sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b",
"sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84",
"sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c",
"sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9",
"sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1",
"sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be",
"sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367",
"sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e",
"sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21",
"sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa",
"sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16",
"sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d",
"sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe",
"sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83",
"sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba",
"sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040",
"sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763",
"sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8",
"sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff",
"sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2",
"sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a",
"sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b",
"sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce",
"sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c",
"sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577",
"sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8",
"sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71",
"sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512",
"sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540",
"sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f",
"sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2",
"sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a",
"sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce",
"sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e",
"sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2",
"sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27",
"sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1",
"sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d",
"sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1",
"sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330",
"sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920",
"sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99",
"sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff",
"sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18",
"sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff",
"sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c",
"sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179",
"sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080",
"sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19",
"sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d",
"sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70",
"sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32",
"sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a",
"sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2",
"sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79",
"sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3",
"sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5",
"sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f",
"sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d",
"sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3",
"sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b",
"sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753",
"sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9",
"sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957",
"sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033",
"sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb",
"sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656",
"sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab",
"sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b",
"sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d",
"sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd",
"sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859",
"sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11",
"sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c",
"sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a",
"sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005",
"sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654",
"sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80",
"sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e",
"sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec",
"sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7",
"sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965",
"sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945",
"sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"
],
"index": "pypi",
"version": "==4.6.3"
"markers": "python_version >= '3.6'",
"version": "==5.3.0"
},
"marshmallow": {
"hashes": [
"sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e",
"sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9"
],
"markers": "python_version >= '3.8'",
"version": "==3.22.0"
},
"mypy-extensions": {
"hashes": [
"sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
"sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
],
"markers": "python_version >= '3.5'",
"version": "==1.0.0"
},
"oauthlib": {
"hashes": [
"sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca",
"sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"
],
"markers": "python_version >= '3.6'",
"version": "==3.2.2"
},
"packaging": {
"hashes": [
"sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
"sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
],
"markers": "python_version >= '3.8'",
"version": "==24.1"
},
"platformdirs": {
"hashes": [
"sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3",
"sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"
],
"markers": "python_version >= '3.7'",
"version": "==3.11.0"
},
"pycparser": {
"hashes": [
"sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6",
"sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"
],
"markers": "python_version >= '3.8'",
"version": "==2.22"
},
"pyjwt": {
"hashes": [
"sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de",
"sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==2.8.0"
},
"pypng": {
"hashes": [
"sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c",
"sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1"
],
"version": "==0.20220715.0"
},
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
],
"index": "pypi",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.7.1"
},
"python-dateutil": {
"hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.2"
"version": "==2.9.0.post0"
},
"python-dotenv": {
"hashes": [
"sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8",
"sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a"
"sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49",
"sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a"
],
"index": "pypi",
"version": "==0.19.1"
"markers": "python_version >= '3.7'",
"version": "==0.21.1"
},
"qrcode": {
"hashes": [
"sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a",
"sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845"
],
"markers": "python_version >= '3.7'",
"version": "==7.4.2"
},
"requests": {
"hashes": [
"sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
"sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
"extras": [
"socks"
],
"index": "pypi",
"version": "==2.26.0"
"hashes": [
"sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
"sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"
],
"markers": "python_version >= '3.7'",
"version": "==2.31.0"
},
"requests-mock": {
"hashes": [
"sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970",
"sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba"
"sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563",
"sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"
],
"index": "pypi",
"version": "==1.9.3"
"markers": "python_version >= '3.5'",
"version": "==1.12.1"
},
"requests-oauthlib": {
"hashes": [
"sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5",
"sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.3.1"
},
"requests-oidc": {
"hashes": [
"sha256:227fbcc561d0acb54469d62a7d4bde055a8eeee7968cdb98fa732e0d2313d814",
"sha256:84ea777457067f8f67fe25534b9c153f3f9a45336c83e073a93ce735a86406ca"
],
"index": "pypi",
"markers": "python_version >= '3.7' and python_version < '4'",
"version": "==0.6.2"
},
"six": {
"hashes": [
@@ -156,23 +567,48 @@
},
"soupsieve": {
"hashes": [
"sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc",
"sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"
"sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb",
"sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"
],
"markers": "python_version >= '3.6'",
"version": "==2.2.1"
"markers": "python_version >= '3.8'",
"version": "==2.6"
},
"tconnectsync": {
"editable": true,
"markers": "python_version >= '3.7'",
"path": "."
},
"typer": {
"hashes": [
"sha256:aa6c4a4e2329d868b80ecbaf16f807f2b54e192209d7ac9dd42691d63f7a54eb",
"sha256:f714c2d90afae3a7929fcd72a3abb08df305e1ff61719381384211c4070af57f"
],
"markers": "python_version >= '3.6'",
"version": "==0.9.4"
},
"typing-extensions": {
"hashes": [
"sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
"sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==4.12.2"
},
"typing-inspect": {
"hashes": [
"sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f",
"sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"
],
"version": "==0.9.0"
},
"urllib3": {
"hashes": [
"sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
"sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
"sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4",
"sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
],
"index": "pypi",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.7"
"version": "==1.26.6"
}
},
"develop": {
@@ -185,50 +621,51 @@
},
"jedi": {
"hashes": [
"sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93",
"sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"
"sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd",
"sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"
],
"markers": "python_version >= '3.6'",
"version": "==0.18.0"
"version": "==0.19.1"
},
"parso": {
"hashes": [
"sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398",
"sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"
"sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18",
"sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"
],
"markers": "python_version >= '3.6'",
"version": "==0.8.2"
"version": "==0.8.4"
},
"prompt-toolkit": {
"hashes": [
"sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c",
"sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c"
"sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90",
"sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"
],
"markers": "python_full_version >= '3.6.2'",
"version": "==3.0.20"
"markers": "python_full_version >= '3.7.0'",
"version": "==3.0.48"
},
"ptpython": {
"hashes": [
"sha256:99636899ab0e4d026e2ecc9368269114f387b4bb5411e57f072b0bde724d9f99",
"sha256:eafd4ced27ca5dc370881d4358d1ab5041b32d88d31af8e3c24167fe4af64ed6"
"sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402",
"sha256:b9d625183aef93a673fc32cbe1c1fcaf51412e7a4f19590521cdaccadf25186e"
],
"index": "pypi",
"version": "==3.0.20"
"markers": "python_version >= '3.7'",
"version": "==3.0.29"
},
"pygments": {
"hashes": [
"sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380",
"sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"
"sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199",
"sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"
],
"markers": "python_version >= '3.5'",
"version": "==2.10.0"
"markers": "python_version >= '3.8'",
"version": "==2.18.0"
},
"wcwidth": {
"hashes": [
"sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
"sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
"sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859",
"sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"
],
"version": "==0.2.5"
"version": "==0.2.13"
}
}
}
+146 -37
View File
@@ -3,52 +3,85 @@
![Python Package workflow](https://github.com/jwoglom/tconnectsync/actions/workflows/python-package.yml/badge.svg)
[![codecov](https://codecov.io/gh/jwoglom/tconnectsync/branch/master/graph/badge.svg)](https://codecov.io/gh/jwoglom/tconnectsync)
Tconnectsync synchronizes data one-way from the Tandem Diabetes t:connect web/mobile application to Nightscout.
Tconnectsync synchronizes data one-way from Tandem Source to Nightscout.
If you have a t:slim X2 pump with the companion t:connect mobile Android or iOS app, this will allow your pump bolus and basal data to be uploaded to [Nightscout](https://github.com/nightscout/cgm-remote-monitor) automatically. The t:connect Android app, by default, uploads pump data to Tandem's servers every hour, [but using this tool you can update the frequency to as low as every five minutes](https://github.com/jwoglom/tconnectpatcher)! This allows for nearly real-time (but not instantaneous) pump data updates, almost like your pump uploads data directly to Nightscout!
> [!IMPORTANT]
> Tandem has announced that t:connect will be shut down in favor of Tandem Source in the US beginning September 30, 2024.
> tconnectsync has undergone major changes to support Tandem Source. **For Tandem Source support, you MUST upgrade to tconnectsync version 2.0 or above.**
At a high level, tconnectsync works by querying Tandem's undocumented APIs to receive basal and bolus data from t:connect, and then uploads that data as treatment objects to Nightscout. It contains features for checking for new Tandem pump data continuously, and updating that data along with the pump's reported IOB value to Nightscout whenever there is new data.
If you have a t:slim X2 pump with the companion t:connect mobile Android or iOS app, this will allow your pump bolus and basal data to be uploaded to [Nightscout](https://github.com/nightscout/cgm-remote-monitor) automatically.
Together with a CGM uploader, such as [xDrip+](https://github.com/NightscoutFoundation/xDrip) or the official Dexcom mobile app plus Dexcom Share, this allows your CGM _and_ pump data to be automatically uploaded to Nightscout!
## How It Works
At a high level, tconnectsync works by querying Tandem's undocumented APIs to receive basal and bolus data from Tandem Source, and then uploads that data as treatment objects to Nightscout. It contains features for checking for new Tandem pump data continuously, and updating that data to Nightscout whenever there is new data.
When you run the program with no arguments, it performs a single cycle of the following, and exits after completion:
* Queries for basal information via the t:connect ControlIQ API.
* Queries for bolus, basal, and IOB data via the t:connect non-ControlIQ API.
* Merges the basal information received from the two APIs. (If using ControlIQ, then basal information appears only on the ControlIQ API. If not using ControlIQ, it appears only on the legacy API.)
* Queries Nightscout for the most recently created Temp Basal object by tconnectsync, and uploads all data newer than that.
* Queries Nightscout for the most recently created Bolus object by tconnectsync, and uploads all data newer than that.
* Uploads a single Nightscout Activity object representing the current IOB as reported by the pump.
* Logs in to Tandem Source
* Fetches your list of pumps, and unless overridden by an environment variable, fetches the event data for the pump which was most recently used
* Processes the internal pump event metadata to extract basal, bolus, CGM, and other pump event data
* Queries Nightscout to find the most recent data which was uploaded to it for each event category
* Uploads any missing data to Nightscout
If run with the `--auto-update` flag, then the application performs the following steps:
If run with the `--auto-update` flag, then the application periodically looks for new data and synchronizes it to Nightscout in a loop every few minutes.
* Queries an API endpoint used only by the t:connect mobile app which returns an internal event ID, corresponding to the most recent event published by the mobile app.
* Whenever the internal event ID changes (denoting that the mobile app uploaded new data to synchronize), perform all of the above mentioned steps to synchronize data.
## What Gets Synced
The following synchronization features are enabled by default:
Tconnectsync is composed of individual so-called _synchronization features_, which are elements of data that can be
synchronized between t:connect data from the pump and Nightscout.
When setting up tconnectsync, you can choose to configure which synchronization features are enabled and disabled.
Here are a few examples of reasons why you might want to adjust the enabled synchronization features:
* If you currently input boluses into Nightscout manually with comments, then you may wish to _disable the `BOLUS` synchronization feature_ so that there are no duplicated boluses in Nightscout.
* If you want to see Sleep and Exercise Mode data appear in Nightscout, then you may wish to _enable the `PUMP_EVENTS` synchronization feature_.
* If you want to automatically update your Nightscout insulin profile settings from your pump, then you may wish to _enable the `PROFILES` synchronization feature_.
These synchronization features are enabled by default:
* `BASAL`: Basal data
* `BOLUS`: Bolus data
* `IOB`: Insulin-on-board data. Only the most recent IOB entry is saved to Nightscout, as an "activity"
The following synchronization feature is disabled by default, but can be enabled via the `--features` flag:
* `PUMP_EVENTS`: Events reported by the pump. Includes support for the following:
* Site/Cartridge Change (occurs for both a site change and a cartridge change)
* Empty Cartridge/Pump Shutdown (from my investigation, occurs either when the cartridge runs out of insulin OR you hard-shut off the pump)
* User Suspended (occurs when you manually disable insulin delivery)
* Exercise Mode (in Nightscout, appears with a start and end time)
* Sleep Mode (in Nightscout, appears with a start and end time)
* Alarms, like cartridge out-of-insulin or pump malfunction
* Basal suspension (user or pump-initiated) and resume
* Cartridge, cannula, and tubing filled
* Sleep and exercise modes
* `PROFILES`: Insulin profile information, including segments, basal rates, correction factors, carb ratios, and the profile which is active.
The following synchronization features can be optionally enabled:
* `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 WILL DELIVER CGM DATA WITH A SIGNIFICANT (>30 MINUTE) LAG AND SHOULD NOT BE USED AS A REPLACEMENT FOR DEXCOM SHARE OR OTHER REAL TIME MONITORING.**
To specify custom synchronization features, pass the names of the desired features to the `--features` flag, e.g.:
```bash
$ tconnectsync --features BASAL BOLUS PUMP_EVENTS PROFILES
```
If you're using tconnectsync-heroku, see [this section in its README](https://github.com/jwoglom/tconnectsync-heroku#Updating-synchronization-features).
## Setup
**To get started,** you need to choose whether to install the application via
The following setup instructions assume that you have a Linux, MacOS, or Windows (with WSL) machine that will run the application continuously.
If you've configured Nightscout before, you may be familiar with Heroku. [You can opt to run tconnectsync with Heroku by following these instructions.](https://github.com/jwoglom/tconnectsync-heroku)
**To get started,** you need to choose whether to install the application on your computer via
**Pip**, **Pipenv**, or **Docker**.
After that, you can choose to run the program continuously via **Supervisord**
or on a regular interval with **Cron**.
**NOTE:** If you fork the tconnectsync repository on GitHub, **do not commit your .env file**.
If pushed to GitHub, this will make your tconnect and Nightscout passwords publicly visible and put your data at risk.
## Installation
First, create a file named `.env` containing configuration values.
First, you need to create a file containing configuration values.
The name of this file will be `.env`, and its location will be dependent on which
method of installation you choose.
You should specify the following parameters:
```bash
@@ -56,31 +89,57 @@ You should specify the following parameters:
TCONNECT_EMAIL='email@email.com'
TCONNECT_PASSWORD='password'
# Your pump's serial number (numeric)
PUMP_SERIAL_NUMBER=11111111
# OPTIONAL: Your region (US or EU)
TCONNECT_REGION=US
# URL and API secret for Nightscout
# URL of your Nightscout site
NS_URL='https://yournightscouturl/'
# Your Nightscout API_SECRET value
NS_SECRET='apisecret'
# Current timezone of the pump
TIMEZONE_NAME='America/New_York'
# OPTIONAL: Your pump's serial number (numeric)
PUMP_SERIAL_NUMBER=11111111
```
These values can alternatively be specified via environment variables.
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).
The .env 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).
(Alternatively, these values can be specified via environment variables.)
### Installation via Pip
This is the easiest method to install.
First, ensure that you have **Python 3** with **Pip** installed on your
Linux machine. Then, install tconnectsync from pip:
First, ensure that you have **Python 3** with **Pip** installed:
* **On MacOS:** Open Terminal. Install [Homebrew](https://brew.sh/), and then run `brew install python3`
* **On Linux:** Follow your distribution's specific instructions.
- For Debian/Ubuntu based distros, `sudo apt install python3 python3-pip`
- For CentOS/Rocky Linux 8:
- `sudo dnf install python39-pip`
- `sudo alternatives --set python /usr/bin/python3.9`
* **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:
```
$ pip3 install tconnectsync
```
To install into a user environment instead of system-wide for a more contained install:
````
$ 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.
After this, you should be able to view tconnectsync's help with:
```
@@ -105,7 +164,12 @@ optional arguments:
Specifies what data should be synchronized between tconnect and Nightscout.
```
Go to the folder where you created the `.env` file, and run:
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) OR `C:\Users\<username>\.config\tconnectsync` (native Windows)
```
$ tconnectsync --check-login
```
@@ -154,7 +218,8 @@ optional arguments:
```
Move the `.env` file you created earlier into this folder, and run:
Move the `.env` file you created earlier into the `tconnectsync` folder, and run:
```
$ pipenv run tconnectsync --check-login
```
@@ -172,6 +237,17 @@ $ docker pull ghcr.io/jwoglom/tconnectsync/tconnectsync:latest
$ docker run ghcr.io/jwoglom/tconnectsync/tconnectsync --help
```
Move the `.env` file you created earlier into the current folder, and run:
```
$ docker run tconnectsync --check-login
```
If you receive no errors, then you can move on to the **Running Tconnectsync Continuously** section.
#### Building Locally
To instead build the image locally and launch the project:
```bash
@@ -183,9 +259,11 @@ $ docker run tconnectsync --help
Move the `.env` file you created earlier into this folder, and run:
```
$ docker run tconnectsync --check-login
$ docker run --env-file=.env tconnectsync --check-login
```
**NOTE:** If using the `--env-file` option to `docker run`, you may need to remove all quotation marks (`'` and `"`s) around values in the `.env` file for Docker to propagate the variables correctly.
If you receive no errors, then you can move on to the **Running Tconnectsync Continuously** section.
## Running Tconnectsync Continuously
@@ -311,22 +389,41 @@ invoking tconnectsync with no arguments via cron.
If using Pipenv or a virtualenv, make sure that you either prefix the call to main.py with `pipenv run` or source the `bin/activate` file within the virtualenv, so that the proper dependencies are loaded. If not using any kind of virtualenv, you can instead just install the necessary dependencies as specified inside Pipfile globally.
An example configuration in `/etc/crontab` which runs every 15 minutes:
An system-wide example configuration in `/etc/crontab` which runs every 15 minutes, on the 15 minute mark:
```bash
# m h dom mon dow user command
0,15,30,45 * * * * root /path/to/tconnectsync/run.sh
```
An example of a user crontab `crontab -e` if not running system-wide, which runs every 15 minutes:
```
*/15 * * * * /path/to/tconnectsync/run.sh
```
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:
* [**controliq**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/controliq.py) - Contains Control:IQ related data, namely a timeline of all Basal events uploaded by the pump, separated by type (temp basals, algorithmically-updated basals, or profile-updated basals).
* [**controliq**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/controliq.py) - Contains Control:IQ related data, namely a timeline of all Basal events uploaded by the pump, separated by type (temp basals, algorithmically-updated basals, or profile-updated basals). Additionally includes CGM and Bolus data.
* [**android**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/android.py) - Used internally by the t:connect Android app, these API endpoints were discovered by reverse-engineering the Android app. Most of the API endpoints are used for uploading pump data, and tconnectsync uses one endpoint which returns the most recent event ID uploaded by the pump, so we know when more data has been uploaded.
* [**tconnectws2**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/ws2.py) - More legacy than the others, this seems to power the bulk of the main t:connect website. It is used to retrieve a CSV export of non-ControlIQ basal data, as well as bolus and IOB data. (I haven't found any mentions of bolus or IOB data in the Control:IQ-specific API.)
* [**tconnectws2**](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/api/ws2.py) - More legacy than the others, this seems to power the bulk of the main t:connect website. It is used as a last resort due to severe performance issues with this API (see https://github.com/jwoglom/tconnectsync/issues/43). We can use it to retrieve a CSV export of non-ControlIQ basal data, as well as bolus and IOB data. It is only used for bolus data as a fallback, and for pump-reported IOB data if requested. Full tracking of pump events also uses a limited version of this API.
I have only tested tconnectsync with a Tandem pump set in the US Eastern timezone. Tandem's (to us, undocumented) APIs are [a bit loose with timezones](https://github.com/jwoglom/tconnectsync/blob/master/tconnectsync/parser.py#L15), so please let me know if you notice any timezone-related bugs.
## Backfilling t:connect Data
@@ -340,3 +437,15 @@ python3 main.py --start-date 2020-01-01 --end-date 2020-03-01
In order to bulk-import a lot of data, you may need to use shorter intervals, and invoke tconnectsync multiple times. Tandem's API endpoints occasionally return invalid data if you request too large of a data window which causes tconnectsync to error out mid-way through.
One oddity when backfilling data is that the Control:IQ specific API endpoints return errors if they are queried before you updated your pump to utilize Control:IQ. This is [partially worked around in tconnectsync's code](https://github.com/jwoglom/tconnectsync/blob/d841c3811aeff3671d941a7d3ff4b80cce6a219e/main.py#L238), but you might need to update the logic if you did not switch to a Control:IQ enabled pump immediately after launch.
## t:connect API Testing
To test t:connect API endpoints in a Python shell, you can do something like the following:
```python
import tconnectsync
tconnectsync.util.cli.enable_logging()
api = tconnectsync.util.cli.get_api()
# Make API calls, e.g.
therapy_timeline = api.controliq.therapy_timeline('2022-08-01', '2022-08-10')
```
+15 -5
View File
@@ -1,9 +1,9 @@
[metadata]
name = tconnectsync
version = 0.6
author = James Woglom
version = 2.3.0
author = James Woglom
author_email = j@wogloms.net
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
description = Syncs Tandem Source (formerly t:connect) insulin pump data to Nightscout for the t:slim X2
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/jwoglom/tconnectsync
@@ -18,13 +18,23 @@ classifiers =
package_dir =
= .
packages = find:
python_requires = >=3.6
python_requires = >=3.7
install_requires =
requests
bs4
arrow
lxml
python-dotenv
requests-mock
pysocks
urllib3
requests
requests-oidc
PyJWT
cryptography
dataclasses-json
cffi
typing-extensions
[options.packages.find]
where = .
@@ -34,4 +44,4 @@ exclude =
[options.entry_points]
console_scripts =
tconnectsync = tconnectsync:main
tconnectsync = tconnectsync:main
+52 -13
View File
@@ -4,10 +4,17 @@ import arrow
import argparse
import logging
import pkg_resources
import typing
# Required for cryptography lib in python 3.7
if sys.version_info < (3, 8):
import typing_extensions
typing.Protocol = typing_extensions.Protocol
from .api import TConnectApi
from .process import process_time_range
from .autoupdate import process_auto_update
from .sync.tandemsource.autoupdate import TandemSourceAutoupdate
from .sync.tandemsource.choose_device import ChooseDevice as TandemSourceChooseDevice
from .sync.tandemsource.process import ProcessTimeRange as TandemSourceProcessTimeRange
from .check import check_login
from .nightscout import NightscoutApi
from .features import DEFAULT_FEATURES, ALL_FEATURES
@@ -16,11 +23,16 @@ try:
from .secret import (
TCONNECT_EMAIL,
TCONNECT_PASSWORD,
TCONNECT_REGION,
NS_URL,
NS_SECRET
NS_SECRET,
NS_SKIP_TLS_VERIFY,
PUMP_SERIAL_NUMBER,
NS_IGNORE_CONN_ERRORS
)
except Exception:
print('Unable to read secret.py')
from . import secret
except Exception as e:
print('Unable to read secrets from secret.py', e)
sys.exit(1)
@@ -30,7 +42,7 @@ except Exception:
__version__ = "UNKNOWN"
def parse_args(*args, **kwargs):
parser = argparse.ArgumentParser(description="Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.")
parser = argparse.ArgumentParser(description="Syncs bolus, basal, and IOB data from Tandem Diabetes t:connect to Nightscout.", epilog="Version %s" % __version__)
parser.add_argument('--version', action='version', version='tconnectsync %s' % __version__)
parser.add_argument('--pretend', dest='pretend', action='store_const', const=True, default=False, help='Pretend mode: do not upload any data to Nightscout.')
parser.add_argument('-v', '--verbose', dest='verbose', action='store_const', const=True, default=False, help='Verbose mode: show extra logging details')
@@ -40,6 +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=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)
@@ -71,20 +85,45 @@ def main(*args, **kwargs):
if time_end < time_start:
raise Exception('time_start must be before time_end')
tconnect = TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD)
# Determine region: command line arg takes precedence, then config, then default to US
region = args.region if args.region else TCONNECT_REGION
nightscout = NightscoutApi(NS_URL, NS_SECRET)
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':
if args.tandem_source:
secret.PUMP_SERIAL_NUMBER = None
else:
logging.warn('NO PUMP SERIAL NUMBER WAS PROVIDED. Ensure you have set PUMP_SERIAL_NUMBER appropriately.')
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)
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:
args.pretend = True
if args.auto_update:
print("Starting auto-update between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
process_auto_update(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features)
u = TandemSourceAutoupdate(secret)
sys.exit(u.process(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features))
else:
print("Processing data between", time_start, "and", time_end, "(PRETEND)" if args.pretend else "")
added = process_time_range(tconnect, nightscout, time_start, time_end, args.pretend, features=args.features)
print("Added", added, "items")
tconnectDevice = TandemSourceChooseDevice(secret, tconnect).choose()
added, last_event_id = TandemSourceProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend=args.pretend, secret=secret, features=args.features).process(time_start, time_end)
# return exit code 0 if processed events
sys.exit(0 if added>0 else 1)
+4
View File
@@ -0,0 +1,4 @@
from . import main
if __name__ == "__main__":
main()
+29 -5
View File
@@ -3,6 +3,8 @@ import logging
from .android import AndroidApi
from .controliq import ControlIQApi
from .ws2 import WS2Api
from .webui import WebUIScraper
from .tandemsource import TandemSourceApi
logger = logging.getLogger(__name__)
@@ -11,13 +13,25 @@ class TConnectApi:
email = None
password = None
_ciq = None
_ws2 = None
_android = 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
self._webui = None
self._tandemsource = None
@property
def tandemsource(self):
if self._tandemsource and not self._tandemsource.needs_relogin():
return self._tandemsource
logger.debug(f"Instantiating new TandemSourceApi for region {self.region}")
self._tandemsource = TandemSourceApi(self.email, self.password, self.region)
return self._tandemsource
@property
@@ -53,4 +67,14 @@ class TConnectApi:
self._android = AndroidApi(self.email, self.password)
return self._android
@property
def webui(self):
if self._webui and not self._webui.needs_relogin():
return self._webui
logger.debug("Instantiating new WebUIScraper")
self._webui = WebUIScraper(self.controliq)
return self._webui
+28 -22
View File
@@ -11,7 +11,7 @@ import logging
from bs4 import BeautifulSoup
from ..util import timeago
from .common import ApiException, ApiLoginException, parse_date
from .common import ApiException, ApiLoginException, parse_date, base_session
logger = logging.getLogger(__name__)
@@ -29,6 +29,12 @@ class AndroidApi:
ANDROID_API_USERNAME = base64.b64decode('QzIzMzFDRDYtRDQ1MC00OTVFLTlDMTktNjcyMTUyMzBDODVD').decode()
ANDROID_API_PASSWORD = base64.b64decode('dHo0MzNLVzVRREM5VjdmIXo2QF4ybyZZNlNHR1lo').decode()
# These credentials are used by tconnect web
TCONNECT_WEB_USERNAME = base64.b64decode('M0U2MzU3QkEtRjYyNS00REQyLUI2NUYtNEI1RTgxNDRBQTZG').decode()
TCONNECT_WEB_PASSWORD = base64.b64decode('cUMyaXFIc2w3OFFoR0RYdCpMenFwb1pxZTl3eHN6').decode()
ANDROID_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 12; Pixel 4a Build/SP2A.220305.012)'
# These tokens are separate from the "standard" tdcservices API
accessToken = None
accessTokenExpiresAt = None
@@ -38,12 +44,13 @@ class AndroidApi:
patientObjectId = None
def __init__(self, email, password):
self.session = base_session()
self.login(email, password)
self._email = email
self._password = password
def login(self, email, password):
r = requests.post(
r = self.session.post(
self.BASE_URL + self.OAUTH_TOKEN_PATH,
{
'username': email,
@@ -51,7 +58,10 @@ class AndroidApi:
'grant_type': 'password',
'scope': self.OAUTH_SCOPES
},
headers={'Content-Type': 'application/x-www-form-urlencoded'},
headers={
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent': self.ANDROID_USER_AGENT
},
auth=requests.auth.HTTPBasicAuth(self.ANDROID_API_USERNAME, self.ANDROID_API_PASSWORD)
)
@@ -59,15 +69,17 @@ class AndroidApi:
raise ApiLoginException(r.status_code, 'Received HTTP %s during login: %s' % (r.status_code, r.text))
j = r.json()
if "user" not in j or not j["user"]:
raise ApiException(r.status_code, 'No user details present in AndroidApi oauth response: %s' % r.text)
# tconnect web returns a null user
# if "user" not in j or not j["user"]:
# raise ApiException(r.status_code, 'No user details present in AndroidApi oauth response: %s' % r.text)
self.accessToken = j["accessToken"]
self.accessTokenExpiresAt = j["accessTokenExpiresAt"]
# NOTE: the refresh token is currently unused, instead a new access
# token is obtained from scratch by re-logging in when it expires.
self.refreshToken = j["refreshToken"]
self.refreshTokenExpiresAt = j["refreshTokenExpiresAt"]
if "refreshToken" in j and "refreshTokenExpiresAt" in j:
self.refreshToken = j["refreshToken"]
self.refreshTokenExpiresAt = j["refreshTokenExpiresAt"]
self.userId = j["user"]["id"]
logger.info("Logged in to AndroidApi successfully (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
@@ -82,7 +94,11 @@ class AndroidApi:
return {'Authorization': 'Bearer %s' % self.accessToken}
def _get(self, endpoint, query={}, **kwargs):
r = requests.get(self.BASE_URL + endpoint, query, headers=self.api_headers(), **kwargs)
r = self.session.get(self.BASE_URL + endpoint, data=query, headers={
'User-Agent': self.ANDROID_USER_AGENT,
'Content-Type': 'application/json',
**self.api_headers()
}, **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "Android API HTTP %s response: %s" % (str(r.status_code), r.text))
@@ -109,7 +125,7 @@ class AndroidApi:
def post(self, endpoint, query={}, **kwargs):
r = requests.post(self.BASE_URL + endpoint, query, headers=self.api_headers(), **kwargs)
r = self.session.post(self.BASE_URL + endpoint, query, headers=self.api_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "Internal API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
@@ -134,6 +150,9 @@ class AndroidApi:
# TODO: these methods are used in the web app, not the Android app,
# but support the same auth tokens and are on this domain. They should
# be moved to a new Api class.
# 3/17/2022: the API appears to be more stringently checking scopes,
# and some of these endpoints no longer work with the API token scoped
# to the Android app.
"""
Returns BG and pump threshold values.
@@ -154,16 +173,3 @@ class AndroidApi:
"""
def user_profile(self):
return self.get('cloud/usersettings/api/UserProfile?userId=%s' % self.userId)
"""
Returns therapy events, used by the webui Therapy Timeline.
{'event': [
{'type': 'Basal', 'basalRate': ...},
{'type': 'Bolus', 'standard': ...},
{'type': 'CGM', 'egv': ...}
]}
"""
def therapy_events(self, start_date=None, end_date=None):
startDate = parse_date(start_date)
endDate = parse_date(end_date)
return self.get('tconnect/therapyevents/api/TherapyEvents/%s/%s/false?userId=%s' % (startDate, endDate, self.userId))
+123 -2
View File
@@ -1,17 +1,138 @@
import datetime
from typing import List, Tuple
import requests
import random
import arrow
from tconnectsync import secret
def parse_date(date):
if type(date) == str:
return date
return (date or datetime.datetime.now()).strftime('%m-%d-%Y')
def parse_ymd_date(date):
if type(date) == str:
date = arrow.get(date)
return (date or datetime.datetime.now()).strftime('%Y-%m-%d')
def parsed_date_to_arrow(date):
return arrow.get(datetime.datetime.strptime(date, '%m-%d-%Y'))
USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.115 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:100.0) Gecko/20100101 Firefox/100.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:101.0) Gecko/20100101 Firefox/101.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 Firefox/102.0',
'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36 OPR/86.0.4363.64',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36 OPR/86.0.4363.70',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36 Edg/101.0.1210.47',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36 Edg/101.0.1210.53',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36 OPR/87.0.4390.45',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.115 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.124 Safari/537.36 Edg/102.0.1245.41',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.124 Safari/537.36 Edg/102.0.1245.44',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.62 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36 Edg/102.0.1245.30',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36 Edg/102.0.1245.33',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36 Edg/102.0.1245.39',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.84 Safari/537.36 OPR/85.0.4341.71',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0',
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0',
'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.115 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0',
'Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0',
'Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0',
'Mozilla/5.0 (X11; Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
'Mozilla/5.0 (X11; Linux x86_64; rv:99.0) Gecko/20100101 Firefox/99.0',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0'
]
# Consistent for entire runtime
random_ua = random.choice(USER_AGENTS)
def base_headers():
return {'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36'}
return {'user-agent': random_ua}
def base_session():
s = requests.Session()
if secret.REQUESTS_PROXY:
def wrapped_request(self, *args, **kwargs):
if not kwargs:
kwargs = {}
kwargs['proxies'] = {
'http': secret.REQUESTS_PROXY,
'https': secret.REQUESTS_PROXY
}
return self._original_request(*args, **kwargs)
s._original_request = s.request
s.request = wrapped_request.__get__(s, requests.Session)
return s
def days_between(start, end) -> int:
diff = arrow.get(end) - arrow.get(start)
return diff.days
# both inclusive
def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[str, str]]:
ranges = []
start = arrow.get(start_a)
end = arrow.get(end_a)
cur_s = start
cur = start
while cur <= end:
if (cur - cur_s).days >= days-1:
ranges.append((cur_s, cur))
cur_s = cur + datetime.timedelta(days=1)
cur += datetime.timedelta(days=1)
if len(ranges) > 0 and (end - ranges[-1][-1]).days > 0:
ranges.append((cur_s, end))
return ranges
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
+92 -21
View File
@@ -1,24 +1,25 @@
import requests
import urllib
import datetime
import arrow
import time
import logging
from bs4 import BeautifulSoup
from ..util import timeago
from .common import parse_date, base_headers, ApiException, ApiLoginException
from ..util import timeago, cap_length
from .common import parse_date, base_headers, base_session, ApiException, ApiLoginException
logger = logging.getLogger(__name__)
class ControlIQApi:
BASE_URL = 'https://tdcservices.tandemdiabetes.com/tconnect/controliq/api/'
BASE_URL = 'https://tdcservices.tandemdiabetes.com/'
LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f'
LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.17.2.3'
userGuid = None
accessToken = None
accessTokenExpiresAt = None
tconnect_software_ver = None
def __init__(self, email, password):
self.login(email, password)
@@ -27,27 +28,71 @@ class ControlIQApi:
def login(self, email, password):
logger.info("Logging in to ControlIQApi...")
with requests.Session() as s:
with base_session() as s:
initial = s.get(self.LOGIN_URL, headers=base_headers())
soup = BeautifulSoup(initial.content, features='lxml')
data = self._build_login_data(email, password, soup)
req = s.post(self.LOGIN_URL, data=data, headers={'Referer': self.LOGIN_URL, **base_headers()}, allow_redirects=False)
# HTTP 200 is reported when credentials are incorrect
if req.status_code == 200:
login_error = self._find_login_error(req.text)
if not login_error:
login_error = 'Check your login credentials.'
raise ApiLoginException(None, 'Error logging in to t:connect: %s' % login_error)
if req.status_code != 302:
raise ApiLoginException(req.status_code, 'Error logging in to t:connect. 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']
self.accessToken = req.cookies['accessToken']
self.accessTokenExpiresAt = req.cookies['accessTokenExpiresAt']
logger.info("Logged in to ControlIQApi successfully (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
if 'accessToken' in req.cookies and 'accessTokenExpiresAt' in req.cookies:
self.accessToken = req.cookies['accessToken']
self.accessTokenExpiresAt = req.cookies['accessTokenExpiresAt']
logger.info("Logged in to ControlIQApi successfully via accessToken cookie (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
else:
logger.info("No accessToken cookie found when logging in to ControlIQApi. Triggering AndroidApi auth")
from .android import AndroidApi
android = AndroidApi(email, password)
self.accessToken = android.accessToken
self.accessTokenExpiresAt = android.accessTokenExpiresAt
logger.info("Logged in to AndroidApi successfully via accessToken param (expiration: %s, %s)" % (self.accessTokenExpiresAt, timeago(self.accessTokenExpiresAt)))
self.loginSession = s
return True
def _build_login_data(self, email, password, soup):
try:
version = soup.select_one("#footer_version").text.strip()
self.tconnect_software_ver = version
logger.info("Reported tconnect software version: %s" % version)
if version != self.LAST_CONFIRMED_SOFTWARE_VERSION:
logger.warning("Newer API version than last confirmed working. Saw %s and expected %s" % (version, self.LAST_CONFIRMED_SOFTWARE_VERSION))
logger.warning("If you experience any issues, please report them to https://github.com/jwoglom/tconnectsync")
except Exception:
logger.warning("Unable to find tconnect software version.")
contents = "<unknown>"
if soup:
contents = "%s" % soup.encode_contents()
if len(contents) > 1000:
contents = "%s[SNIP]%s" % (contents[:500], contents[-500:])
logger.info("BeautifulSoup parsed contents: %s" % contents)
pass
if not soup.select_one("#__VIEWSTATE"):
enc_contents = str(soup.encode_contents())
if "Web Page Blocked!" in enc_contents or "Attack ID:" in enc_contents:
logger.warn("Being ratelimited/blocked by web application firewall. Sleeping for 30 minutes before retrying.")
logger.info("BeautifulSoup parsed contents: %s" % enc_contents)
time.sleep(1800)
exit(1)
return {
"__LASTFOCUS": "",
"__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin",
@@ -61,17 +106,33 @@ 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):
if not self.accessTokenExpiresAt:
return False
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
if not self.accessToken:
raise Exception('No access token provided')
return {'Authorization': 'Bearer %s' % self.accessToken, **base_headers()}
return {
'Authorization': 'Bearer %s' % self.accessToken,
'Origin': 'https://tconnect.tandemdiabetes.com',
'Referer': 'https://tconnect.tandemdiabetes.com/',
**base_headers()
}
def _get(self, endpoint, query):
r = requests.get(self.BASE_URL + endpoint, query, headers=self.api_headers())
r = base_session().get(self.BASE_URL + endpoint, data=query, headers=self.api_headers())
if r.status_code != 200:
raise ApiException(r.status_code, "ControlIQ API HTTP %s response: %s" % (str(r.status_code), r.text))
@@ -101,15 +162,15 @@ class ControlIQApi:
"""
Returns detailed basal event information and reasons for delivery suspension.
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
"""
def therapy_timeline(self, start=None, end=None):
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('therapytimeline/users/%s' % (self.userGuid), {
"startDate": startDate,
"endDate": endDate
})
# Microsoft-Azure-Application-Gateway/v2 WAF error message appears
# if startDate and endDate are not specified in exactly this order.
return self.get('tconnect/controliq/api/therapytimeline/users/%s?startDate=%s&endDate=%s' % (self.userGuid, startDate, endDate), {})
"""
Returns a summary of pump and cgm activity.
@@ -122,14 +183,24 @@ class ControlIQApi:
startDate = parse_date(start)
endDate = parse_date(end)
return self.get('summary/users/%s' % (self.userGuid), {
"startDate": startDate,
"endDate": endDate
})
return self.get('tconnect/controliq/api/summary/users/%s?startDate=%s&endDate=%s' % (self.userGuid, startDate, endDate), {})
"""
Returns active account features, including the date when ControlIQ was enabled.
[{"serialNumber": "11111111", "features": {"controlIQ": {"feature": 1, "dateTimeFirstDetected": "YYYY-MM-DD:THH:MM:SS", "unixTimestamp": 1111111111}}}]
"""
def pumpfeatures(self):
return self.get('pumpfeatures/users/%s' % self.userGuid, {})
return self.get('tconnect/controliq/api/pumpfeatures/users/%s' % self.userGuid, {})
"""
Returns therapy events, used by the webui Therapy Timeline.
{'event': [
{'type': 'Basal', 'basalRate': ...},
{'type': 'Bolus', 'standard': ...},
{'type': 'CGM', 'egv': ...}
]}
"""
def therapy_events(self, start_date=None, end_date=None):
startDate = parse_date(start_date)
endDate = parse_date(end_date)
return self.get('tconnect/therapyevents/api/TherapyEvents/%s/%s/false?userId=%s' % (startDate, endDate, self.userGuid), {})
+455
View File
@@ -0,0 +1,455 @@
import urllib
import arrow
import time
import logging
import json
import base64
import hashlib
import os
import jwt
import pickle
from requests_oidc import make_auth_code_session
from requests_oidc.plugins import OSCachedPlugin
from requests_oidc.utils import ServerDetails
from requests_oauthlib import OAuth2Session
from jwt.algorithms import RSAAlgorithm
from ..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/'
TDC_AUTH_CALLBACK_URL = 'https://sso.tandemdiabetes.com/auth/callback'
# 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, 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(f"Logging in to TandemSourceApi ({self.region} region)...")
if self.try_load_cached_creds(email):
logger.info("Successfully used cached credentials")
return True
with base_session() as s:
initial = s.get(self.LOGIN_PAGE_URL, headers=base_headers())
data = {
"username": email,
"password": password
}
req = s.post(self.LOGIN_API_URL, json=data, headers={'Referer': self.LOGIN_PAGE_URL, **base_headers()}, allow_redirects=False)
logger.debug("1. made POST to LOGIN_API")
# {"redirectUrl":"/","status":"SUCCESS"}
if req.status_code != 200:
raise ApiException(req.status_code, 'Error sending POST to login_api_url: %s' % req.text)
req_json = req.json()
login_ok = req_json.get('status', '') == 'SUCCESS'
if not login_ok:
raise ApiException(req.status_code, 'Error parsing login_api_url: %s' % json.dumps(req_json))
logger.debug("2. starting OIDC")
# oidc
client_id = self.TDC_OIDC_CLIENT_ID
redirect_uri = self._region_urls['REDIRECT_URI']
scope = 'openid profile email'
token_endpoint = self._region_urls['TOKEN_ENDPOINT']
def generate_code_verifier():
"""Generates a high-entropy code verifier."""
code_verifier = base64.urlsafe_b64encode(os.urandom(64)).decode('utf-8').rstrip('=')
return code_verifier
def generate_code_challenge(verifier):
"""Generates a code challenge from the code verifier."""
sha256_digest = hashlib.sha256(verifier.encode('utf-8')).digest()
code_challenge = base64.urlsafe_b64encode(sha256_digest).decode('utf-8').rstrip('=')
return code_challenge
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
authorization_endpoint = self._region_urls['AUTHORIZATION_ENDPOINT']
oidc_step1_params = {
'client_id': client_id,
'response_type': 'code',
'scope': scope,
'redirect_uri': redirect_uri,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
}
logger.debug("3. calling oidc_step1 with %s" % json.dumps(oidc_step1_params))
oidc_step1 = s.get(
authorization_endpoint + '?' + urllib.parse.urlencode(oidc_step1_params),
headers={'Referer': self.LOGIN_PAGE_URL, **base_headers()},
allow_redirects=True
)
if oidc_step1.status_code // 100 != 2:
raise ApiException(oidc_step1.status_code, 'Got unexpected status code for oidc step1: %s' % oidc_step1.text)
oidc_step1_loc = oidc_step1.url
oidc_step1_query = urllib.parse.parse_qs(urllib.parse.urlparse(oidc_step1_loc).query)
if 'code' not in oidc_step1_query:
raise ApiException(oidc_step1.status_code, 'No code for oidc step1 ReturnUrl (%s): %s' % (oidc_step1_loc, json.dumps(oidc_step1_query)))
oidc_step1_callback_code = oidc_step1_query['code'][0]
oidc_step2_token_data = {
'grant_type': 'authorization_code',
'client_id': client_id,
'code': oidc_step1_callback_code,
'redirect_uri': redirect_uri,
'code_verifier': code_verifier,
}
logger.debug("4. calling oidc_step2 with %s" % json.dumps(oidc_step2_token_data))
oidc_step2 = s.post(token_endpoint, data=oidc_step2_token_data, headers={
'Content-Type': 'application/x-www-form-urlencoded',
**base_headers()
})
if oidc_step2.status_code//100 != 2:
raise ApiException(oidc_step1.status_code, 'Got unexpected status code for oidc step2: %s' % oidc_step1.text)
oidc_json = oidc_step2.json()
logger.debug("5. parsing oidc_step2 json response: %s" % json.dumps(oidc_json))
if not 'access_token' in oidc_json:
raise ApiException(oidc_step1.status_code, 'Missing access_token in oidc_step2 json: %s' % json.dumps(oidc_json))
if not 'id_token' in oidc_json:
raise ApiException(oidc_step1.status_code, 'Missing id_token in oidc_step2 json: %s' % json.dumps(oidc_json))
self.loginSession = s
self.idToken = oidc_json['id_token']
self.extract_jwt()
self.accessToken = oidc_json['access_token']
self.accessTokenExpiresAt = arrow.get(arrow.get().int_timestamp + oidc_json['expires_in'])
self.cache_creds(email)
return True
def extract_jwt(self):
logger.debug("6. extracting JWT from %s" % self.idToken)
id_token = self.idToken
jwks_response = self.loginSession.get(self.TDC_OIDC_JWKS_URL)
jwks = jwks_response.json()
public_keys = {}
for jwk in jwks['keys']:
kid = jwk['kid']
public_keys[kid] = RSAAlgorithm.from_jwk(json.dumps(jwk))
# Get the key ID (kid) from the headers of the ID Token
unverified_header = jwt.get_unverified_header(id_token)
kid = unverified_header['kid']
key = public_keys.get(kid)
if not key:
raise ApiException(0, 'Public key not found for JWT: %s' % kid)
audience = self.TDC_OIDC_CLIENT_ID
issuer = self.TDC_OIDC_ISSUER
# Decode and verify the ID Token
id_token_claims = jwt.decode(
id_token,
key=key,
algorithms=['RS256'],
audience=audience,
issuer=issuer,
)
logger.info("Decoded JWT: %s" % json.dumps(id_token_claims))
self.jwtData = id_token_claims
self.pumperId = id_token_claims['pumperId']
self.accountId = id_token_claims['accountId']
def try_load_cached_creds(self, email):
if not CACHE_CREDENTIALS:
return False
if not os.path.exists(CACHE_CREDENTIALS_PATH):
logger.info("No cached credentials exist")
return False
_saved_blob = {}
try:
with open(CACHE_CREDENTIALS_PATH, 'rb') as f:
_saved_blob = pickle.load(f)
except Exception as e:
logger.warning(f"Could not load cached credentials at {CACHE_CREDENTIALS_PATH}: {e}")
return False
if not _saved_blob:
logger.warning(f"Could not load cached credentials at {CACHE_CREDENTIALS_PATH}: empty dict")
return False
if _saved_blob.get('cache_creds_version') != 1.0:
logger.warning(f"Unexpected cache_creds_version at {CACHE_CREDENTIALS_PATH}: {_saved_blob['cache_creds_version']}, expected 1.0")
return False
if _saved_blob.get('cache_creds_email') != email:
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")
return False
self.jwtData = _saved_blob['jwtData']
self.pumperId = _saved_blob['pumperId']
self.accountId = _saved_blob['accountId']
self.idToken = _saved_blob['idToken']
self.accessToken = _saved_blob['accessToken']
self.accessTokenExpiresAt = _saved_blob['accessTokenExpiresAt']
self.loginSession = _saved_blob['loginSession']
def est_time(t):
now = arrow.get()
if now < t:
sec = (t - now).seconds
else:
sec = (now - t).seconds
min = sec//60
hr = min//60
min = min % 60
sec = sec % 60
r = ''
if hr:
r += f'{hr} hr '
if min:
r += f'{min} min '
if sec:
r += f'{sec} sec '
if not r:
return 'now'
elif now < t:
return 'in '+r.strip()
else:
return r.strip()+' ago'
sa = _saved_blob['cache_creds_saved_at']
ex = _saved_blob['accessTokenExpiresAt']
logger.info(f"Loaded cached credentials from {CACHE_CREDENTIALS_PATH}: saved at {sa} ({est_time(sa)}), access token expiry {ex} ({est_time(ex)})")
return True
def cache_creds(self, email):
if not CACHE_CREDENTIALS:
logger.info("Credentials caching is disabled, skipping save")
return
_saved_blob = {
'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,
'idToken': self.idToken,
'accessToken': self.accessToken,
'accessTokenExpiresAt': self.accessTokenExpiresAt,
'loginSession': self.loginSession
}
if not os.path.exists(CACHE_CREDENTIALS_PATH):
mkdir = os.path.dirname(CACHE_CREDENTIALS_PATH)
logger.debug(f"Running mkdir on {mkdir}")
os.makedirs(mkdir, exist_ok=True)
with open(CACHE_CREDENTIALS_PATH, 'wb') as f:
pickle.dump(_saved_blob, f)
logger.info(f"Saved cached credentials to {CACHE_CREDENTIALS_PATH}")
def needs_relogin(self):
if not self.accessTokenExpiresAt:
return False
diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get())
return (diff.seconds <= 5 * 60)
def api_headers(self):
if not self.accessToken:
raise Exception('No access token provided')
return {
'Authorization': 'Bearer %s' % self.accessToken,
'Origin': 'https://tconnect.tandemdiabetes.com',
'Referer': 'https://tconnect.tandemdiabetes.com/',
**base_headers()
}
def _get(self, endpoint, query):
r = base_session().get(self.SOURCE_URL + endpoint, data=query, headers=self.api_headers())
if r.status_code != 200:
raise ApiException(r.status_code, "TandemSourceApi HTTP %s response: %s" % (str(r.status_code), r.text))
return r.json()
def get(self, endpoint, query, tries=0):
try:
return self._get(endpoint, query)
except ApiException as e:
logger.warning("Received ApiException in TandemSourceApi with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
if tries > 0:
raise ApiException(e.status_code, "TandemSourceApi HTTP %d on retry #%d: %s", e.status_code, tries, e)
# Trigger automatic re-login, and try again once
if e.status_code == 401:
logger.info("Performing automatic re-login after HTTP 401 for TandemSourceApi")
self.accessTokenExpiresAt = time.time()
self.login(self._email, self._password)
return self.get(endpoint, query, tries=tries+1)
if e.status_code == 500:
return self.get(endpoint, query, tries=tries+1)
raise e
"""
Returns information about the user and available pumps.
"""
def pumper_info(self):
return self.get('api/pumpers/pumpers/%s' % (self.pumperId), {})
"""
Returns metadata for pump events. Returns a list of dict's per-pump on the account.
[
{'tconnectDeviceId', 'serialNumber', 'modelNumber', 'minDateWithEvents', 'maxDateWithEvents', 'lastUpload', 'patientName', 'patientDateOfBirth', 'patientCareGiver', 'softwareVersion', 'partNumber'},
]
"""
def pump_event_metadata(self):
return self.get('api/reports/reportsfacade/%s/pumpeventmetadata' % (self.pumperId), {})
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, event_ids_filter=DEFAULT_EVENT_IDS):
minDate = parse_ymd_date(min_date)
maxDate = parse_ymd_date(max_date)
logger.debug(f'pump_events_raw({tconnect_device_id}, {minDate}, {maxDate})')
# default: 229,5,28,4,26,99,279,3,16,59,21,55,20,280,64,65,66,61,33,371,171,369,460,172,370,461,372,399,256,213,406,394,212,404,214,405,447,313,60,14,6,90,230,140,12,11,53,13,63,203,307,191
eventIdsFilter = '%2C'.join(map(str, event_ids_filter)) if event_ids_filter else None
return self.get('api/reports/reportsfacade/pumpevents/%s/%s?minDate=%s&maxDate=%s%s' % (
self.pumperId,
tconnect_device_id,
minDate,
maxDate,
'&eventIds=%s' % eventIdsFilter if eventIdsFilter else ''
), {})
"""
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)
+296
View File
@@ -0,0 +1,296 @@
from typing import Dict, List, Tuple
import requests
import urllib
import datetime
import arrow
import time
import logging
from bs4 import BeautifulSoup
from tconnectsync.domain.device_settings import Device, Profile, ProfileSegment, DeviceSettings
from tconnectsync.util import removesuffix, removeprefix
from tconnectsync.util.constants import MMOLL_TO_MGDL
from .common import base_headers, ApiException
logger = logging.getLogger(__name__)
"""
WebUIScraper contains data that is scraped from the t:connect Web UI and is
not accessible via any known API.
"""
class WebUIScraper:
BASE_URL = "https://tconnect.tandemdiabetes.com/"
def __init__(self, controliq):
self.controliq = controliq
def needs_relogin(self):
return self.controliq.needs_relogin()
def _get(self, endpoint):
r = self.controliq.loginSession.get(self.BASE_URL + endpoint, headers=base_headers(), allow_redirects=True)
if r.status_code != 200:
raise ApiException(r.status_code, "WebUIScraper HTTP %s response: %s" % (str(r.status_code), r.text))
if 'login.aspx' in r.url:
raise ApiException(401, "WebUIScraper HTTP %s response for login page, returning 401: %s" % (str(r.status_code), r.url))
return r
def get(self, endpoint, tries=0):
try:
return self._get(endpoint)
except ApiException as e:
logger.warning("Received ApiException in WebUIScraper with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
if tries > 0:
raise ApiException(e.status_code, "WebUIScraper HTTP %d on retry #%d: %s", e.status_code, tries, e)
# Trigger automatic re-login, and try again once
if e.status_code == 401:
logger.info("Performing automatic re-login to ControlIQApi after HTTP 401 for ControlIQApi")
self.controliq.accessTokenExpiresAt = time.time()
self.controliq.login(self.controliq._email, self.controliq._password)
return self.get(endpoint, tries=tries+1)
if e.status_code == 500:
return self.get(endpoint, tries=tries+1)
raise e
def strip(self, txt):
# Remove errant whitespace between litearl newlines (and literal &nbsp;)
sep = '\r\n'
if sep not in txt and '\n' in txt:
sep = '\n'
return ' '.join([i.replace('\xa0',' ').strip() for i in txt.strip().split(sep)])
"""
Returns a mapping between pump/device IDs and information about that device,
including the GUID used for obtaining pump settings.
"""
def my_devices(self) -> Dict[str, DeviceSettings]:
devices = {}
r = self.get('myaccount/my_devices.aspx')
soup = BeautifulSoup(r.content, features='lxml')
for device in soup.select('#content > div.box'):
device_name = self.strip(device.select_one('.subTitle').text)
def find_label_value(lbl):
label = device.find(text=lbl)
if label:
tds = label.parent.parent.parent.select('td')
if len(tds) > 1:
return self.strip(tds[1].text)
return None
serial_number = find_label_value('Serial #')
model_number = find_label_value('Model #')
status = find_label_value('Status')
settings_span = device.find(text='View Settings')
settings_guid = None
if settings_span:
settings_a = settings_span.parent.parent
settings_guid = settings_a.attrs['href'].split('?guid=')[1]
if serial_number:
devices[serial_number] = Device(
name=device_name,
model_number=model_number,
status=status,
guid=settings_guid)
return devices
"""
Returns a parsed representation of a pump's settings.
Note that pump_guid is NOT the serial number of the pump, and
should be obtained from my_devices()[str(serial_number)].guid
"""
def device_settings_from_guid(self, pump_guid: str) -> Tuple[List[Profile], DeviceSettings]:
profiles = []
settings = {}
r = self.get('myaccount/DeviceSettings.aspx?guid=%s' % pump_guid)
soup = BeautifulSoup(r.content, features='lxml')
settings["upload_date"] = self.strip(soup.select_one('#lblUploadDate').text)
divxml = soup.select_one('#divXML')
divxmlDiv = divxml.findChild('div')
for tbl in divxmlDiv.findChildren('table', recursive=False):
setting_bg = tbl.select_one('.setting_bg')
if setting_bg and self.strip(setting_bg.text) == 'Profile':
profiles.append(self._parse_profile_tbl(tbl))
else:
settings.update(self._parse_settings_tbl(tbl))
low_bg_threshold, high_bg_threshold = self._extract_bg_thresholds(settings)
dev_settings = DeviceSettings(
low_bg_threshold=low_bg_threshold,
high_bg_threshold=high_bg_threshold,
raw_settings=settings
)
return profiles, dev_settings
def _parse_profile_tbl(self, tbl) -> Profile:
profile = {}
profile["title"] = self.strip(tbl.select_one('.setting_title').text)
profile["active"] = bool(tbl.find(text='Active at the time of upload'))
profile["segments"] = []
def parse_basal_rate(rate) -> float:
return float(removesuffix(rate, ' u/hr'))
def parse_factor(ratio) -> int:
return parse_bg_mgdl(removeprefix(ratio, '1u:'))
def parse_ratio(ratio) -> float:
return float(removesuffix(removeprefix(ratio, '1u:'), ' g'))
def parse_bg_mgdl(bg) -> int:
if bg.endswith(' mg/dL'):
return float(removesuffix(bg, ' mg/dL'))
elif bg.endswith(' mmol/L'):
return float(removesuffix(bg, ' mmol/L')) * MMOLL_TO_MGDL
raise ValueError(bg)
def hours_to_mins(text) -> int:
hrmin = removesuffix(text, " hours")
hr, min = hrmin.split(":", 1)
return int(min) + int(hr)*60
for tr in tbl.select('tr'):
# Skip header rows
if tr.select_one('.setting_bg'):
continue
if tr.find(text='Start Time'):
continue
tds = tr.select('td')
def is_time_row(td):
txt = self.strip(td.select_one('strong').text)
return " AM" in txt or " PM" in txt or txt in ("Midnight", "Noon")
if len(tds) > 0 and is_time_row(tds[0]):
display_time = self.strip(tds[0].text)
t = display_time
if display_time == "Midnight":
t = "12:00 AM"
elif display_time == "Noon":
t = "12:00 PM"
segment = {
"display_time": display_time,
"time": t,
"basal_rate": parse_basal_rate(self.strip(tds[1].text)),
"correction_factor": parse_factor(self.strip(tds[2].text)),
"carb_ratio": parse_ratio(self.strip(tds[3].text)),
"target_bg_mgdl": parse_bg_mgdl(self.strip(tds[4].text))
}
profile["segments"].append(ProfileSegment(**segment))
continue
if tr.find(text='Calculated Total Daily Basal'):
profile["calculated_total_daily_basal"] = float(removesuffix(self.strip(tds[1].text), " units"))
continue
# Last row
if tr.find(text='Duration of Insulin:'):
lastrow = self.strip(tr.text)
for part in lastrow.split(' |'):
if len(part) < 1:
continue
key, val = part.split(': ')
key = self.strip(key)
val = self.strip(val)
if key == 'Duration of Insulin':
profile["insulin_duration_min"] = hours_to_mins(val)
elif key == 'Carbohydrates':
profile["carbs_enabled"] = self.strip(val.lower()) == "on"
return Profile(**profile)
def _parse_settings_tbl(self, tbl):
outer_tr = tbl.select('tr')[2]
settings = {}
def loop(td, subhead):
settings[subhead] = {}
for tr in td.select('.settingstable > tr'):
if not tr.select_one('strong'):
continue
key = self.strip(tr.select_one('strong').text)
tds = tr.select('td')
if len(tds) == 1:
subhead = key
settings[subhead] = {}
continue
val_text = self.strip(tds[1].text)
val = {}
if tds[1].find(text=' - '):
val['value'] = False
elif tds[1].find(text='Off'):
val['value'] = False
val_text = self.strip(val_text.split('Off', 1)[1])
elif tds[1].find(text='On'):
val['value'] = True
val_text = self.strip(val_text.split('On', 1)[1])
val['text'] = val_text
settings[subhead][key] = val
children = outer_tr.findChildren('td', recursive=False)
loop(children[0], 'Alerts')
loop(children[1], 'Pump Settings')
return settings
def _extract_bg_thresholds(self, settings):
# Nightscout needs default values
low_bg_threshold = 70
high_bg_threshold = 180
if 'CGM Alerts' in settings:
if 'Low Alert' in settings['CGM Alerts']:
low = settings['CGM Alerts']['Low Alert']
if low['value']:
low_bg_threshold = int(low['text'].split(' mg/dL')[0])
if 'High Alert' in settings['CGM Alerts']:
high = settings['CGM Alerts']['High Alert']
if high['value']:
high_bg_threshold = int(high['text'].split(' mg/dL')[0])
return low_bg_threshold, high_bg_threshold
"""
Wraps a call to my_devices to identify the device GUID from the
given pump serial, and then returns device_settings_from_guid.
"""
def device_settings(self, pump_serial: str) -> Tuple[List[Profile], DeviceSettings]:
devices = self.my_devices()
if str(pump_serial) in devices.keys():
dev = devices[str(pump_serial)]
return self.device_settings_from_guid(dev['guid'])
raise RuntimeError('Unable to find pump with serial number: %s. Known devices: %s' % (pump_serial, devices))
+39 -8
View File
@@ -5,7 +5,7 @@ import logging
import time
import json
from .common import parse_date, base_headers, ApiException
from .common import base_session, parse_date, parsed_date_to_arrow, base_headers, days_between, split_days_range, ApiException
logger = logging.getLogger(__name__)
@@ -19,15 +19,16 @@ class WS2Api:
def __init__(self, userGuid):
self.userGuid = userGuid
self.session = base_session()
def get(self, endpoint, query):
r = requests.get(self.BASE_URL + endpoint, query, headers=base_headers())
def get(self, endpoint, **kwargs):
r = self.session.get(self.BASE_URL + endpoint, headers=base_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
return r.text
def get_jsonp(self, endpoint):
r = requests.get(self.BASE_URL + endpoint, {'callback': 'cb'}, headers=base_headers())
def get_jsonp(self, endpoint, **kwargs):
r = self.session.get(self.BASE_URL + endpoint + '?callback=cb', headers=base_headers(), **kwargs)
if r.status_code != 200:
raise ApiException(r.status_code, "WS2 API HTTP %s response: %s" % (str(r.status_code), r.text))
@@ -70,13 +71,40 @@ class WS2Api:
Basal data does NOT appear for the specified time range if using Control-IQ.
The ControlIQ API endpoints must be used for basal data instead.
However, all other fields are still accessed via this endpoint.
This has its own built-in retry logic because Tandem's frontend serving
the API returns 500s when its backend times out.
"""
MAX_THERAPY_TIMELINE_DAYS = 2
def therapy_timeline_csv(self, start=None, end=None, tries=0):
startDate = parse_date(start)
endDate = parse_date(end)
pStart = parsed_date_to_arrow(startDate)
pEnd = parsed_date_to_arrow(endDate)
if days_between(pStart, pEnd) > self.MAX_THERAPY_TIMELINE_DAYS:
ranges = split_days_range(pStart, pEnd, self.MAX_THERAPY_TIMELINE_DAYS)
logger.debug("Splitting call to therapy_timeline_csv(%s, %s) into: %s", start, end, ranges)
outputs = []
for rng in ranges:
rStart, rEnd = rng
logger.debug("split therapy_timeline_csv(%s, %s)", rStart, rEnd)
output = self.therapy_timeline_csv(rStart, rEnd, tries=tries)
logger.debug("split therapy_timeline_csv(%s, %s) = %s", rStart, rEnd, ["%s: %s items" % (key, len(val)) for key, val in output.items()])
outputs.append(output)
full = {}
for o in outputs:
for key, val in o.items():
if key not in full:
full[key] = val
elif val is not None:
full[key] += val
logger.debug("therapy_timeline_csv merge: %s", ["%s: %s items" % (key, len(val)) for key, val in full.items()])
return full
try:
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), {})
req_text = self.get('therapytimeline2csv/%s/%s/%s?format=csv' % (self.userGuid, startDate, endDate), timeout=10)
except ApiException as e:
# This seems to occur as some kind of soft rate-limit.
logger.warning("Received ApiException in therapy_timeline_csv: (retry count %d) %s" % (tries, e))
@@ -88,6 +116,7 @@ class WS2Api:
return self.therapy_timeline_csv(start, end, tries+1)
raise e
logger.debug('req_text: %s', req_text)
sections = self._split_empty_sections(req_text)
readingData = None
@@ -123,6 +152,8 @@ class WS2Api:
- "manual"
- "previous"
- "alarm"
End-date inclusive: Returns data from 00:00 on start date to 23:59 on end date.
{"BasalSuspension":[{"EventDateTime":"/Date(EPOCH_MS-0000)/", "SuspendReason": "reason"}]}
"""
@@ -131,7 +162,7 @@ class WS2Api:
endDate = parse_date(end)
arg = "filterbasal/1" if filterbasal else ""
return self.get_jsonp('basalsuspension/%s/%s/%s/%s' % (self.userGuid, startDate, endDate, arg))
return self.get_jsonp('basalsuspension/%s/%s/%s/%s' % (self.userGuid, startDate, endDate, arg), timeout=10)
"""
Returns info on BasalIQ in JSONP format.
@@ -140,4 +171,4 @@ class WS2Api:
startDate = parse_date(start)
endDate = parse_date(end)
return self.get_jsonp('basaliqtech/%s/%s/%s' % (self.userGuid, startDate, endDate))
return self.get_jsonp('basaliqtech/%s/%s/%s' % (self.userGuid, startDate, endDate), timeout=10)
-95
View File
@@ -1,95 +0,0 @@
import time
import logging
import sys
from .process import process_time_range
from .features import DEFAULT_FEATURES
from .secret import (
PUMP_SERIAL_NUMBER,
AUTOUPDATE_DEFAULT_SLEEP_SECONDS,
AUTOUPDATE_MAX_SLEEP_SECONDS,
AUTOUPDATE_USE_FIXED_SLEEP,
AUTOUPDATE_FAILURE_MINUTES,
AUTOUPDATE_RESTART_ON_FAILURE
)
logger = logging.getLogger(__name__)
"""
Performs the auto-update functionality. Runs indefinitely in a loop
until stopped (ctrl+c).
"""
def process_auto_update(tconnect, nightscout, time_start, time_end, pretend, 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.
last_event_index = None
last_event_time = None
last_process_time_range = None
time_diffs = []
while True:
last_event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
if not last_event_index or last_event['maxPumpEventIndex'] > last_event_index:
now = time.time()
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], 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 last_event_index:
logger.error('An event index change was recorded, but no new data was found via the API. ' +
'If this error reoccurs, try restarting tconnectsync.')
else:
last_process_time_range = now
if last_event_index:
time_diffs.append(now - last_event_time)
logger.debug('Updating tracking of time since last update: %s' % time_diffs)
last_event_index = last_event['maxPumpEventIndex']
last_event_time = now
else:
logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex'])
now = time.time()
if last_event_time and (now - last_event_time) >= 60 * AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateFailureException("No new data event indexes have been detected for over %d minutes. " % AUTOUPDATE_FAILURE_MINUTES +
"The t:connect app might no longer be functioning."))
if AUTOUPDATE_RESTART_ON_FAILURE:
sys.exit(1)
elif last_process_time_range and (now - last_process_time_range) >= 60 * AUTOUPDATE_FAILURE_MINUTES:
logger.error(AutoupdateFailureException("No new data has been found via the API for over %d minutes. " % AUTOUPDATE_FAILURE_MINUTES +
"tconnectsync might not be functioning properly."))
if AUTOUPDATE_RESTART_ON_FAILURE:
sys.exit(1)
if len(time_diffs) > 2:
logger.info('Sleeping 60 seconds after unexpected no index change. (New data might be delayed.)')
time.sleep(60)
continue
sleep_secs = AUTOUPDATE_DEFAULT_SLEEP_SECONDS
if AUTOUPDATE_USE_FIXED_SLEEP != 1:
if len(time_diffs) > 10:
time_diffs = time_diffs[1:]
if len(time_diffs) > 2:
sleep_secs = sum(time_diffs) / len(time_diffs)
if sleep_secs > AUTOUPDATE_MAX_SLEEP_SECONDS:
sleep_secs = AUTOUPDATE_MAX_SLEEP_SECONDS
# Sleep for a rolling average of time between updates
logger.info('Sleeping for %d sec' % sleep_secs)
time.sleep(sleep_secs)
class AutoupdateFailureException(RuntimeError):
pass
+186 -36
View File
@@ -1,58 +1,208 @@
import sys
import time
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 .domain.tandemsource.event_class import EventClass
from .sync.tandemsource.choose_device import ChooseDevice
try:
__version__ = pkg_resources.require("tconnectsync")[0].version
except Exception:
__version__ = "UNKNOWN"
"""
Attempts to authenticate with each t:connect API,
and returns the output of a sample API call from each.
Also attempts to connect to the Nightscout API.
"""
def check_login(tconnect, time_start, time_end):
def check_login(tconnect, time_start, time_end, verbose=False, sanitize=True):
errors = 0
print("Logging in to t:connect ControlIQ API...")
loglines = []
def log(*args):
print(*args)
loglines.append(" ".join([str(i) for i in args]) + "\n")
def log_err(e):
try:
out = ''.join(list(traceback.TracebackException.from_exception(e).format()))
log(out)
except Exception:
log("could not log exception traceback: {}".format(e))
def debug(*args):
if verbose:
print(*args)
loglines.append(" ".join([str(i) for i in args]) + "\n")
log("tconnectsync version %s" % __version__)
log("Python version %s" % sys.version)
log("System platform %s" % sys.platform)
log("Running checks with time range %s to %s" % (time_start, time_end))
log("Current time: %s" % datetime.now())
log("time.tzname: %s" % str(time.tzname))
log("Loading secrets...")
try:
summary = tconnect.controliq.dashboard_summary(time_start, time_end)
print("ControlIQ dashboard summary: %s" % summary)
except Exception as e:
print("Error occurred querying ControlIQ API: %s" % e)
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
print("\nLogging in to t:connect WS2 API...")
try:
summary = tconnect.ws2.basaliqtech(time_start, time_end)
print("WS2 basaliq status: %s" % summary)
except Exception as e:
print("Error occurred querying WS2 API: %s" % e)
if not TCONNECT_PASSWORD or TCONNECT_PASSWORD == 'password':
log("Error: You have not specified a TCONNECT_PASSWORD")
errors += 1
print("\nLogging in to t:connect Android API...")
try:
summary = tconnect.android.user_profile()
print("Android user profile: %s" % summary)
if not PUMP_SERIAL_NUMBER or PUMP_SERIAL_NUMBER == '11111111':
log("Warning: You have not specified a PUMP_SERIAL_NUMBER, so the pump with most recent activity will be automatically used.")
from .secret import PUMP_SERIAL_NUMBER
event = tconnect.android.last_event_uploaded(PUMP_SERIAL_NUMBER)
print("\nAndroid last uploaded event: %s" % event)
except ImportError:
print("Error: Unable to load config file.")
except Exception as e:
print("Error occurred querying Android API: %s" % e)
if not NS_URL or NS_URL == 'https://yournightscouturl/':
log("Error: You have not specified a NS_URL")
errors += 1
print("\nLogging in to Nightscout...")
try:
from .secret import NS_URL, NS_SECRET
status = NightscoutApi(NS_URL, NS_SECRET).api_status()
print("\nNightscout status: %s" % status)
except ImportError:
print("Error: Unable to load config file.")
except Exception as e:
print("Error occurred querying Nightscout API: %s" % e)
if not NS_SECRET or NS_SECRET == 'apisecret':
log("Error: You have not specified a NS_SECRET")
errors += 1
log("TIMEZONE_NAME: %s" % TIMEZONE_NAME)
log("-----")
serialNumberToPump = None
try:
log("Fetching pump metadata...")
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
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 Tandem Source:")
log_err(e)
errors += 1
log("-----")
log("Logging in to Nightscout...")
try:
nightscout = NightscoutApi(NS_URL, NS_SECRET)
status = nightscout.api_status()
debug("Nightscout status: \n%s" % pformat(status))
last_upload_basal = nightscout.last_uploaded_entry(BASAL_EVENTTYPE)
debug("Nightscout last uploaded basal: \n%s" % pformat(last_upload_basal))
last_upload_bolus = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE)
debug("Nightscout last uploaded bolus: \n%s" % pformat(last_upload_bolus))
except Exception as e:
log("Error occurred querying Nightscout API:")
log_err(e)
errors += 1
log("-----")
def time_ago(t):
return '%s ago' % (arrow.now() - arrow.get(t)) if t else 'n/a'
if errors == 0:
print("\nNo API errors returned!")
log("No API errors returned!")
else:
print("\nAPI errors occurred. Please check the errors above.")
log("API errors occurred. Please check the errors above.")
with open('tconnectsync-check-output.log', 'w') as f:
if sanitize:
sanitizedData = {
'TCONNECT_EMAIL': TCONNECT_EMAIL,
'TCONNECT_PASSWORD': TCONNECT_PASSWORD,
'PUMP_SERIAL_NUMBER': PUMP_SERIAL_NUMBER,
'NS_URL': NS_URL,
'NS_SECRET': NS_SECRET
}
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)
print("Created file tconnectsync-check-output.log containing additional debugging information.")
print("For support, you can upload this file to https://github.com/jwoglom/tconnectsync/issues/new")
if sanitize:
print("The file -- but NOT the output printed above -- has been sanitized to remove sensitive data.")
print("Please verify and remove any sensitive data, such as your Nightscout URL/secret and pump serial number,")
print("as necessary.")
def run_sanitize(s, sanitizedData):
ret = str(s)
for k, v in sanitizedData.items():
if v and len(str(v)) > 0:
ret = ret.replace(str(v), '[%s]' % k)
return ret
def pformat(*args, **kwargs):
kwargs['width'] = 160
return pformat_base(*args, **kwargs)
View File
+25
View File
@@ -0,0 +1,25 @@
from dataclasses import dataclass, asdict
@dataclass
class Bolus:
description: str
complete: str # "1" / "0"
completion: str
request_time: str # _datetime_parse timestamp
completion_time: str # _datetime_parse timestamp
insulin: str
requested_insulin: str
carbs: str
bg: str # potentially ""
user_override: str
extended_bolus: str # "1" / "0"
bolex_completion_time: str
bolex_start_time: str
def to_dict(self):
return asdict(self)
@property
def is_extended_bolus(self):
return self.extended_bolus == "1"
+44
View File
@@ -0,0 +1,44 @@
from dataclasses import dataclass, replace
from typing import List, Optional
@dataclass
class Device:
name: str
model_number: str
status: str
guid: Optional[str]
@dataclass
class ProfileSegment:
display_time: str # Identical to time except written out as Midnight or Noon
time: str
basal_rate: float # _ u/hr
correction_factor: int # 1u: _ mg/dL
carb_ratio: float # 1u: _ g
target_bg_mgdl: int
@dataclass
class Profile:
title: str
active: bool
segments: List[ProfileSegment]
calculated_total_daily_basal: float # in units
insulin_duration_min: int
carbs_enabled: bool
def activeProfile(self):
p = self.copy()
p.active = True
return p
def copy(self):
p = replace(self)
p.segments = [replace(s) for s in p.segments]
return p
# Settings stored globally in the pump that are stored per-profile in Nightscout
@dataclass
class DeviceSettings:
low_bg_threshold: int
high_bg_threshold: int
raw_settings: dict
@@ -0,0 +1,40 @@
from enum import Enum
from ...eventparser import events
class EventClass(set, Enum):
# LidBasalDelivery = every 5min entry
# LidBasalRateChange = only when basal rate changes
BASAL = {events.LidBasalDelivery} # , LidBasalRateChange
BASAL_SUSPENSION = {events.LidPumpingSuspended}
BASAL_RESUME = {events.LidPumpingResumed}
ALARM = {events.LidAlarmActivated, events.LidMalfunctionActivated}
BOLUS = {
events.LidBolusRequestedMsg1, # carb amount, bg, iob
events.LidBolusRequestedMsg2, # more robust bolus type
events.LidBolusRequestedMsg3, # total bolus requested amount
events.LidBolusCompleted, # final event showing amount delivered
events.LidBolexCompleted # extended bolus
}
CARTRIDGE = {events.LidCartridgeFilled, events.LidCannulaFilled, events.LidTubingFilled}
CGM_ALERT = {events.LidCgmAlertActivated, events.LidCgmAlertActivatedDex, events.LidCgmAlertActivatedFsl2}
_CGM_START = {events.LidCgmStartSessionGx, events.LidCgmStartSessionFsl2}
_CGM_JOIN = {events.LidCgmJoinSessionGx, events.LidCgmJoinSessionG7, events.LidCgmJoinSessionFsl2}
_CGM_STOP = {events.LidCgmStopSessionGx, events.LidCgmStopSessionG7, events.LidCgmStopSessionFsl2}
CGM_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):
for typ, vals in EventClass.__members__.items():
if typ.startswith('_'):
continue
if type(evt) == type and evt in vals:
return EventClass.__members__[typ]
elif type(evt) in vals:
return EventClass.__members__[typ]
return None
@@ -0,0 +1,55 @@
from dataclasses import dataclass
from dataclasses_json import dataclass_json
from typing import List
@dataclass_json
@dataclass
class PumpProfileSegment:
startTime: int # minutes
basalRate: int # milliunits
isf: int
carbRatio: int
targetBg: int
@property
def skip(self):
return self.startTime == 0 and self.basalRate == 0 and self.isf == 0 and self.carbRatio == 0 and self.targetBg == 0
@dataclass_json
@dataclass
class PumpProfile:
name: str
idp: int
tDependentSegs: List[PumpProfileSegment]
insulinDuration: int # minutes
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:
activeIdp: int
profile: List[PumpProfile]
@dataclass_json
@dataclass
class PumpGlucoseAlertSettings:
mgPerDl: int
enabled: int # 1 / 0
duration: int # minutes
status: int # unknown
@dataclass_json
@dataclass
class PumpCgmSettings:
highGlucoseAlert: PumpGlucoseAlertSettings
lowGlucoseAlert: PumpGlucoseAlertSettings
@dataclass_json
@dataclass
class PumpSettings:
profiles: PumpProfiles
cgmSettings: PumpCgmSettings
+483
View File
@@ -0,0 +1,483 @@
import arrow
from tconnectsync.domain.bolus import Bolus
from ..secret import TIMEZONE_NAME
def _datetime_parse(date):
# consistent format with ws2 endpoint
return arrow.get(date, tzinfo=TIMEZONE_NAME).format("YYYY-MM-DD HH:mm:ssZZ")
class TherapyEvent:
type = None
eventDateTime = None
sourceRecId = None
def parse(self, json):
self.type = json['type']
self.eventDateTime = json['eventDateTime']
self.sourceRecId = json['sourceRecId']
self.rawJson = json
def __str__(self):
return "%s(%s)" % (self.type, self.rawJson)
class CGMTherapyEvent(TherapyEvent):
eventID = None
egv = None
"""
{
"eventDateTime": "2022-07-21T00:00:08",
"eventID": 256,
"requestDateTime": "0001-01-01T00:00:00",
"type": "CGM",
"description": "EGV",
"sourceRecId": 0,
"eventTypeId": 0,
"deviceType": "t:slim X2 Insulin Pump",
"serialNumber": "xxx",
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0,
"egv": {
"estimatedGlucoseValue": 174,
"hypo": 0,
"belowTarget": 0,
"withinTarget": 1,
"aboveTarget": 0,
"hyper": 0
}
},
"""
@classmethod
def parse(_, json):
self = CGMTherapyEvent()
TherapyEvent.parse(self, json)
self.eventID = json['eventID']
self.egv = json['egv']['estimatedGlucoseValue']
return self
class BGTherapyEvent(TherapyEvent):
eventID = None
egv = None
"""
{
'bg': 160, # note in EGV
'cgmCalibration': 1, # not in EGV
'description': 'BG',
'deviceType': 't:slim X2 Insulin Pump',
'eventDateTime': '2022-08-20T07:25:24',
'eventTypeId': 16,
'indexId': 844955,
'interactive': 0,
'iob': 0.75,
'note': { 'active': False,
'eventId': 0, # different location than EGV
'eventTypeId': 16,
'id': 0,
'indexId': '',
'sourceRecordId': 0},
'requestDateTime': '0001-01-01T00:00:00',
'serialNumber': 'xxx',
'sourceRecId': 793549667,
'tempRateActivated': 0,
'tempRateCompleted': 0,
'tempRateId': 0,
'type': 'BG',
'uploadId': 748700213}
"""
@classmethod
def parse(_, json):
self = BGTherapyEvent()
TherapyEvent.parse(self, json)
self.eventID = json['note']['eventId']
# This is probably not how we want to provide CGM calibrations to Nightscout,
# but will just include it as egv data for now to keep the thing from crashing :)
self.egv = json['bg']
return self
class BolusTherapyEvent(TherapyEvent):
bolusRequestOptions = None
REQUEST_AUTOMATIC = "Automatic Bolus/Correction"
REQUEST_STANDARD = "Standard"
bolusType = None
TYPE_AUTOMATIC = "Automatic Correction"
TYPE_CARB = "Carb"
carbSize = None
correctionBolusSize = None
foodBolusSize = None
insulinDelivered = None
insulinRequested = None
completionDateTime = None
completionStatus = None
STATUS_COMPLETED = "Completed"
eventHistoryReportDetails = None
standardPercent = None
sourceRecId = None
@classmethod
def parse(_, json):
self = BolusTherapyEvent()
TherapyEvent.parse(self, json)
self.description = json.get("description")
self.complete = json.get("standard", {}).get("bolusIsComplete")
self.completion = json.get("standard", {}).get("completionStatusDesc")
self.request_time = json.get("requestDateTime")
self.completion_time = json.get("standard", {}).get("insulinDelivered", {}).get("completionDateTime")
# TODO: separate extended vs standard bolus into separate fields
self.insulin = json.get("standard", {}).get("insulinDelivered", {}).get("value")
self.requested_insulin = json.get("standard", {}).get("insulinRequested")
self.carbs = json.get("carbSize")
self.bg = json.get("bg")
self.user_override = json.get("userOverride")
self.extended_bolus = json.get("bolusRequestOptions") == "Extended"
if self.extended_bolus and self.complete:
# TODO(https://github.com/jwoglom/tconnectsync/issues/19): read more extended bolus info
self.complete = json.get("bolex", {}).get("extendedBolusIsComplete")
self.completion = json.get("bolex", {}).get("completionStatusDesc")
self.bolex_completion_time = json.get("bolex", {}).get("insulinDelivered", {}).get("completionDateTime")
self.bolex_start_time = json.get("bolex", {}).get("bolexStartDateTime")
else:
self.bolex_completion_time = ""
self.bolex_start_time = ""
return self
def to_bolus(self):
return Bolus(
description=self.description,
complete="1" if self.complete else "0",
completion=self.completion or "",
request_time=_datetime_parse(self.request_time),
completion_time=_datetime_parse(self.completion_time),
insulin=str(self.insulin),
requested_insulin=str(self.requested_insulin),
carbs=str(self.carbs or "0"), # Nightscout expects non-empty carbs
bg=str(self.bg or ""),
user_override=str(self.user_override),
extended_bolus="1" if self.extended_bolus else "0",
bolex_completion_time=_datetime_parse(self.bolex_completion_time) if self.bolex_completion_time else "",
bolex_start_time=_datetime_parse(self.bolex_start_time) if self.bolex_start_time else ""
)
"""
Correction:
{
"actualTotalBolusRequested": 2.9,
"bg": 254,
"bolusRequestOptions": "Automatic Bolus/Correction",
"bolusType": "Automatic Correction",
"carbSize": 0,
"correctionBolusSize": 2.9,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T11:53:08",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
"eventHistoryReportEventDesc": "Correction Bolus",
"foodBolusSize": 0,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "572946",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-07-21T11:53:08",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T11:55:24",
"value": 2.9
},
"foodDelivered": 0,
"correctionDelivered": 2.9,
"insulinRequested": 2.9,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3361,
"bolusCompletionId": 3361
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Automatic Bolus/Correction",
"sourceRecId": 1171791787,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
},
Standard:
{
"actualTotalBolusRequested": 4.17,
"bolusRequestOptions": "Standard",
"bolusType": "Carb",
"carbSize": 25,
"correctionBolusSize": 0,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T12:27:36",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"eventHistoryReportEventDesc": "Food Bolus",
"foodBolusSize": 4.17,
"iob": 2.62,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "573042",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-07-21T12:27:36",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T12:29:21",
"value": 4.17
},
"foodDelivered": 4.17,
"correctionDelivered": 0,
"insulinRequested": 4.17,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3362,
"bolusCompletionId": 3362
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Standard",
"sourceRecId": 1171853319,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
},
Extended bolus incomplete:
{
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"iob": 0,
"completionStatusId": 0,
"extendedBolusIsComplete": 0,
"insulinRequested": 0,
"bolexCompletionId": 0
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
Extended bolus (complete):
{
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:35:03",
"value": 0.2
},
"iob": 5.7,
"completionStatusId": 3.0,
"completionStatusDesc": "Completed",
"extendedBolusIsComplete": 1,
"insulinRequested": 0.2,
"bolexCompletionId": 16757133
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": false
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
CGM Calibration (Therapy Event Type BG):
{ 'bg': 160,
'cgmCalibration': 1,
'description': 'BG',
'deviceType': 't:slim X2 Insulin Pump',
'eventDateTime': '2022-08-20T07:25:24',
'eventTypeId': 16,
'indexId': 844955,
'interactive': 0,
'iob': 0.75,
'note': { 'active': False,
'eventId': 0,
'eventTypeId': 16,
'id': 0,
'indexId': '',
'sourceRecordId': 0},
'requestDateTime': '0001-01-01T00:00:00',
'serialNumber': 'xxx',
'sourceRecId': 793549667,
'tempRateActivated': 0,
'tempRateCompleted': 0,
'tempRateId': 0,
'type': 'BG',
'uploadId': 0}
"""
class BasalTherapyEvent(TherapyEvent):
"""
{
'basalRate': {
'duration': 0,
'percent': 0,
'value': 0.0
},
'displayInHistory': 0,
'eventDateTime': '2022-12-02T00:00:00',
'note': {
'id': 0,
'indexId': '16403',
'eventTypeId': 90,
'sourceRecordId': 0,
'eventId': 0,
'active': False
},
'noteDate': {},
'requestDateTime': '0001-01-01T00:00:00',
'type': 'Basal',
'description': 'NDE',
'sourceRecId': xxx,
'eventTypeId': 0,
'indexId': 0,
'uploadId': 0,
'interactive': 1,
'tempRateId': 0,
'tempRateCompleted': 0,
'tempRateActivated': 0
}
"""
basalRateValue = None
basalRatePercent = None
basalRateDuration = None
eventTime = None
@classmethod
def parse(_, json):
self = CGMTherapyEvent()
TherapyEvent.parse(self, json)
if 'basalRate' in json:
self.basalRateValue = json['basalRate']['value']
self.basalRatePercent = json['basalRate']['percent']
self.basalRateDuration = json['basalRate']['duration']
self.eventTime = json['eventDateTime']
return self
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
class Time:
def __init__(self, hour: int, min: int):
self.hour = hour
self.min = min
@classmethod
def parse(cls, input):
if ' ' not in input:
raise ValueError('unable to parse time: %s' % input)
hrmin, ampm = input.split(' ')
hr, min = hrmin.split(':')
hr = int(hr)
min = int(min)
if ampm.lower() == 'pm':
hr += 12
elif ampm.lower() != 'am':
raise ValueError('unable to parse time: %s' % input)
return cls(hr, min)
+196
View File
@@ -0,0 +1,196 @@
header = '''# THIS FILE IS AUTOGENERATED. DO NOT EDIT.
import struct
import logging
from dataclasses import dataclass
from enum import Enum, IntFlag
from .raw_event import RawEvent, BaseEvent
logger = logging.getLogger(__name__)
EVENT_LEN = 26
'''
TYPE_TO_STRUCT = {
'uint8': '>B',
'int8': '>b',
'uint16': '>H',
'int16': '>h',
'uint32': '>I',
'float32': '>f',
}
for k, v in TYPE_TO_STRUCT.items():
header += f"{k.upper()} = '{v}'\n"
TYPE_TO_PYOBJ = {
'uint8': 'int',
'int8': 'int',
'uint16': 'int',
'int16': 'int',
'uint32': 'int',
'float32': 'float',
}
HEADER_SIZE = 10
def unpack_command_for(field_def):
return f'struct.unpack_from({field_def["type"].upper()}, raw[:EVENT_LEN], {HEADER_SIZE + field_def["offset"]})'
TEMPLATE = '''
@dataclass
class {name}(BaseEvent):
"""{id}: {raw_name}"""
ID = {id}
NAME = "{raw_name}"
raw: RawEvent
{fields}
{transform_funcs}
@staticmethod
def build(raw):
{build_p1}
return {name}(
raw = RawEvent.build(raw),
{build_p2}
)
@property
def eventTimestamp(self):
return self.raw.timestamp
@property
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):
if not text:
return text
return f'{text[0].lower()}{text[1:]}'
def eventNameFormat(text):
if not text:
return text
return text.replace('_', ' ').title().replace(' ', '')
def fieldNameFormat(text):
if not text or all([i.isupper() for i in text]):
return text
return firstLower(text.replace('_', ' ').title().replace(' ', '')).replace('raw', 'Raw')
def build_fields(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}: {TYPE_TO_PYOBJ[field["type"]]}'
if "uom" in field:
f += ' # ' + field['uom']
ret.append(f)
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 = []
p2s = []
for name, field in event_def["data"].items():
p1 = f'{fieldNameFormat(name)}, = {unpack_command_for(field)}'
p1s.append(p1)
suffix = 'Raw' if "transform" in field and name[-3:] != 'Raw' else ''
p2 = f'{fieldNameFormat(name)}{suffix} = {fieldNameFormat(name)},'
p2s.append(p2)
return '\n'.join([f'{" "*8}{f}' for f in p1s]), '\n'.join([f'{" "*12}{f}' for f in p2s])
def build_transform_funcs(event_def):
try:
from transforms import TRANSFORMS
except ImportError:
from .transforms import TRANSFORMS
ret = []
for name, field in event_def["data"].items():
if not "transform" in field:
continue
for tx in field["transform"]:
ret += TRANSFORMS[tx[0]](event_def, name, fieldNameFormat(name), field, tx[1])
return '\n'.join([f'{" "*4}{f}' if f else '' for f in ret])
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),
id = event_id,
raw_name = event_def["name"]
)
def build_events_map(events):
ret = ['EVENT_IDS = {']
for event_id, event_def in events.items():
ret += [f'{" "*4}{event_id}: {eventNameFormat(event_def["name"])},']
ret += ['}', '']
ret += ['EVENT_NAMES = {']
for event_id, event_def in events.items():
ret += [f'{" "*4}"{event_def["name"]}": {eventNameFormat(event_def["name"])},']
ret += ['}', '']
return '\n'.join(ret)
if __name__ == '__main__':
import json
merged_events = {}
output = f'{header}'
with open("events.json", "r") as f:
j = json.loads(f.read())
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)
print(output)
@@ -0,0 +1,67 @@
{
"events": {
"81": {
"name": "LID_DAILY_BASAL",
"data": {
"dailyTotalBasal": {
"type": "float32",
"offset": 0,
"uom": "units"
},
"lastBasalRate": {
"type": "float32",
"offset": 4,
"uom": "units/hour"
},
"iob": {
"type": "float32",
"offset": 8,
"uom": "units"
},
"batteryChargePercentMSBRaw": {
"type": "uint8",
"offset": 12
},
"batteryChargePercentLSBRaw": {
"type": "uint8",
"offset": 13,
"transform": [["battery_charge_percent", ""]]
},
"batteryLipoMilliVolts": {
"type": "uint16",
"offset": 14
}
}
},
"48": {
"name": "LID_CARBS_ENTERED",
"data": {
"carbs": {
"type": "float32",
"offset": 0,
"uom": "carbs"
}
}
},
"36": {
"name": "LID_USB_CONNECTED",
"data": {
"negotiatedCurrent": {
"type": "float32",
"offset": 0,
"uom": "mA"
}
}
},
"37": {
"name": "LID_USB_DISCONNECTED",
"data": {
"negotiatedCurrent": {
"type": "float32",
"offset": 0,
"uom": "mA"
}
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import struct
import base64
from dataclasses import dataclass
from .raw_event import RawEvent, EVENT_LEN
from .events import EVENT_IDS
from .utils import batched
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):
return base64.b64decode(raw)
+73
View File
@@ -0,0 +1,73 @@
import struct
import arrow
from ..secret import TIMEZONE_NAME
from dataclasses import dataclass
EVENT_LEN = 26
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
id: int
timestampRaw: int
seqNum: int
raw: bytearray
@staticmethod
def build(raw):
source_and_id, = struct.unpack_from(UINT16, raw[:EVENT_LEN], 0)
timestampRaw, = struct.unpack_from(UINT32, raw[:EVENT_LEN], 2)
seqNum, = struct.unpack_from(UINT32, raw[:EVENT_LEN], 6)
return RawEvent(
source = (source_and_id & 0xF000) >> 12,
id = source_and_id & 0x0FFF,
timestampRaw = timestampRaw,
seqNum = seqNum,
raw = raw
)
@property
def timestamp(self):
# Event timestamps do not have TZ data attached to them when parsed,
# 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='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),
)
+144
View File
@@ -0,0 +1,144 @@
ALERTS_DICT = {
"0": "LOW_INSULIN_ALERT",
"1": "USB_CONNECTION_ALERT",
"2": "LOW_POWER_ALERT",
"3": "LOW_POWER_ALERT2",
"4": "DATA_ERROR_ALERT",
"5": "AUTO_OFF_ALERT",
"6": "MAX_BASAL_RATE_ALERT",
"7": "POWER_SOURCE_ALERT",
"8": "MIN_BASAL_ALERT",
"9": "CONNECTION_ERROR_ALERT",
"10": "CONNECTION_ERROR_ALERT2",
"11": "INCOMPLETE_BOLUS_ALERT",
"12": "INCOMPLETE_TEMP_RATE_ALERT",
"13": "INCOMPLETE_CARTRIDGE_CHANGE_ALERT",
"14": "INCOMPLETE_FILL_TUBING_ALERT",
"15": "INCOMPLETE_FILL_CANNULA_ALERT",
"16": "INCOMPLETE_SETTING_ALERT",
"17": "LOW_INSULIN_ALERT2",
"18": "MAX_BASAL_ALERT",
"19": "LOW_TRANSMITTER_ALERT",
"20": "TRANSMITTER_ALERT",
"21": "DEFAULT_ALERT_21",
"22": "SENSOR_EXPIRING_ALERT",
"23": "PUMP_REBOOTING_ALERT",
"24": "DEVICE_CONNECTION_ERROR",
"25": "CGM_GRAPH_REMOVED",
"26": "MIN_BASAL_ALERT2",
"27": "INCOMPLETE_CALIBRATION",
"28": "CALIBRATION_TIMEOUT",
"29": "INVALID_TRANSMITTER_ID",
"30": "DEFAULT_ALERT_30",
"32": "DEFAULT_ALERT_32",
"33": "BUTTON_ALERT",
"34": "QUICK_BOLUS_ALERT",
"35": "BASAL_IQ_ALERT",
"36": "DEFAULT_ALERT_36",
"37": "DEFAULT_ALERT_37",
"38": "DEFAULT_ALERT_38",
"39": "TRANSMITTER_END_OF_LIFE",
"40": "CGM_ERROR",
"41": "CGM_ERROR2",
"42": "CGM_ERROR3",
"43": "DEFAULT_ALERT_43",
"44": "TRANSMITTER_EXPIRING_ALERT",
"45": "TRANSMITTER_EXPIRING_ALERT2",
"46": "TRANSMITTER_EXPIRING_ALERT3",
"47": "DEFAULT_ALERT_47",
"48": "CGM_UNAVAILABLE",
"49": "DEFAULT_ALERT_49",
"50": "DEFAULT_ALERT_50",
"51": "DEFAULT_ALERT_51",
"52": "DEFAULT_ALERT_52",
"53": "DEFAULT_ALERT_53",
"54": "DEVICE_PAIRED",
"55": "DEFAULT_ALERT_55",
"56": "DEFAULT_ALERT_56",
"57": "DEFAULT_ALERT_57",
"58": "DEFAULT_ALERT_58",
"59": "DEFAULT_ALERT_59",
"60": "DEFAULT_ALERT_60",
"61": "DEFAULT_ALERT_61",
"62": "DEFAULT_ALERT_62",
"63": "DEFAULT_ALERT_63",
}
ALARMS_DICT = {
"0": "CARTRIDGE_ALARM",
"1": "CARTRIDGE_ALARM2",
"2": "OCCLUSION_ALARM",
"3": "PUMP_RESET_ALARM",
"4": "DEFAULT_ALARM_4",
"5": "CARTRIDGE_ALARM3",
"6": "CARTRIDGE_ALARM4",
"7": "AUTO_OFF_ALARM",
"8": "EMPTY_CARTRIDGE_ALARM",
"9": "CARTRIDGE_ALARM5",
"10": "TEMPERATURE_ALARM",
"11": "TEMPERATURE_ALARM2",
"12": "BATTERY_SHUTDOWN_ALARM",
"13": "DEFAULT_ALARM_13",
"14": "INVALID_DATE_ALARM",
"15": "TEMPERATURE_ALARM3",
"16": "CARTRIDGE_ALARM6",
"17": "DEFAULT_ALARM_17",
"18": "RESUME_PUMP_ALARM",
"19": "DEFAULT_ALARM_19",
"20": "CARTRIDGE_ALARM7",
"21": "ALTITUDE_ALARM",
"22": "STUCK_BUTTON_ALARM",
"23": "RESUME_PUMP_ALARM2",
"24": "ATMOSPHERIC_PRESSURE_OUT_OF_RANGE_ALARM",
"25": "CARTRIDGE_REMOVED_ALARM",
"26": "OCCLUSION_ALARM2",
"27": "DEFAULT_ALARM_27",
"28": "DEFAULT_ALARM_28",
"29": "CARTRIDGE_ALARM10",
"30": "CARTRIDGE_ALARM11",
"31": "CARTRIDGE_ALARM12",
"32": "DEFAULT_ALARM_32",
"33": "DEFAULT_ALARM_33",
"34": "DEFAULT_ALARM_34",
"35": "DEFAULT_ALARM_35",
"36": "DEFAULT_ALARM_36",
"37": "DEFAULT_ALARM_37",
"38": "DEFAULT_ALARM_38",
"39": "DEFAULT_ALARM_39",
"40": "DEFAULT_ALARM_40",
"41": "DEFAULT_ALARM_41",
"42": "DEFAULT_ALARM_42",
"43": "DEFAULT_ALARM_43",
"44": "DEFAULT_ALARM_44",
"45": "DEFAULT_ALARM_45",
"46": "DEFAULT_ALARM_46",
"47": "DEFAULT_ALARM_47",
"48": "DEFAULT_ALARM_48",
"49": "DEFAULT_ALARM_49",
"50": "DEFAULT_ALARM_50",
"51": "DEFAULT_ALARM_51",
"52": "DEFAULT_ALARM_52",
"53": "DEFAULT_ALARM_53",
"54": "DEFAULT_ALARM_54",
"55": "DEFAULT_ALARM_55",
"56": "DEFAULT_ALARM_56",
"57": "DEFAULT_ALARM_57",
"58": "DEFAULT_ALARM_58",
"59": "DEFAULT_ALARM_59",
"60": "DEFAULT_ALARM_60",
"61": "DEFAULT_ALARM_61",
"62": "DEFAULT_ALARM_62",
"63": "DEFAULT_ALARM_63",
}
CGM_ALERTS_DICT = {
"11": "CGM Sensor Fail",
"13": "CGM Sensor Expired",
"14": "CGM Out Of Range",
"20": "CGM Transmitter Error",
"26": "CGM Temperature",
"27": "CGM Failed Connection",
"39": "CGM Transmitter Expired",
"40": "Pump Bluetooth Error"
}
+132
View File
@@ -0,0 +1,132 @@
import json
try:
from static_dicts import ALERTS_DICT, ALARMS_DICT, CGM_ALERTS_DICT
except ImportError:
from .static_dicts import ALERTS_DICT, ALARMS_DICT, CGM_ALERTS_DICT
def enumNameFormat(text):
if not text:
return text
t = text.replace('_', ' ').title().replace(' ', '')
if t.startswith('no,'):
return 'No'
if t.startswith('yes,'):
return 'Yes'
rem = None
for i in '-,.':
spl = t.split(i)
t = spl[0]
if len(spl) > 1:
rem = rem or spl[1]
for i in '()/"\u201c\u201d':
t = t.replace(i, '')
if t.lower() == 'false':
return 'FalseVal'
if t.lower() == 'true':
return 'TrueVal'
if t.lower() == 'none':
return 'NoneVal'
if t.lower() == 'reserved':
return None
if t.lower() == 'unused':
return None
if t.lower() == 'unavailable' and rem:
suffix = enumNameFormat(rem)
t += f'{suffix[0].lower()}{suffix[1:]}'
return f'{t[0].upper()}{t[1:]}'
def transform_enum(event_def, name, name_fmt, field, tx):
out = []
lines_for_out = json.dumps(tx, indent=4).splitlines()
out += [f'{enumNameFormat(name_fmt)}Map = {lines_for_out[0]}']
out += lines_for_out[1:]
out += ['']
out += [f'class {enumNameFormat(name_fmt)}Enum(Enum):']
out += [
f' {enumNameFormat(v)} = {k}' for k, v in tx.items() if enumNameFormat(v)
]
out += ['']
out += [
'@property',
f'def {name_fmt}(self):',
f' try:',
f' return self.{enumNameFormat(name_fmt)}Enum(self.{name_fmt}Raw)',
f' except ValueError as e:',
f' logger.error("Invalid {name_fmt}Raw in {enumNameFormat(name_fmt)} for "+str(self))',
f' logger.error(e)',
f' return None',
''
]
return out
def transform_dictionary(event_def, name, name_fmt, field, tx):
if tx == 'alerts':
return transform_enum(event_def, name, name_fmt, field, ALERTS_DICT)
if tx == 'alarms':
return transform_enum(event_def, name, name_fmt, field, ALARMS_DICT)
if tx == 'dalerts':
return transform_enum(event_def, name, name_fmt, field, CGM_ALERTS_DICT)
return [f'# Dictionary unknown: {tx}']
def transform_bitmask(event_def, name, name_fmt, field, tx):
out = []
lines_for_out = json.dumps(tx, indent=4).splitlines()
out += [f'{enumNameFormat(name_fmt)}Map = {lines_for_out[0]}']
out += lines_for_out[1:]
out += ['']
out += [f'class {enumNameFormat(name_fmt)}Bitmask(IntFlag):',]
out += [
f' {enumNameFormat(v)} = 2**{k}' for k, v in tx.items() if enumNameFormat(v)
]
out += ['']
out += [
'@property',
f'def {name_fmt}(self):',
f' try:',
f' return self.{enumNameFormat(name_fmt)}Bitmask(self.{name_fmt}Raw)',
f' except ValueError as e:',
f' logger.error("Invalid {name_fmt}Raw in {enumNameFormat(name_fmt)}Bitmask for "+str(self))',
f' logger.error(e)',
f' return None',
f''
]
return out
def transform_ratio(event_def, name, name_fmt, field, tx):
out = []
out += [
'@property',
f'def {name_fmt}(self):',
f' return self.{name_fmt}Raw * {tx}',
''
]
return out
def transform_battery_charge_percent(event_def, name, name_fmt, field, tx):
out = []
out += [
'@property',
f'def batteryChargePercent(self):',
f' return (256*(self.batterychargepercentmsbRaw-14)+self.batterychargepercentlsbRaw)/(3*256)',
''
]
return out
TRANSFORMS = {
'enum': transform_enum,
'dictionary': transform_dictionary,
'bitmask': transform_bitmask,
'ratio': transform_ratio,
'battery_charge_percent': transform_battery_charge_percent
}
+23
View File
@@ -0,0 +1,23 @@
import itertools
def batched(iterable, n):
"""
Batch data into iterators of length n. The last batch may be shorter.
This is a polyfill for itertools.batched() in Python 3.12+
"""
if n < 1:
raise ValueError('n must be at least one')
it = iter(iterable)
while True:
chunk_it = itertools.islice(it, n)
try:
first_el = next(chunk_it)
except StopIteration:
return
yield itertools.chain((first_el,), chunk_it)
def bitmask_to_list(intflag):
n = type(intflag).__name__
if not str(intflag).startswith(n):
return []
return str(intflag)[len(n)+1:].split('|')
+13 -4
View File
@@ -7,24 +7,33 @@ IOB = "IOB"
BOLUS_BG = "BOLUS_BG"
CGM = "CGM"
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,
BOLUS,
IOB
PUMP_EVENTS,
PROFILES
]
ALL_FEATURES = [
BASAL,
BOLUS,
IOB,
PUMP_EVENTS
PUMP_EVENTS,
PUMP_EVENTS_BASAL_SUSPENSION,
PROFILES,
CGM,
CGM_ALERTS,
DEVICE_STATUS,
]
# These modes are not yet ready for wide use.
if ENABLE_TESTING_MODES:
ALL_FEATURES += [
BOLUS_BG,
CGM
BOLUS_BG
]
+169 -46
View File
@@ -1,24 +1,40 @@
import sys
import datetime
import requests
import hashlib
import time
import urllib.parse
import arrow
import logging
from urllib.parse import urljoin
from .api.common import ApiException
from .parser.nightscout import ENTERED_BY
# try:
# from .secret import NS_URL, NS_SECRET
# except Exception:
# print('Unable to import Nightscout secrets from secret.py')
# sys.exit(1)
def format_datetime(date):
return arrow.get(date).isoformat()
def time_range(field_name, start_time, end_time, t_to_space=False):
def fmt(date):
ret = format_datetime(date)
if t_to_space:
return ret.replace('T', ' ')
return ret
arg = ''
if start_time:
arg += '&find[%s][$gte]=%s' % (field_name, fmt(start_time))
if end_time:
arg += '&find[%s][$lte]=%s' % (field_name, fmt(end_time))
return arg
logger = logging.getLogger(__name__)
class NightscoutApi:
def __init__(self, url, secret):
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'):
@@ -26,63 +42,156 @@ class NightscoutApi:
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout upload response: %s" % r.text)
raise ApiException(r.status_code, "Nightscout upload %s response: %s" % (r.status_code, r.text))
def delete_entry(self, entity):
r = requests.delete(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json={}, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout delete response: %s" % r.text)
raise ApiException(r.status_code, "Nightscout delete %s response: %s" % (r.status_code, r.text))
def put_entry(self, ns_format, entity):
r = requests.put(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout put response: %s" % r.text)
raise ApiException(r.status_code, "Nightscout put %s response: %s" % (r.status_code, r.text))
def last_uploaded_entry(self, eventType):
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry response: %s" % latest.text)
def last_uploaded_entry(self, eventType, time_start=None, time_end=None):
def internal(t_to_space):
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space)
latest = requests.get(urljoin(self.url, 'api/v1/treatments?count=1&find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[eventType]=' + urllib.parse.quote(eventType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout last_uploaded_entry %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (eventType, time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout last_uploaded_entry %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
def last_uploaded_bg_entry(self):
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry response: %s" % latest.text)
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
try:
ret = None
try:
ret = internal(False)
except ApiException as e:
#logger.warning("last_uploaded_entry with no t_to_space: %s", e)
ret = None
if ret is None and (time_start or time_end):
try:
ret = internal(True)
except ApiException as e:
#logger.warning("last_uploaded_entry with t_to_space: %s", e)
ret = None
if ret is not None:
logger.warning("last_uploaded_entry with eventType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (eventType, time_start, time_end))
return ret
except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
else:
raise e
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
def last_uploaded_bg_entry(self, time_start=None, time_end=None):
def internal(t_to_space):
dateFilter = time_range('dateString', time_start, time_end, t_to_space=t_to_space)
latest = requests.get(urljoin(self.url, 'api/v1/entries.json?count=1&find[device]=' + urllib.parse.quote(ENTERED_BY) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout last_uploaded_bg_entry could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry %s response: %s" % (latest.status_code, latest.text))
def last_uploaded_activity(self, activityType):
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
if latest.status_code != 200:
raise ApiException(latest.status_code, "Nightscout activity response: %s" % latest.text)
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("last_uploaded_bg_entry with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end))
return ret
except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
else:
raise e
def last_uploaded_activity(self, activityType, time_start=None, time_end=None):
def internal(t_to_space):
dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space)
latest = requests.get(urljoin(self.url, 'api/v1/activity?find[enteredBy]=' + urllib.parse.quote(ENTERED_BY) + '&find[activityType]=' + urllib.parse.quote(activityType) + dateFilter + '&ts=' + str(time.time())), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if latest.status_code != 200:
if 'as a valid ISO-8601 date' in latest.text:
logger.warning("Nightscout activity %s could not process ISO-8601 date: start=%s end=%s dateFilter=%s" % (activityType, time_start, time_end, dateFilter))
return None
raise ApiException(latest.status_code, "Nightscout activity %s response: %s" % (latest.status_code, latest.text))
j = latest.json()
if j and len(j) > 0:
return j[0]
return None
try:
ret = internal(False)
if ret is None and (time_start or time_end):
ret = internal(True)
if ret is not None:
logger.warning("last_uploaded_activity with activityType=%s time_start=%s time_end=%s only returned data when timestamps contained a space" % (activityType, time_start, time_end))
return ret
except requests.exceptions.ConnectionError as e:
if self.ignore_conn_errors:
logger.warn('Ignoring ConnectionError because ignore_conn_errors=true', e)
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.
@@ -90,7 +199,21 @@ class NightscoutApi:
def api_status(self):
status = requests.get(urljoin(self.url, 'api/v1/status.json'), headers={
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
})
}, verify=self.verify)
if status.status_code != 200:
raise Exception('HTTP error status code (%d) from Nightscout: %s' % (status.status_code, status.text))
return status.json()
return status.json()
"""
Returns information on the currently configured Nightscout profile data store
(contains all profiles in Nightscout under one mongo object).
"""
def current_profile(self, time_start=None, time_end=None):
r = requests.get(urljoin(self.url, 'api/v1/profile/current?api_secret=' + self.secret), json={}, headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'api-secret': hashlib.sha1(self.secret.encode()).hexdigest()
}, verify=self.verify)
if r.status_code != 200:
raise ApiException(r.status_code, "Nightscout current_profile %s response: %s" % (r.status_code, r.text))
return r.json()
+27
View File
@@ -0,0 +1,27 @@
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent, BGTherapyEvent, BasalTherapyEvent
from tconnectsync.parser.tconnect import TConnectEntry
import logging
logger = logging.getLogger(__name__)
def split_therapy_events(ciqTherapyEvents):
bolusEvents = []
cgmEvents = []
bgEvents = []
basalEvents = []
for e in ciqTherapyEvents['event']:
event = TConnectEntry.parse_therapy_event(e)
if isinstance(event, BolusTherapyEvent):
bolusEvents.append(event)
elif isinstance(event, CGMTherapyEvent):
cgmEvents.append(event)
elif isinstance(event, BGTherapyEvent):
bgEvents.append(event)
elif isinstance(event, BasalTherapyEvent):
basalEvents.append(event)
logger.debug("split_therapy_events: %d bolus, %d CGM, %d BG, %d basal" % (len(bolusEvents), len(cgmEvents), len(bgEvents), len(basalEvents)))
# TODO: BG events (CGM Calibration) values are not currently returned from ciq_therapy_events.py
return bolusEvents, cgmEvents
+251 -18
View File
@@ -1,14 +1,24 @@
import arrow
from ..domain.device_settings import Profile, DeviceSettings
from ..domain.tandemsource.pump_settings import PumpProfile, PumpSettings
from ..secret import TIMEZONE_NAME, NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE
ENTERED_BY = "Pump (tconnectsync)"
BASAL_EVENTTYPE = "Temp Basal"
BOLUS_EVENTTYPE = "Combo Bolus"
SITECHANGE_EVENTTYPE = "Site Change"
BASALSUSPENSION_EVENTTYPE = "Basal Suspension"
BASALRESUME_EVENTTYPE = "Basal Resume"
ACTIVITY_EVENTTYPE = "Activity"
EXERCISE_EVENTTYPE = "Exercise"
SLEEP_EVENTTYPE = "Sleep"
ALARM_EVENTTYPE = "Alarm"
CGM_ALERT_EVENTTYPE = "CGM Alert"
CGM_START_EVENTTYPE = "Sensor Start"
CGM_JOIN_EVENTTYPE = "Sensor Start"
CGM_STOP_EVENTTYPE = "Sensor Stop"
IOB_ACTIVITYTYPE = "tconnect_iob"
@@ -18,7 +28,7 @@ Conversion methods for parsing data into Nightscout objects.
"""
class NightscoutEntry:
@staticmethod
def basal(value, duration_mins, created_at, reason=""):
def basal(value, duration_mins, created_at, reason="", pump_event_id=""):
return {
"eventType": BASAL_EVENTTYPE,
"reason": reason,
@@ -28,7 +38,8 @@ class NightscoutEntry:
"created_at": created_at,
"carbs": None,
"insulin": None,
"enteredBy": ENTERED_BY
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
# Note that Nightscout is not consistent and uses "Sensor"/"Finger"
@@ -37,23 +48,29 @@ class NightscoutEntry:
FINGER = "Finger"
@staticmethod
def bolus(bolus, carbs, created_at, notes="", bg="", bg_type=""):
def bolus(bolus, carbs, created_at, notes="", bg="", bg_type="", pump_event_id=""):
data = {
"eventType": BOLUS_EVENTTYPE,
"created_at": created_at,
"carbs": int(carbs),
"carbs": int(carbs) if carbs else 0,
"insulin": float(bolus),
"notes": notes,
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
if bg:
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
raise InvalidBolusTypeException
if bg_type:
if bg_type not in (NightscoutEntry.SENSOR, NightscoutEntry.FINGER):
raise InvalidBolusTypeException("bg_type: %s (%s)" % (bg_type, data))
data.update({
"glucose": str(bg),
"glucoseType": bg_type
})
data.update({
"glucose": str(bg),
"glucoseType": bg_type
})
else:
data.update({
"glucose": str(bg)
})
return data
@staticmethod
@@ -64,48 +81,264 @@ class NightscoutEntry:
"created_at": created_at,
"enteredBy": ENTERED_BY
}
@staticmethod
def entry(sgv, created_at):
def entry(sgv, created_at, pump_event_id=""):
return {
"type": "sgv",
"sgv": int(sgv),
"date": int(1000 * arrow.get(created_at).timestamp()),
"dateString": arrow.get(created_at).strftime('%Y-%m-%dT%H:%M:%S%z'),
"device": ENTERED_BY,
"pump_event_id": pump_event_id,
# delta, direction are undefined
}
@staticmethod
def sitechange(created_at, reason=""):
def sitechange(created_at, reason="", pump_event_id=""):
return {
"eventType": SITECHANGE_EVENTTYPE,
"reason": reason,
"notes": reason,
"created_at": created_at,
"enteredBy": ENTERED_BY
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def basalsuspension(created_at, reason=""):
def basalsuspension(created_at, reason="", pump_event_id=""):
return {
"eventType": BASALSUSPENSION_EVENTTYPE,
"reason": reason,
"notes": reason,
"created_at": created_at,
"enteredBy": ENTERED_BY
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def activity(created_at, duration, reason="", event_type=ACTIVITY_EVENTTYPE):
def basalresume(created_at, pump_event_id=""):
return {
"eventType": BASALRESUME_EVENTTYPE,
"reason": "Basal resumed",
"notes": "Basal resumed",
"created_at": created_at,
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def alarm(created_at, reason="", pump_event_id=""):
return {
"eventType": ALARM_EVENTTYPE,
"reason": reason,
"notes": reason,
"created_at": created_at,
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def cgm_alert(created_at, reason="", pump_event_id=""):
return {
"eventType": CGM_ALERT_EVENTTYPE,
"reason": reason,
"notes": reason,
"created_at": created_at,
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def cgm_start(created_at, reason="", pump_event_id=""):
return {
"eventType": CGM_START_EVENTTYPE,
"reason": reason,
"notes": reason,
"created_at": created_at,
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def cgm_join(created_at, reason="", pump_event_id=""):
return {
"eventType": CGM_JOIN_EVENTTYPE,
"reason": reason,
"notes": reason,
"created_at": created_at,
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def cgm_stop(created_at, reason="", pump_event_id=""):
return {
"eventType": CGM_STOP_EVENTTYPE,
"reason": reason,
"notes": reason,
"created_at": created_at,
"enteredBy": ENTERED_BY,
"pump_event_id": pump_event_id
}
@staticmethod
def activity(created_at, duration, reason="", event_type=ACTIVITY_EVENTTYPE, pump_event_id=""):
return {
"eventType": event_type,
"reason": reason,
"notes": reason,
"duration": float(duration),
"created_at": created_at,
"enteredBy": ENTERED_BY
"enteredBy": ENTERED_BY,
"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:
return {
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
"dia": "%s" % (profile.insulin_duration_min / 60),
"carbratio": [
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.carb_ratio
} for segment in profile.segments
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [ # Correction factor
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.correction_factor
} for segment in profile.segments
],
"basal": [
{
"time": tandem_to_ns_time(segment.time),
"timeAsSeconds": tandem_to_ns_time_seconds(segment.time),
"value": segment.basal_rate
} for segment in profile.segments
],
"target_low": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": device_settings.low_bg_threshold
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": device_settings.high_bg_threshold
}
],
"timezone": TIMEZONE_NAME, # tconnectsync settings timezone
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
# TandemSource profile to Nightscout profile store entry
@staticmethod
def tandemsource_profile_store(profile: PumpProfile, pump_settings: PumpSettings) -> dict:
return {
# insulin duration in hours; Nightscout JS bug requires all top-level fields to be strings
"dia": "%s" % (profile.insulinDuration / 60),
"carbratio": list(sorted([
{
"time": minutes_to_ns_time(segment.startTime),
"timeAsSeconds": segment.startTime * 60,
"value": segment.carbRatio / 1000 # milliunits->units
} for segment in profile.tDependentSegs if not segment.skip
], key=lambda x: x["timeAsSeconds"])),
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"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 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",
"timeAsSeconds": 0,
"value": pump_settings.cgmSettings.lowGlucoseAlert.mgPerDl
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": pump_settings.cgmSettings.highGlucoseAlert.mgPerDl
}
],
"timezone": TIMEZONE_NAME, # tconnectsync settings timezone
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
def tandem_to_ns_time(tandem_time: str) -> str:
numbers, ampm = tandem_time.split(' ')
hr, min = numbers.split(':')
if ampm.lower().strip() == 'am':
return "%02d:%02d" % (int(hr) % 12, int(min))
elif ampm.lower().strip() == 'pm':
return "%02d:%02d" % (12 + (int(hr) % 12), int(min))
raise InvalidTimeException(tandem_time)
def tandem_to_ns_time_seconds(tandem_time: str) -> int:
numbers, ampm = tandem_time.split(' ')
hr, min = numbers.split(':')
if ampm.lower().strip() == 'am':
return 60 * (60 * (int(hr) % 12) + int(min))
elif ampm.lower().strip() == 'pm':
return 60 * (60 * (12 + (int(hr) % 12)) + int(min))
raise InvalidTimeException(tandem_time)
def minutes_to_ns_time(minutes_time: int) -> str:
hr = minutes_time // 60
mn = minutes_time % 60
return "%02d:%02d" % (hr, mn)
class InvalidBolusTypeException(RuntimeError):
pass
class InvalidTimeException(RuntimeError):
pass
+35 -7
View File
@@ -1,6 +1,9 @@
from os import stat
import sys
import arrow
from tconnectsync.domain.bolus import Bolus
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent, BGTherapyEvent, BasalTherapyEvent
try:
from ..secret import TIMEZONE_NAME
@@ -105,7 +108,7 @@ class TConnectEntry:
complete = is_complete(data["ExtendedBolusIsComplete"]) or is_complete(data["BolusIsComplete"])
extended_bolus = ("extended" in data["Description"].lower())
return {
return Bolus(**{
"description": data["Description"],
"complete": "1" if complete else "",
"completion": data["CompletionStatusDesc"] if not extended_bolus else data["BolexCompletionStatusDesc"],
@@ -117,9 +120,10 @@ class TConnectEntry:
"bg": data["BG"], # Note: can be empty string for automatic Control-IQ boluses
"user_override": data["UserOverride"],
"extended_bolus": "1" if extended_bolus else "",
"bolex_completion_time": TConnectEntry._datetime_parse(data["BolexCompletionDateTime"]).format() if complete and extended_bolus else None,
"bolex_start_time": TConnectEntry._datetime_parse(data["BolexStartDateTime"]).format() if complete and extended_bolus else None,
}
# Note: completion time can be empty if the extended bolus is in progress
"bolex_completion_time": TConnectEntry._datetime_parse(data["BolexCompletionDateTime"]).format() if data["BolexCompletionDateTime"] and complete and extended_bolus else None,
"bolex_start_time": TConnectEntry._datetime_parse(data["BolexStartDateTime"]).format() if data["BolexStartDateTime"] and complete and extended_bolus else None,
})
@staticmethod
def parse_reading_entry(data):
@@ -156,7 +160,10 @@ class TConnectEntry:
"alarm": "Empty Cartridge/Pump Shutdown",
# manual corresponds to a Pumping Suspended by User event
"manual": "User Suspended"
"manual": "User Suspended",
# temp-profile corresponds to a Basal Rate Change event to 0u/hr
"temp-profile": "Basal Rate Change"
}
BASALSUSPENSION_SKIPPED_EVENTS = {
@@ -171,6 +178,9 @@ class TConnectEntry:
}
@staticmethod
def parse_basalsuspension_event(data):
if not data or "SuspendReason" not in data:
return None
if data["SuspendReason"] in TConnectEntry.BASALSUSPENSION_SKIPPED_EVENTS:
return None
@@ -182,7 +192,20 @@ class TConnectEntry:
"time": time.format(),
"event_type": TConnectEntry.BASALSUSPENSION_EVENTS[data["SuspendReason"]]
}
# Parses an entry from controliq.therapy_events() and returns a TherapyEvent
@staticmethod
def parse_therapy_event(data):
if data["type"] == "Bolus":
return BolusTherapyEvent.parse(data)
elif data["type"] == "CGM":
return CGMTherapyEvent.parse(data)
elif data["type"] == "BG":
return BGTherapyEvent.parse(data)
elif data["type"] == "Basal":
return BasalTherapyEvent.parse(data)
raise UnknownTherapyEventException(data)
class UnknownCIQActivityEventException(Exception):
def __init__(self, data):
@@ -190,4 +213,9 @@ class UnknownCIQActivityEventException(Exception):
class UnknownBasalSuspensionEventException(Exception):
def __init__(self, data):
super().__init__("Unknown basal suspension event type: %s" % data)
super().__init__("Unknown basal suspension event type: %s" % data)
class UnknownTherapyEventException(Exception):
def __init__(self, data):
typ = data["type"]
super().__init__(f"Unknown therapy event type: {typ} in {data}")
+136 -47
View File
@@ -3,6 +3,8 @@ import datetime
import arrow
import time
from tconnectsync.parser.ciq_therapy_events import split_therapy_events
from .util import timeago
from .api.common import ApiException
from .sync.basal import (
@@ -27,8 +29,10 @@ from .sync.pump_events import (
process_basalsuspension_events,
ns_write_pump_events
)
from .sync.profile import process_profiles
from .parser.tconnect import TConnectEntry
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS
from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS, PROFILES, PUMP_EVENTS_BASAL_SUSPENSION
from tconnectsync.sync import basal
logger = logging.getLogger(__name__)
@@ -38,45 +42,95 @@ cycle of synchronizing data within the time range.
If pretend is true, then doesn't actually write data to Nightscout.
"""
def process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES):
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
# device in the time range which is queried. Since it launched in early 2020,
# ignore 404's before February.
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqTherapyTimelineData = None
ciqTherapyTimelineData = None
if BASAL in features or PUMP_EVENTS in features:
logger.info("Downloading t:connect ControlIQ data")
try:
ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end)
except ApiException as e:
# The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled
# device in the time range which is queried. Since it launched in early 2020,
# ignore 404's before February.
if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1):
logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020")
ciqTherapyTimelineData = None
else:
raise e
csvReadingData = None
csvIobData = None
csvBasalData = None
csvBolusData = None
ciqBolusData = None
ciqReadingData = None
if BOLUS in features:
logger.info("Downloading t:connect therapy_events")
ciqTherapyEventsData = tconnect.controliq.therapy_events(time_start, time_end)
ciqBolusData, ciqReadingData = split_therapy_events(ciqTherapyEventsData)
if ciqReadingData and len(ciqReadingData) > 0:
lastReading = ciqReadingData[-1].eventDateTime
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(ciqReadingData[-1])
logger.info("Last CGM reading from t:connect CIQ: %s (%s)" % (lastReading, timeago(lastReading)))
else:
raise e
logger.warning("No last CGM reading is able to be determined from CIQ")
if ciqBolusData and len(ciqBolusData) > 0:
lastBolus = ciqBolusData[-1].eventDateTime
lastReading = TConnectEntry._datetime_parse(lastBolus)
logger.debug(ciqBolusData[-1].to_bolus())
logger.info("Last bolus from t:connect CIQ: %s (%s)" % (lastBolus, timeago(lastBolus)))
logger.info("Downloading t:connect CSV data")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
bolusFallingBack = (BOLUS in features and not ciqBolusData)
ciqFallingBack = (CGM in features and not ciqReadingData)
if bolusFallingBack or \
ciqFallingBack or \
BOLUS_BG in features or \
IOB in features:
logger.warning("Downloading t:connect CSV data")
if bolusFallingBack:
logger.warning("Falling back on WS2 CSV data source because BOLUS is an enabled feature and CIQ bolus data was empty!!")
if ciqFallingBack:
logger.warning("Falling back on WS2 CSV data source because CGM is an enabled feature and CIQ cgm data was empty!!")
if BOLUS_BG in features:
logger.warning("Falling back on WS2 CSV data source because BOLUS_BG is an enabled feature. " +
"Please consider disabling this feature to improve synchronization reliability.")
if IOB in features:
logger.warning("Falling back on WS2 CSV data source because IOB is an enabled feature. " +
"Please consider disabling this feature to improve synchronization reliability.")
logger.warning("<!!> The WS2 data source is unreliable and may prevent timely synchronization")
csvdata = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
readingData = csvdata["readingData"]
iobData = csvdata["iobData"]
csvBasalData = csvdata["basalData"]
bolusData = csvdata["bolusData"]
csvReadingData = csvdata["readingData"]
csvIobData = csvdata["iobData"]
csvBasalData = csvdata["basalData"]
csvBolusData = csvdata["bolusData"]
if csvReadingData and len(csvReadingData) > 0:
lastReading = csvReadingData[-1]['EventDateTime'] if 'EventDateTime' in csvReadingData[-1] else 0
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(csvReadingData[-1])
logger.info("Last CGM reading from t:connect CSV: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined from CSV")
if readingData and len(readingData) > 0:
lastReading = readingData[-1]['EventDateTime'] if 'EventDateTime' in readingData[-1] else 0
lastReading = TConnectEntry._datetime_parse(lastReading)
logger.debug(readingData[-1])
logger.info("Last CGM reading from t:connect: %s (%s)" % (lastReading, timeago(lastReading)))
else:
logger.warning("No last CGM reading is able to be determined")
added = 0
cgmData = None
if CGM in features or BOLUS_BG in features:
logger.debug("Processing CGM events")
cgmData = process_cgm_events(readingData)
if CGM in features:
logger.debug("Writing CGM events")
added += ns_write_cgm_events(nightscout, cgmData, pretend)
if csvReadingData:
cgmData = None
if CGM in features or BOLUS_BG in features:
logger.debug("Processing CGM events")
cgmData = process_cgm_events(csvReadingData)
if CGM in features:
logger.debug("Writing CGM events")
added += ns_write_cgm_events(nightscout, cgmData, pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing CGM events")
if BASAL in features:
basalEvents = process_ciq_basal_events(ciqTherapyTimelineData)
@@ -85,29 +139,64 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat
add_csv_basal_events(basalEvents, csvBasalData)
else:
logger.debug("No CSV basal data found")
if basalEvents and len(basalEvents) > 0:
logger.info("Last basal event from CIQ: %s" % basalEvents[-1])
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend)
if PUMP_EVENTS in features:
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
logger.debug("CIQ activity events: %s" % pumpEvents)
logger.debug("Writing basal events")
added += ns_write_basal_events(nightscout, basalEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
if PUMP_EVENTS_BASAL_SUSPENSION in features:
logger.warning("Using WS2 data source for basalsuspension because PUMP_EVENTS_BASAL_SUSPENSION is an enabled feature")
logger.warning("<!!> The WS2 data source is unreliable and may prevent timely synchronization")
ws2BasalSuspension = tconnect.ws2.basalsuspension(time_start, time_end)
bsPumpEvents = process_basalsuspension_events(ws2BasalSuspension)
logger.debug("basalsuspension events: %s" % bsPumpEvents)
pumpEvents += bsPumpEvents
logger.debug("Writing pump basalsuspension events")
added += ns_write_pump_events(nightscout, bsPumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend)
if PUMP_EVENTS in features:
pumpEvents = process_ciq_activity_events(ciqTherapyTimelineData)
logger.debug("CIQ activity events: %s" % pumpEvents)
logger.debug("Writing pump events")
added += ns_write_pump_events(nightscout, pumpEvents, pretend=pretend, time_start=time_start, time_end=time_end)
logger.debug("Finished writing basal events")
if BOLUS in features:
bolusEvents = process_bolus_events(bolusData)
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features))
bolusEvents = []
if ciqBolusData:
logger.info("Processing ciqBolusData (%d entries)" % len(ciqBolusData))
bolusEvents = process_bolus_events(ciqBolusData, source="ciq")
if IOB in features:
iobEvents = process_iob_events(iobData)
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
if csvBolusData and not bolusEvents:
logger.warning("Falling back on non-CIQ csvBolusData")
bolusEvents = process_bolus_events(csvBolusData, source="csv")
logger.debug("ciq bolusEvents: %s" % bolusEvents)
logger.info("finalized bolusEvents: %s" % bolusEvents)
logger.debug("Writing bolus events")
added += ns_write_bolus_events(nightscout, bolusEvents, pretend=pretend, include_bg=(BOLUS_BG in features), time_start=time_start, time_end=time_end)
logger.debug("Finished writing bolus events")
logger.info("Wrote %d events to Nightscout this process cycle" % added)
return added
if csvIobData:
if IOB in features:
iobEvents = process_iob_events(csvIobData)
logger.debug("Writing iob events")
added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend)
logger.debug("Finished writing iob events")
if PROFILES in features:
logger.debug("Running profiles feature")
if process_profiles(tconnect, nightscout, pretend=pretend):
added += 1
if pretend:
logger.info("Would have written %d events to Nightscout this process cycle (in pretend mode)" % added)
else:
logger.info("Wrote %d events to Nightscout this process cycle" % added)
return added
+58 -8
View File
@@ -1,15 +1,36 @@
import os, sys
from dotenv import load_dotenv
import os, sys, pathlib
from dotenv import dotenv_values
load_dotenv()
cwd_path = os.path.join(os.getcwd(), '.env')
global_path = os.path.join(pathlib.Path.home(), '.config/tconnectsync/.env')
def get(*args):
return os.environ.get(*args)
cwd_creds_path = os.path.join(os.getcwd(), '.creds_cache')
global_creds_path = os.path.join(pathlib.Path.home(), '.config/tconnectsync/.creds_cache')
values = {}
if os.path.exists(cwd_path):
values = dotenv_values(cwd_path)
elif os.path.exists(global_path):
values = dotenv_values(global_path)
else:
values = dotenv_values()
def get(val, default=None):
return os.environ.get(val, values.get(val, default))
def get_one_of(name, default=None, options=[]):
val = get(name, default)
if val not in options:
print("Error: %s must be one of: %s" % (name, options))
print("Current value: %s" % val)
sys.exit(1)
return val
def get_number(name, default):
val = get(name, default)
try:
return int(val)
return float(val)
except ValueError:
print("Error: %s must be a number." % name)
print("Current value: %s" % val)
@@ -20,24 +41,53 @@ 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 = get_number('PUMP_SERIAL_NUMBER', '11111111')
PUMP_SERIAL_NUMBER = int(get_number('PUMP_SERIAL_NUMBER', '11111111'))
NS_URL = get('NS_URL', 'https://yournightscouturl/')
NS_SECRET = get('NS_SECRET', 'apisecret')
if not get('NS_SECRET') and get('API_SECRET'):
print('API_SECRET environment variable is set, overriding NS_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')
# 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'):
print('TZ environment variable is set, overriding TIMEZONE_NAME')
TIMEZONE_NAME = get('TZ')
# Optional configuration
CACHE_CREDENTIALS = get_bool('CACHE_CREDENTIALS', 'true')
CACHE_CREDENTIALS_PATH = get('CACHE_CREDENTIALS', cwd_creds_path if os.path.exists(cwd_creds_path) else global_creds_path)
AUTOUPDATE_DEFAULT_SLEEP_SECONDS = get_number('AUTOUPDATE_DEFAULT_SLEEP_SECONDS', '300') # 5 minutes
AUTOUPDATE_MAX_SLEEP_SECONDS = get_number('AUTOUPDATE_MAX_SLEEP_SECONDS', '1500') # 25 minutes
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_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '180') # 3 hours
AUTOUPDATE_NO_DATA_FAILURE_MINUTES = get_number('AUTOUPDATE_NO_DATA_FAILURE_MINUTES', '180') # 3 hours
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '75') # 75 minutes
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
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')
REQUESTS_PROXY = get('REQUESTS_PROXY', '')
if __name__ == '__main__':
for k in locals():
-161
View File
@@ -1,161 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
BASAL_EVENTTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
from ..secret import SKIP_NS_LAST_UPLOADED_CHECK
logger = logging.getLogger(__name__)
"""
Merges together input from the therapy timeline API
into a digestable format of basal data.
"""
def process_ciq_basal_events(data):
if data is None:
return []
suspensionEvents = {}
for s in data["suspensionDeliveryEvents"]:
entry = TConnectEntry.parse_suspension_entry(s)
suspensionEvents[entry["time"]] = entry
basalEvents = []
for b in data["basal"]["tempDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="tempDelivery"))
for b in data["basal"]["algorithmDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="algorithmDelivery"))
for b in data["basal"]["profileDeliveryEvents"]:
basalEvents.append(TConnectEntry.parse_ciq_basal_entry(b, delivery_type="profileDelivery"))
# Suspensions with suspendReason 'control-iq' will match a basal event found above.
for i in basalEvents:
if i["time"] in suspensionEvents:
i["delivery_type"] += " (" + suspensionEvents[i["time"]]["suspendReason"] + " suspension)"
del suspensionEvents[i["time"]]
# Suspensions with suspendReason 'manual' do not have an associated basal event,
# and require extra processing.
basalEvents.sort(key=lambda x: arrow.get(x["time"]))
unprocessedSuspensions = list(suspensionEvents.values())
unprocessedSuspensions.sort(key=lambda x: arrow.get(x["time"]))
# For the remaining suspensions which did not match with an existing basal event,
# add a new event manually. This means we need to calculate the duration of the
# suspension.
newEvents = []
for i in range(len(basalEvents)):
if len(unprocessedSuspensions) == 0:
break
existingTime = arrow.get(basalEvents[i]["time"])
unprocessedTime = arrow.get(unprocessedSuspensions[0]["time"])
# If we've found an event which occurs after the suspension, then the
# difference in their timestamps is the duration of the suspension.
if i > 0 and existingTime > unprocessedTime:
suspension = unprocessedSuspensions.pop(0)
# TConnect's internal duration object tracks the duration in seconds
seconds = (existingTime - unprocessedTime).seconds
newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds)
logger.debug("Creating 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):
logger.debug("ns_write_basal_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BASAL_EVENTTYPE)
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" % event)
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
-104
View File
@@ -1,104 +0,0 @@
import arrow
import logging
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):
bolusEvents = []
for b in bolusdata:
parsed = TConnectEntry.parse_bolus_entry(b)
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 bolus data (was a bolus in progress?): %s parsed: %s" % (b, parsed))
continue
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["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):
logger.debug("ns_write_bolus_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_entry(BOLUS_EVENTTYPE)
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["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" % event)
continue
if include_bg and event["bg"]:
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
created_at=created_at,
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else ""),
bg=event["bg"],
bg_type=event["bg_type"]
)
else:
entry = NightscoutEntry.bolus(
bolus=event["insulin"],
carbs=event["carbs"],
created_at=created_at,
notes="{}{}{}".format(event["description"], " (Override)" if event["user_override"] == "1" else "", " (Extended)" if event["extended_bolus"] == "1" else "")
)
add_count += 1
logger.info(" Processing bolus: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry)
return add_count
-69
View File
@@ -1,69 +0,0 @@
import json
import arrow
import logging
from ..parser.tconnect import TConnectEntry
from ..parser.nightscout import NightscoutEntry
logger = logging.getLogger(__name__)
def process_cgm_events(readingData):
data = []
for r in readingData:
data.append(TConnectEntry.parse_reading_entry(r))
return data
"""
Given reading data and a time, finds the BG reading event which would have
been the current one at that time. e.g., it looks before the given time,
not after.
This is a heuristic for checking whether the BG component of a bolus was
manually entered or inferred based on the pump's CGM.
"""
def find_event_at(cgmEvents, find_time):
find_t = arrow.get(find_time)
events = list(map(lambda x: (arrow.get(x["time"]), x), cgmEvents))
events.sort()
closestReading = None
for t, r in events:
if t > find_t:
break
closestReading = r
return closestReading
"""
Given processed CGM data, adds reading entries to Nightscout.
"""
def ns_write_cgm_events(nightscout, cgmEvents, pretend=False):
logger.debug("ns_write_cgm_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_bg_entry()
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" % event)
continue
entry = NightscoutEntry.entry(
sgv=event["bg"],
created_at=created_at
)
add_count += 1
logger.info(" Processing cgm reading: %s entry: %s" % (event, entry))
if not pretend:
nightscout.upload_entry(entry, entity='entries')
return add_count
-59
View File
@@ -1,59 +0,0 @@
import arrow
import logging
from ..parser.nightscout import (
IOB_ACTIVITYTYPE,
NightscoutEntry
)
from ..parser.tconnect import TConnectEntry
logger = logging.getLogger(__name__)
"""
Given IOB data input from the therapy timeline CSV, converts it into a digestable format.
"""
def process_iob_events(iobdata):
iobEvents = []
for d in iobdata:
iobEvents.append(TConnectEntry.parse_iob_entry(d))
iobEvents.sort(key=lambda x: arrow.get(x["time"]))
return iobEvents
"""
Given processed IOB data, creates a single Nightscout activity definition to store IOB.
"""
def ns_write_iob_events(nightscout, iobEvents, pretend=False):
logger.debug("ns_write_iob_events: querying for last uploaded entry")
last_upload = nightscout.last_uploaded_activity(IOB_ACTIVITYTYPE)
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
-206
View File
@@ -1,206 +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):
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)
count += ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=pretend)
count += ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=pretend)
count += ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=pretend)
count += ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=pretend)
count += ns_write_activity_events(nightscout, activityEvents, pretend=pretend)
return count
def ns_write_pump_sitechange_events(nightscout, siteChangeEvents, pretend=False):
return _ns_write_pump_events(
nightscout,
siteChangeEvents,
lambda event: NightscoutEntry.sitechange(
created_at=event["time"],
reason=event["event_type"]
),
SITECHANGE_EVENTTYPE,
pretend=pretend)
def ns_write_empty_cart_events(nightscout, emptyCartEvents, pretend=False):
return _ns_write_pump_events(
nightscout,
emptyCartEvents,
lambda event: NightscoutEntry.basalsuspension(
created_at=event["time"],
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend)
def ns_write_user_suspended_events(nightscout, userSuspendedEvents, pretend=False):
return _ns_write_pump_events(
nightscout,
userSuspendedEvents,
lambda event: NightscoutEntry.basalsuspension(
created_at=event["time"],
reason=event["event_type"]
),
BASALSUSPENSION_EVENTTYPE,
pretend=pretend)
def ns_write_exercise_activity_events(nightscout, exerciseEvents, pretend=False):
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)
def ns_write_sleep_activity_events(nightscout, sleepEvents, pretend=False):
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)
def ns_write_activity_events(nightscout, activityEvents, pretend=False):
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)
def _ns_write_pump_events(nightscout, events, buildNsEventFunc, eventType, pretend=False):
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)
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 = event["time"]
if last_upload_time and arrow.get(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"]):
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 event before last upload time: %s" % (eventType, event))
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
@@ -0,0 +1,180 @@
import time
import logging
import datetime
import sys
import arrow
from ...features import DEFAULT_FEATURES
from .process import ProcessTimeRange
from .choose_device import ChooseDevice
logger = logging.getLogger(__name__)
class TandemSourceAutoupdate:
"""Wrap access to secrets for easier testing."""
def __init__(self, secret):
self.secret = secret
self.autoupdate_invocations = 0
self.last_max_date_with_events = None
self.last_event_time = 0
self.last_attempt_time = 0
self.last_event_seqnum = None
self.time_diffs_between_attempts = []
self.time_diffs_between_updates = []
"""
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
# Query for data, find exact interval to cut down on API calls
# Refresh API token. If failure, die, have wrapper script re-run.
self.autoupdate_start = time.time()
while True:
logger.debug("autoupdate loop")
now = time.time()
tconnectDevice = ChooseDevice(self.secret, tconnect).choose()
event_seqnum = None
cur_max_date_with_events = arrow.get(tconnectDevice['maxDateWithEvents']).float_timestamp
if not self.last_max_date_with_events or cur_max_date_with_events > self.last_max_date_with_events:
logger.info('New reported tandemsource data. (cur_max_date: %s last_max_date: %s)' % (cur_max_date_with_events, self.last_max_date_with_events))
if pretend:
logger.info('Would update now if not in pretend mode')
else:
added, event_seqnum = ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, self.secret, features=features).process(time_start, time_end)
logger.info('Added %d items from ProcessTimeRange' % added)
self.last_successful_process_time_range = now
# Track the time it took to find a new event between runs,
# but skip this calculation the first process cycle (since
# we don't know at what exact point the event index changed)
if self.last_event_seqnum:
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_seqnum:
self.last_event_seqnum = event_seqnum
self.last_event_time = now
self.last_max_date_with_events = cur_max_date_with_events
self.last_attempt_time = now
self.time_diffs_between_attempts = []
else:
logger.info('No new reported tandemsource data. cur_max_date: %s (%dm ago) last_event_time: %s (%dm ago)' % (
arrow.get(cur_max_date_with_events) if cur_max_date_with_events else None,
(now - cur_max_date_with_events)//60 if cur_max_date_with_events else None,
arrow.get(self.last_event_time) if self.last_event_time else None,
(now - self.last_event_time)//60 if self.last_event_time else None
))
# If we haven't seen the pump event index update in AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
# then trigger an error and potentially restart.
# The most likely case here is that the pump isn't uploading right now.
if self.last_event_time and (now - self.last_event_time) >= 60 * self.secret.AUTOUPDATE_NO_DATA_FAILURE_MINUTES:
logger.error(AutoupdateNoEventIndexesDetectedError(
"%s: No new data event indexes have been detected for %d minutes. " % (datetime.datetime.now(), (now - self.last_event_time)//60) +
"New data might not be uploading."))
# 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 (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:
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
@@ -0,0 +1,62 @@
import arrow
import logging
logger = logging.getLogger(__name__)
class ChooseDevice:
def __init__(self, secret, tconnect):
self.secret = secret
self.tconnect = tconnect
def choose(self):
tconnect = self.tconnect
pumpEventMetadata = tconnect.tandemsource.pump_event_metadata()
serialNumberToPump = {p['serialNumber']: p for p in pumpEventMetadata}
logger.info(f'Found {len(serialNumberToPump)} pumps: {serialNumberToPump.keys()}')
tconnectDevice = None
if self.secret.PUMP_SERIAL_NUMBER and str(self.secret.PUMP_SERIAL_NUMBER) != '11111111':
if not str(self.secret.PUMP_SERIAL_NUMBER) in serialNumberToPump.keys():
raise InvalidSerialNumber(f'Serial number {self.secret.PUMP_SERIAL_NUMBER} is not present on your account: choose one of {", ".join(serialNumberToPump.keys())}')
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
for pump in pumpEventMetadata:
if not tconnectDevice:
tconnectDevice = pump
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
else:
if arrow.get(pump['maxDateWithEvents']) > maxDateSeen:
maxDateSeen = arrow.get(pump['maxDateWithEvents'])
tconnectDevice = pump
logger.info(f'Using most recent pump (serial: {tconnectDevice["serialNumber"]}, tconnectDeviceId: {tconnectDevice["tconnectDeviceId"]}, last seen: {tconnectDevice["maxDateWithEvents"]})')
return tconnectDevice
class InvalidSerialNumber(RuntimeError):
def __str__(self):
return "%s: %s" % (self.__class__.__name__, super().__str__())
@@ -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)
@@ -0,0 +1,8 @@
def insulin_float_round(amt):
if type(amt) != float:
return amt
return round(amt, 2)
def insulin_milliunits_to_real(amtMilli):
return insulin_float_round(amtMilli / 1000)
@@ -0,0 +1,17 @@
from ...features import DEFAULT_FEATURES
from .choose_device import ChooseDevice
from .process import ProcessTimeRange
from ... import secret
import datetime
def run_oneshot(tconnect, nightscout, pretend=False, features=DEFAULT_FEATURES, secret_arg=None, time_start=None, time_end=None):
if not time_start and not time_end:
time_end = datetime.datetime.now()
time_start = time_end - datetime.timedelta(days=1)
if not secret_arg:
secret_arg = secret
tconnectDevice = ChooseDevice(secret_arg, tconnect).choose()
return ProcessTimeRange(tconnect, nightscout, tconnectDevice, pretend, secret_arg, features).process(time_start, time_end)
+102
View File
@@ -0,0 +1,102 @@
import logging
import collections
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
from .process_basal_suspension import ProcessBasalSuspension
from .process_basal_resume import ProcessBasalResume
from .process_alarm import ProcessAlarm
from .process_bolus import ProcessBolus
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, 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 = {
EventClass.BASAL.name: ProcessBasal,
EventClass.BASAL_SUSPENSION.name: ProcessBasalSuspension,
EventClass.BASAL_RESUME.name: ProcessBasalResume,
EventClass.ALARM.name: ProcessAlarm,
EventClass.BOLUS.name: ProcessBolus,
EventClass.CARTRIDGE.name: ProcessCartridge,
EventClass.CGM_ALERT.name: ProcessCGMAlert,
EventClass.CGM_START_JOIN_STOP.name: ProcessCGMStartJoinStop,
EventClass.CGM_READING.name: ProcessCGMReading,
EventClass.USER_MODE.name: ProcessUserMode,
EventClass.DEVICE_STATUS.name: ProcessDeviceStatus
}
updater_classes = [
UpdateProfiles
]
def process(self, time_start, time_end):
fetch_all_event_types = self.secret.FETCH_ALL_EVENT_TYPES or DEVICE_STATUS in self.features
logger.info(f"ProcessTimeRange time_start={time_start} time_end={time_end} tconnect_device_id={self.tconnect_device_id} features={self.features} fetch_all_event_types={fetch_all_event_types}")
events = self.tconnect.tandemsource.pump_events(self.tconnect_device_id, time_start, time_end, fetch_all_event_types=fetch_all_event_types)
events_first_time = None
events_last_time = None
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()}
logger.info(f"Found events: {count_by_eventclass}")
processed_count = 0
for clazz, events in for_eventclass.items():
if clazz in self.event_classes.keys():
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)
w = c.write(ns_entries)
if w:
processed_count += w
else:
logger.info("Skipping %s, is not enabled from features %s" % (clazz, self.features))
for updater_class in self.updater_classes:
c = updater_class(self.tconnect, self.nightscout, self.tconnect_device_id, self.pretend, self.features)
if c.enabled():
logger.info("%s is enabled from features %s" % (updater_class.__name__, self.features))
done = c.update(self.pretend)
logger.info("%s completed with update required: %s" % (updater_class.__name__, done))
else:
logger.info("Skipping %s, is not enabled from features %s" % (updater_class.__name__, self.features))
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
@@ -0,0 +1,82 @@
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 (
ALARM_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessAlarm:
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.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessAlarm: querying for last uploaded alarm")
last_upload = self.nightscout.last_uploaded_entry(ALARM_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout alarm upload: %s" % last_upload_time)
ns_entries = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
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:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def alarm_to_nsentry(self, event):
if type(event) == eventtypes.LidAlarmActivated:
return NightscoutEntry.alarm(
created_at = event.eventTimestamp.format(),
reason = "%s" % event.alarmid.name,
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.seqNum
)
@@ -0,0 +1,101 @@
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
from ...eventparser.utils import bitmask_to_list
from ...eventparser import events as eventtypes
from .helpers import insulin_float_round, insulin_milliunits_to_real
from ...domain.tandemsource.event_class import EventClass
from ...parser.nightscout import (
BASAL_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessBasal:
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.BASAL in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessBasal: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(BASAL_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout basal upload: %s" % last_upload_time)
with_duration = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping basal event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
with_duration.append([event.eventTimestamp, None, event])
if not with_duration:
logger.info("No basal events found to process")
return []
for i in range(len(with_duration)-1):
with_duration[i][1] = with_duration[i+1][0] - with_duration[i][0]
with_duration[-1][1] = time_end - with_duration[-1][0]
ns_entries = []
for item in with_duration:
ns = self.basal_to_nsentry(*item)
if ns:
ns_entries.append(ns)
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
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 = value,
duration_mins = duration.seconds / 60,
created_at = start.format(),
reason = ', '.join(bitmask_to_list(event.changetype)),
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 = value,
duration_mins = duration.seconds / 60,
created_at = start.format(),
reason = ', '.join(bitmask_to_list(event.commandedRateSource)),
pump_event_id = "%s" % event.seqNum
)
@@ -0,0 +1,66 @@
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 (
BASALRESUME_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessBasalResume:
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.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessBasalResume: querying for last uploaded resume-suspension")
last_upload = self.nightscout.last_uploaded_entry(BASALRESUME_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout BasalResume upload: %s" % last_upload_time)
ns_entries = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping BasalResume event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
ns_entries.append(self.resume_to_nsentry(event))
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def resume_to_nsentry(self, event):
if type(event) == eventtypes.LidPumpingResumed:
return NightscoutEntry.basalresume(
created_at = event.eventTimestamp.format(),
pump_event_id = "%s" % event.seqNum
)
@@ -0,0 +1,67 @@
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 (
BASALSUSPENSION_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessBasalSuspension:
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.PUMP_EVENTS in self.features or features.BASAL in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessBasalSuspension: querying for last uploaded suspension")
last_upload = self.nightscout.last_uploaded_entry(BASALSUSPENSION_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout basalsuspension upload: %s" % last_upload_time)
ns_entries = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping basalsuspension event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
ns_entries.append(self.suspension_to_nsentry(event))
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def suspension_to_nsentry(self, event):
if type(event) == eventtypes.LidPumpingSuspended:
return NightscoutEntry.basalsuspension(
created_at = event.eventTimestamp.format(),
reason = ', '.join(bitmask_to_list(event.suspendreason)),
pump_event_id = "%s" % event.seqNum
)
@@ -0,0 +1,112 @@
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 .helpers import insulin_float_round
from ...parser.nightscout import (
BOLUS_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessBolus:
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.BOLUS in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessBolus: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(BOLUS_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout bolus upload: %s" % last_upload_time)
# TODO EXTENDED BOLUSES
bolusCompletedEvents = []
bolusEventsForId = {}
for event in sorted(events, key=lambda x: x.eventTimestamp):
if event.bolusid not in bolusEventsForId.keys():
bolusEventsForId[event.bolusid] = {}
bolusEventsForId[event.bolusid][type(event)] = event
if type(event) == eventtypes.LidBolusCompleted:
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping bolusCompletedEvent not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
bolusCompletedEvents.append(event)
bolusCompletedEvents.sort(key=lambda e: e.eventTimestamp)
ns_entries = []
for bolusCompleted in bolusCompletedEvents:
m = bolusEventsForId[bolusCompleted.bolusid]
ns_entries.append(self.bolus_to_nsentry(
bolusCompleted,
bolusRequested1 = m.get(eventtypes.LidBolusRequestedMsg1),
bolusRequested2 = m.get(eventtypes.LidBolusRequestedMsg2),
bolusRequested3 = m.get(eventtypes.LidBolusRequestedMsg3),
))
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def bolus_to_nsentry(self, bolusCompleted, bolusRequested1, bolusRequested2, bolusRequested3):
suffixes = []
if bolusRequested2 and bolusRequested2.useroverride == eventtypes.LidBolusRequestedMsg2.UseroverrideEnum.Yes:
suffixes.append('(Override)')
if bolusRequested2 and bolusRequested2.declinedcorrection == eventtypes.LidBolusRequestedMsg2.DeclinedcorrectionEnum.Yes:
suffixes.append('(Declined Correction)')
suffix = (' ' + (' '.join(suffixes))) if suffixes else ''
seq_nums = []
for e in [bolusCompleted, bolusRequested1, bolusRequested2, bolusRequested3]:
if e:
seq_nums.append(str(e.seqNum))
notes = ''
if bolusRequested2 and str(bolusRequested2.optionsRaw) in eventtypes.LidBolusRequestedMsg2.OptionsMap:
notes = eventtypes.LidBolusRequestedMsg2.OptionsMap['%d' % bolusRequested2.optionsRaw]
return NightscoutEntry.bolus(
bolus = insulin_float_round(bolusCompleted.insulindelivered),
carbs = bolusRequested1.carbamount if bolusRequested1 and bolusRequested1.carbamount>0 else None,
created_at = bolusCompleted.eventTimestamp.format(),
notes = notes + suffix,
bg = bolusRequested1.BG if bolusRequested1 and bolusRequested1.BG > 0 else None,
pump_event_id = ",".join(seq_nums)
)
@@ -0,0 +1,100 @@
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 (
SITECHANGE_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessCartridge:
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.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessCartridge: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(SITECHANGE_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout sitechange upload: %s" % last_upload_time)
cartFilledEvents = []
cannulaFilledEvents = []
tubingFilledEvents = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
continue
if type(event) == eventtypes.LidCartridgeFilled:
cartFilledEvents.append(event)
elif type(event) == eventtypes.LidCannulaFilled:
cannulaFilledEvents.append(event)
elif type(event) == eventtypes.LidTubingFilled:
tubingFilledEvents.append(event)
cartFilledEvents.sort(key=lambda e: e.eventTimestamp)
cannulaFilledEvents.sort(key=lambda e: e.eventTimestamp)
tubingFilledEvents.sort(key=lambda e: e.eventTimestamp)
ns_entries = []
for cartFilled in cartFilledEvents:
ns_entries.append(self.cart_to_nsentry(cartFilled))
for cannulaFilled in cannulaFilledEvents:
ns_entries.append(self.cannula_to_nsentry(cannulaFilled))
for tubingFilled in tubingFilledEvents:
ns_entries.append(self.tubing_to_nsentry(tubingFilled))
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def cart_to_nsentry(self, cartFilled):
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.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.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.seqNum
)
@@ -0,0 +1,92 @@
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 (
CGM_ALERT_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessCGMAlert:
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.CGM_ALERTS in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessCGMAlert: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_entry(CGM_ALERT_EVENTTYPE, time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload:
last_upload_time = arrow.get(last_upload["created_at"])
logger.info("Last Nightscout cgmalert upload: %s" % last_upload_time)
alertEvents = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
continue
alertEvents.append(event)
alertEvents.sort(key=lambda e: e.eventTimestamp)
ns_entries = []
for event in alertEvents:
e = self.alert_to_nsentry(event)
if e:
ns_entries.append(e)
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def alert_to_nsentry(self, alert):
if not alert.dalertid:
logger.info("ProcessCGMAlert: Skipping alert with unknown dalertid %d: %s" % (alert.dalertidRaw, alert))
return None
if type(alert) == eventtypes.LidCgmAlertActivated:
return NightscoutEntry.cgm_alert(
created_at = alert.eventTimestamp.format(),
reason = ("CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "CGM Alert (Unknown)",
pump_event_id = "%s" % alert.seqNum
)
elif type(alert) == eventtypes.LidCgmAlertActivatedDex:
if alert.dalertid == eventtypes.LidCgmAlertActivatedDex.DalertidEnum.CgmOutOfRange:
logger.info("ProcessCGMAlert: Skipping alert with CgmOutOfRange dalertid %d: %s" % (alert.dalertidRaw, alert))
return None
return NightscoutEntry.cgm_alert(
created_at = alert.eventTimestamp.format(),
reason = ("Dexcom CGM Alert (%s)" % alert.dalertid.name) if alert.dalertid else "Dexcom CGM Alert (Unknown)",
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.seqNum
)
@@ -0,0 +1,76 @@
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.raw_event import TANDEM_EPOCH
from ...eventparser import events as eventtypes
from ...domain.tandemsource.event_class import EventClass
from ...parser.nightscout import (
CGM_START_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessCGMReading:
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.CGM in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessCGMReading: querying for last uploaded entry")
last_upload = self.nightscout.last_uploaded_bg_entry(time_start=time_start, time_end=time_end)
last_upload_time = None
if last_upload and "dateString" in last_upload:
last_upload_time = arrow.get(last_upload["dateString"])
elif last_upload and "date" in last_upload:
last_upload_time = arrow.get(last_upload["date"])
logger.info("ProcessCGMReading: Last Nightscout bg upload: %s" % last_upload_time)
readings = []
for event in sorted(events, key=lambda x: self.timestamp_for(x)):
if last_upload_time and self.timestamp_for(event) <= last_upload_time:
if self.pretend:
logger.info("ProcessCGMReading: Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
continue
readings.append(event)
ns_entries = []
for event in readings:
ns_entries.append(self.to_nsentry(event))
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry, entity='entries')
count += 1
return count
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)
def to_nsentry(self, event):
return NightscoutEntry.entry(
sgv = event.currentglucosedisplayvalue,
created_at = self.timestamp_for(event).format(),
pump_event_id = "%s" % event.seqNum,
)
@@ -0,0 +1,96 @@
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 (
CGM_START_EVENTTYPE,
CGM_JOIN_EVENTTYPE,
CGM_STOP_EVENTTYPE,
NightscoutEntry
)
logger = logging.getLogger(__name__)
class ProcessCGMStartJoinStop:
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.PUMP_EVENTS in self.features or features.CGM_ALERTS in self.features
def process(self, events, time_start, time_end):
last_upload = None
last_upload_time = None
for eventtype in [CGM_START_EVENTTYPE, CGM_JOIN_EVENTTYPE, CGM_STOP_EVENTTYPE]:
logger.debug("ProcessCGMStartJoinStop: querying for last uploaded entry for %s" % eventtype)
_last_upload = self.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"])
if not last_upload_time:
last_upload = _last_upload
last_upload_time = _last_upload_time
elif _last_upload_time > last_upload_time:
last_upload = _last_upload
last_upload_time = _last_upload_time
logger.info("ProcessCGMStartJoinStop: Last Nightscout %s upload: %s" % (eventtype, _last_upload_time))
logger.info("ProcessCGMStartJoinStop: Overall last Nightscout upload: %s %s" % (last_upload_time, last_upload))
allEvents = []
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("ProcessCGMStartJoinStop: Skipping %s not after last upload time: %s (time range: %s - %s)" % (type(event), event, time_start, time_end))
continue
allEvents.append(event)
allEvents.sort(key=lambda e: e.eventTimestamp)
ns_entries = []
for event in allEvents:
ns_entries.append(self.to_nsentry(event))
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def to_nsentry(self, event):
if type(event) in EventClass._CGM_START:
return NightscoutEntry.cgm_start(
created_at = event.eventTimestamp.format(),
reason = "CGM Session Started",
pump_event_id = "%s" % event.seqNum
)
elif type(event) in EventClass._CGM_JOIN:
return NightscoutEntry.cgm_join(
created_at = event.eventTimestamp.format(),
reason = "CGM Session Joined",
pump_event_id = "%s" % event.seqNum
)
elif type(event) in EventClass._CGM_STOP:
return NightscoutEntry.cgm_stop(
created_at = event.eventTimestamp.format(),
reason = "CGM Session Stopped",
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
@@ -0,0 +1,244 @@
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
)
NOT_ENDED = "Not Ended"
logger = logging.getLogger(__name__)
class ProcessUserMode:
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.PUMP_EVENTS in self.features
def process(self, events, time_start, time_end):
logger.debug("ProcessUserMode: querying for last uploaded exercise entry")
exercise_last_upload = self.nightscout.last_uploaded_entry(EXERCISE_EVENTTYPE, time_start=time_start, time_end=time_end)
exercise_last_upload_time = None
if exercise_last_upload:
exercise_last_upload_time = arrow.get(exercise_last_upload["created_at"])
logger.info("ProcessUserMode: Last Nightscout exercise upload: %s" % exercise_last_upload_time)
exercise_not_ended = False
if exercise_last_upload and NOT_ENDED in exercise_last_upload.get("reason", ""):
exercise_not_ended = True
logger.info("ProcessUserMode: Last exercise not ended: %s" % exercise_last_upload)
logger.debug("ProcessUserMode: querying for last uploaded sleep entry")
sleep_last_upload = self.nightscout.last_uploaded_entry(SLEEP_EVENTTYPE, time_start=time_start, time_end=time_end)
sleep_last_upload_time = None
if sleep_last_upload:
sleep_last_upload_time = arrow.get(sleep_last_upload["created_at"])
logger.info("ProcessUserMode: Last Nightscout sleep upload: %s" % sleep_last_upload_time)
sleep_not_ended = False
if sleep_last_upload and NOT_ENDED in sleep_last_upload.get("reason", ""):
sleep_not_ended = True
logger.info("ProcessUserMode: Last sleep not ended: %s" % sleep_last_upload)
last_upload_time = None
if exercise_last_upload_time and sleep_last_upload_time:
last_upload_time = max(exercise_last_upload_time, sleep_last_upload_time)
elif exercise_last_upload_time:
last_upload_time = exercise_last_upload_time
elif sleep_last_upload_time:
last_upload_time = sleep_last_upload_time
logger.info("ProcessUserMode: Last Nightscout usermode upload: %s" % last_upload_time)
ns_entries = []
processed_sleep = []
processed_exercise = []
start_sleep = None
start_exercise = None
for event in sorted(events, key=lambda x: x.eventTimestamp):
if last_upload_time and arrow.get(event.eventTimestamp) <= last_upload_time:
if self.pretend:
logger.info("ProcessUserMode: Skipping usermode event not after last upload time: %s (time range: %s - %s)" % (event, time_start, time_end))
continue
if self.is_start_sleep(event):
start_sleep = event
elif self.is_stop_sleep(event):
if start_sleep:
processed_sleep.append((start_sleep, event))
start_sleep = None
else:
if sleep_not_ended:
logger.info("ProcessUserMode: Found StopSleep without StartSleep, with incomplete sleep event in nightscout: %s NS: %s" % (event, sleep_last_upload))
ns_entries.append(self.process_unended_sleep_stop(event, sleep_last_upload))
else:
logger.warning("ProcessUserMode: Found StopSleep without StartSleep, and no active sleep event in nightscout: %s" % event)
elif self.is_start_exercise(event):
start_exercise = event
elif self.is_stop_exercise(event):
if start_exercise:
processed_exercise.append((start_exercise, event))
start_exercise = None
else:
if exercise_not_ended:
logger.info("ProcessUserMode: Found StopExercise without StartExercise, with incomplete exercise event in nightscout: %s NS: %s" % (event, exercise_last_upload))
ns_entries.append(self.process_unended_exercise_stop(event, exercise_last_upload))
else:
logger.warning("ProcessUserMode: Found StopExercise without StartExercise, and no active exercise event in nightscout: %s" % event)
else:
logger.warning("ProcessUserMode: not sure how to process event: %s" % event)
if start_sleep:
processed_sleep.append((start_sleep, None))
logger.info("ProcessUserMode: sleep is active")
if start_exercise:
processed_exercise.append((start_exercise, None))
logger.info("ProcessUserMode: exercise is active")
for items in processed_sleep:
ns_entries.append(self.sleep_to_nsentry(start=items[0], stop=items[1], time_end=time_end))
for items in processed_exercise:
ns_entries.append(self.exercise_to_nsentry(start=items[0], stop=items[1], time_end=time_end))
return ns_entries
def write(self, ns_entries):
count = 0
for entry in ns_entries:
if self.pretend:
logger.info("Would upload to Nightscout: %s" % entry)
else:
logger.info("Uploading to Nightscout: %s" % entry)
self.nightscout.upload_entry(entry)
count += 1
return count
def is_start_sleep(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep
def is_stop_sleep(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep or \
event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
def is_start_exercise(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise
def is_stop_exercise(self, event):
return event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise or \
event.requestedaction == eventtypes.LidAaUserModeChange.RequestedactionEnum.StopAll
def sleep_to_nsentry(self, start, stop=None, time_end=None):
if start and stop:
reason = None
if start.sleepstartedbygui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
reason = "Sleep (Manual)"
elif start.activesleepschedule:
reason = "Sleep (Scheduled)"
duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason,
duration=duration_mins,
event_type=SLEEP_EVENTTYPE,
pump_event_id = "%s,%s" % (start.seqNum, stop.seqNum)
)
elif start:
reason = None
if start.sleepstartedbygui == eventtypes.LidAaUserModeChange.SleepstartedbyguiEnum.TrueVal:
reason = "Sleep (Manual)"
elif start.activesleepscheduleRaw:
reason = "Sleep (Scheduled)"
duration_mins = (time_end - start.eventTimestamp).seconds / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason + " - " + NOT_ENDED if reason else NOT_ENDED,
duration=duration_mins,
event_type=SLEEP_EVENTTYPE,
pump_event_id = "%s" % start.seqNum
)
def exercise_to_nsentry(self, start, stop=None, time_end=None):
if start and stop:
reason = "Exercise"
if start.exercisechoice == eventtypes.LidAaUserModeChange.ExercisechoiceEnum.Timed:
reason = "Exercise (Timed)"
if stop.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
reason += " (Stopped by timer)"
duration_mins = (stop.eventTimestamp - start.eventTimestamp).seconds / 60
return NightscoutEntry.activity(
created_at=start.eventTimestamp.format(),
reason=reason,
duration=duration_mins,
event_type=EXERCISE_EVENTTYPE,
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 - 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" % start.seqNum
)
def process_unended_sleep_stop(self, event, sleep_last_upload):
logger.info("ProcessUserMode: Deleting old sleep event treatment before pushing update (delete treatments/%s)" % sleep_last_upload["_id"])
if self.pretend:
logger.info("ProcessUserMode: Skipping delete in pretend mode")
else:
self.nightscout.delete_entry('treatments/%s' % sleep_last_upload["_id"])
duration_mins = (event.eventTimestamp - arrow.get(sleep_last_upload["created_at"])).seconds / 60
return NightscoutEntry.activity(
created_at=sleep_last_upload["created_at"],
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.seqNum)
)
def process_unended_exercise_stop(self, event, exercise_last_upload):
logger.info("ProcessUserMode: Deleting old exercise event treatment before pushing update (delete treatments/%s)" % exercise_last_upload["_id"])
if self.pretend:
logger.info("ProcessUserMode: Skipping delete in pretend mode")
else:
self.nightscout.delete_entry('treatments/%s' % exercise_last_upload["_id"])
reason = exercise_last_upload["reason"].replace(" - %s" % NOT_ENDED, "")
if event.exercisestoppedbytimer == eventtypes.LidAaUserModeChange.ExercisestoppedbytimerEnum.TrueVal:
reason += " (Stopped by timer)"
duration_mins = (event.eventTimestamp - arrow.get(exercise_last_upload["created_at"])).seconds / 60
return NightscoutEntry.activity(
created_at=exercise_last_upload["created_at"],
reason=reason,
duration=duration_mins,
event_type=EXERCISE_EVENTTYPE,
pump_event_id="%s,%s" % (exercise_last_upload.get("pump_event_id",""), event.seqNum)
)
@@ -0,0 +1,213 @@
import logging
import arrow
import copy
import json
from typing import Tuple
from ...features import DEFAULT_FEATURES
from ... import features
from ...domain.tandemsource.pump_settings import PumpSettings
from ...parser.nightscout import (
NightscoutEntry, ENTERED_BY
)
from ...secret import NIGHTSCOUT_PROFILE_UPLOAD_MODE
logger = logging.getLogger(__name__)
def _get_default_upload_mode():
return NIGHTSCOUT_PROFILE_UPLOAD_MODE
class UpdateProfiles:
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.PROFILES in self.features
def update(self, pretend):
upload_mode = _get_default_upload_mode()
logger.debug("UpdateProfiles: getting Tandem Source profile data")
all_metadata = self.tconnect.tandemsource.pump_event_metadata()
pump_meta = None
for m in all_metadata:
if m['tconnectDeviceId'] == self.tconnect_device_id:
pump_meta = m
if not pump_meta:
return False
raw_settings = pump_meta.get("lastUpload", {}).get("settings")
if not raw_settings:
return False
pump_settings = PumpSettings.from_dict(raw_settings)
logger.info("Current pump settings: %s" % pump_settings)
ns_profile_obj = self.nightscout.current_profile()
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")
return False
if upload_mode == 'add':
profile_to_upload = self.setup_new_profile(ns_profile_new)
logger.info("Adding new Nightscout profiles object: %s", profile_to_upload)
if not pretend:
self.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:
self.nightscout.put_entry(ns_profile_new, entity='profile')
return True
else:
raise RuntimeError('invalid upload_mode: %s' % upload_mode)
"""
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(self, pump_settings: PumpSettings, ns_profile_obj: dict) -> Tuple[bool, dict]:
device = {profile.name: profile for profile in pump_settings.profiles.profile}
activeIdp = pump_settings.profiles.activeIdp
ns = ns_profile_obj.get('store', {})
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:
new_ns_profile['store'] = {}
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.tandemsource_profile_store(pump_configured_profile, pump_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.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)
if self.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 pump_settings.profiles.profile:
if profile.idp == activeIdp:
current_pump_profile = profile.name
if not current_pump_profile:
logger.error('No current pump profile, so skipping profile update')
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)
new_ns_profile['enteredBy'] = ENTERED_BY
return True, new_ns_profile
def nightscout_profiles_identical(self, 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(self, 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
+19
View File
@@ -1,5 +1,8 @@
import arrow
from . import cli
from . import constants
def timeago(timestamp):
seconds = (arrow.get() - arrow.get(timestamp)).total_seconds()
fmt = '%s ago' if seconds >= 0 else 'in %s'
@@ -15,3 +18,19 @@ def timeago(timestamp):
ret += '%d minutes' % (seconds//60)
return fmt % ret
# String methods only available in python 3.9+
def removesuffix(input_string, suffix):
if suffix and input_string.endswith(suffix):
return input_string[:-len(suffix)]
return input_string
def removeprefix(input_string, prefix):
if prefix and input_string.startswith(prefix):
return input_string[len(prefix):]
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:])
+20
View File
@@ -0,0 +1,20 @@
import logging
"""
Enables logging at the specified level for all loggers
inside the tconnectsync package, and sets up a basicConfig
to print those log messages to stderr.
"""
def enable_logging(level=logging.DEBUG):
logging.basicConfig()
for logger in logging.root.manager.loggerDict:
if logger.startswith('tconnectsync'):
logging.getLogger(logger).setLevel(level)
"""
Returns a TConnectApi object with default secret parameters.
"""
def get_api():
from ..api import TConnectApi
from ..secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION
return TConnectApi(TCONNECT_EMAIL, TCONNECT_PASSWORD, TCONNECT_REGION)
+4
View File
@@ -0,0 +1,4 @@
# http://www.soc-bdr.org/rds/authors/unit_tables_conversions_and_genetic_dictionaries/conversion_glucose_mg_dl_to_mmol_l/index_en.html
MMOLL_TO_MGDL = 18.0182
MGDL_TO_MMOLL = 0.0555
+19 -3
View File
@@ -1,9 +1,11 @@
import tconnectsync.api
import requests
class ControlIQApi(tconnectsync.api.controliq.ControlIQApi):
def __init__(self):
self.BASE_URL = 'invalid://'
self.LOGIN_URL = 'invalid://'
self.session = requests.Session() # mocked in tests
def login(self, email, password):
raise NotImplementedError
@@ -19,7 +21,7 @@ class WS2Api(tconnectsync.api.ws2.WS2Api):
self.BASE_URL = 'invalid://'
self.SLEEP_SECONDS_INCREMENT = 0.01
def get(self, endpoint, query):
def get(self, endpoint):
raise NotImplementedError
def get_jsonp(self, endpoint):
@@ -38,10 +40,24 @@ class AndroidApi(tconnectsync.api.android.AndroidApi):
def _get(self, endpoint, query={}, **kwargs):
raise NotImplementedError
class WebUIScraper(tconnectsync.api.webui.WebUIScraper):
def __init__(self, controliq):
self.controliq = controliq
def my_devices(self):
raise NotImplementedError
def device_settings(self, pump_guid):
raise NotImplementedError
class TConnectApi(tconnectsync.api.TConnectApi):
def __init__(self):
pass
def __init__(self, email=None, password=None):
if email is not None and password is not None:
self.with_credentials = True
else:
self.with_credentials = False
_ciq = ControlIQApi()
_ws2 = WS2Api()
_android = AndroidApi()
_webui = WebUIScraper(_ciq)
+44
View File
@@ -0,0 +1,44 @@
import unittest
from unittest.mock import patch
from tconnectsync.api.common import base_session
class TestRequestsProxy(unittest.TestCase):
def test_proxy_used_in_base_session(self):
with patch("tconnectsync.api.common.secret") as mock_secret, \
patch("requests.Session.request") as mock_request:
s = base_session()
s.request('sentinel')
mock_request.assert_called_once_with('sentinel', proxies={
'http': mock_secret.REQUESTS_PROXY,
'https': mock_secret.REQUESTS_PROXY
})
def test_proxy_used_in_base_session_with_two_args(self):
with patch("tconnectsync.api.common.secret") as mock_secret, \
patch("requests.Session.request") as mock_request:
s = base_session()
s.request('GET', 'sentinel')
mock_request.assert_called_once_with('GET', 'sentinel', proxies={
'http': mock_secret.REQUESTS_PROXY,
'https': mock_secret.REQUESTS_PROXY
})
def test_proxy_used_in_base_session_with_kwargs(self):
with patch("tconnectsync.api.common.secret") as mock_secret, \
patch("requests.Session.request") as mock_request:
s = base_session()
s.request('GET', 'sentinel', foo={'bar': 'baz'})
mock_request.assert_called_once_with('GET', 'sentinel', foo={'bar': 'baz'}, proxies={
'http': mock_secret.REQUESTS_PROXY,
'https': mock_secret.REQUESTS_PROXY
})
+61 -12
View File
@@ -111,17 +111,70 @@ 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):
nonlocal http_code, expected_endpoint, num_times, tries
if endpoint.endswith(expected_endpoint):
if endpoint.split("?")[0].endswith(expected_endpoint):
if tries < num_times:
tries += 1
raise ApiException(http_code, "fake HTTP %d" % http_code)
@@ -202,12 +255,10 @@ class TestControlIQApi(unittest.TestCase):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def fake_get(endpoint, query):
def fake_get(raw_endpoint, ignored_query):
endpoint, query = raw_endpoint.split("?")
self.assertTrue(endpoint.endswith("therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
self.assertEqual(query, {
"startDate": "04-01-2021",
"endDate": "04-02-2021"
})
self.assertEqual(query, "startDate=04-01-2021&endDate=04-02-2021")
return {"faked_json": True}
@@ -223,12 +274,10 @@ class TestControlIQApi(unittest.TestCase):
ciq = ControlIQApi()
ciq.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def fake_get(endpoint, query):
def fake_get(raw_endpoint, ignored_query):
endpoint, query = raw_endpoint.split("?")
self.assertTrue(endpoint.endswith("summary/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
self.assertEqual(query, {
"startDate": "04-01-2021",
"endDate": "04-02-2021"
})
self.assertEqual(query, "startDate=04-01-2021&endDate=04-02-2021")
return {"faked_json": True}
File diff suppressed because it is too large Load Diff
+59 -9
View File
@@ -2,6 +2,7 @@
import unittest
import itertools
import copy
from .fake import WS2Api
@@ -10,7 +11,7 @@ from tconnectsync.api.common import ApiException
class TestWS2Api(unittest.TestCase):
def fake_get_with_http_500(self, num_times):
tries = 0
def fake_get(endpoint, query):
def fake_get(endpoint, **kwargs):
nonlocal tries, num_times
if "therapytimeline2csv" in endpoint:
if tries < num_times:
@@ -28,7 +29,7 @@ class TestWS2Api(unittest.TestCase):
ws2.get = self.fake_get_with_http_500(2)
self.assertEqual(
ws2.therapy_timeline_csv('2021-04-01', '2021-04-02'),
ws2.therapy_timeline_csv('04-01-2021', '04-02-2021'),
{
"readingData": [],
"iobData": [],
@@ -41,7 +42,7 @@ class TestWS2Api(unittest.TestCase):
ws2.get = self.fake_get_with_http_500(3)
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '2021-04-01', '2021-04-02')
self.assertRaises(ApiException, ws2.therapy_timeline_csv, '04-01-2021', '04-02-2021')
RAW_DATA_HEADER = """Tandem Diabetes Care Inc.
t:connect Therapy Timeline Data Export
@@ -94,14 +95,14 @@ Report Generated On, 4/24/2021 7:50:04 PM
rawData = self.RAW_DATA_FULL
def fake_get(endpoint, query):
def fake_get(endpoint, **kwargs):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData
ws2.get = fake_get
tt = ws2.therapy_timeline_csv('2021-04-01', '2021-04-02')
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
self.assertDictEqual(tt, self.PARSED_DATA)
@@ -111,9 +112,9 @@ Report Generated On, 4/24/2021 7:50:04 PM
rawData = ""
def fake_get(endpoint, query):
def fake_get(endpoint, **kwargs):
nonlocal rawData
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/2021-04-01/2021-04-02?format=csv':
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData
ws2.get = fake_get
@@ -122,8 +123,57 @@ Report Generated On, 4/24/2021 7:50:04 PM
for i in itertools.permutations([self.RAW_DATA_HEADER, self.RAW_DATA_CGM, self.RAW_DATA_IOB, self.RAW_DATA_BOLUS], 4):
rawData = "\n".join(i)
tt = ws2.therapy_timeline_csv('2021-04-01', '2021-04-02')
tt = ws2.therapy_timeline_csv('04-01-2021', '04-02-2021')
self.assertDictEqual(tt, self.PARSED_DATA)
def test_therapy_timeline_csv_split_past_max_days(self):
ws2 = WS2Api()
ws2.userGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
def replace_str(raw, one, two):
return raw.replace('04-01-2021', one).replace('04-02-2021', two)
rawData1 = self.RAW_DATA_FULL
rawData2 = replace_str(self.RAW_DATA_FULL, '04-03-2021', '04-04-2021')
rawData3 = replace_str(self.RAW_DATA_FULL, '04-05-2021', '04-06-2021')
rawData4 = replace_str(self.RAW_DATA_FULL, '04-07-2021', '04-07-2021')
def replace_parsed(one, two):
parsedData = copy.deepcopy(self.PARSED_DATA)
for typ in parsedData.keys():
for i in range(len(parsedData[typ])):
for f in parsedData[typ][i].keys():
if 'datetime' in f.lower():
parsedData[typ][i][f] = replace_str(parsedData[typ][i][f], one, two)
return parsedData
parsedData1 = self.PARSED_DATA
parsedData2 = replace_parsed('04-03-2021', '04-04-2021')
parsedData3 = replace_parsed('04-05-2021', '04-06-2021')
parsedData4 = replace_parsed('04-07-2021', '04-07-2021')
fullParsedData = parsedData1
for d in [parsedData2, parsedData3, parsedData4]:
for typ in d.keys():
fullParsedData[typ] += d[typ]
def fake_get(endpoint, **kwargs):
nonlocal rawData1, rawData2, rawData3, rawData4
print('fake_get call %s' % endpoint)
if endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-01-2021/04-02-2021?format=csv':
return rawData1
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-03-2021/04-04-2021?format=csv':
return rawData2
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-05-2021/04-06-2021?format=csv':
return rawData3
elif endpoint == 'therapytimeline2csv/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/04-07-2021/04-07-2021?format=csv':
return rawData4
ws2.get = fake_get
tt = ws2.therapy_timeline_csv('04-01-2021', '04-07-2021')
self.assertDictEqual(tt, fullParsedData)
if __name__ == '__main__':
unittest.main()
View File
+367
View File
@@ -0,0 +1,367 @@
import dataclasses
import unittest
from tconnectsync.domain.bolus import Bolus
from tconnectsync.domain.therapy_event import BolusTherapyEvent, CGMTherapyEvent
class TestCGMTherapyEvent(unittest.TestCase):
maxDiff = None
sampleJson = {
"eventDateTime": "2022-07-21T00:00:08",
"eventID": 256,
"requestDateTime": "0001-01-01T00:00:00",
"type": "CGM",
"description": "EGV",
"sourceRecId": 0,
"eventTypeId": 0,
"deviceType": "t:slim X2 Insulin Pump",
"serialNumber": "xxx",
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0,
"egv": {
"estimatedGlucoseValue": 174,
"hypo": 0,
"belowTarget": 0,
"withinTarget": 1,
"aboveTarget": 0,
"hyper": 0
}
}
def test_parse_cgm(self):
e = CGMTherapyEvent.parse(self.sampleJson)
self.assertEqual(e.type, "CGM")
self.assertEqual(e.eventDateTime, "2022-07-21T00:00:08")
self.assertEqual(e.sourceRecId, 0)
self.assertEqual(e.eventID, 256)
self.assertEqual(e.egv, 174)
class TestBolusTherapyEvent(unittest.TestCase):
maxDiff = None
standardJson = {
"actualTotalBolusRequested": 4.17,
"bolusRequestOptions": "Standard",
"bolusType": "Carb",
"carbSize": 25,
"correctionBolusSize": 0,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T12:27:36",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110",
"eventHistoryReportEventDesc": "Food Bolus",
"foodBolusSize": 4.17,
"iob": 2.62,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "573042",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-07-21T12:27:36",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T12:29:21",
"value": 4.17
},
"foodDelivered": 4.17,
"correctionDelivered": 0,
"insulinRequested": 4.17,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3362,
"bolusCompletionId": 3362
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Standard",
"sourceRecId": 1171853319,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_standard_to_bolus(self):
e = BolusTherapyEvent.parse(self.standardJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Standard",
complete="1",
completion="Completed",
request_time="2022-07-21 12:27:36-04:00",
completion_time="2022-07-21 12:29:21-04:00",
insulin="4.17",
requested_insulin="4.17",
carbs="25",
bg="",
user_override="0",
extended_bolus="0",
bolex_completion_time="",
bolex_start_time=""
)))
correctionJson = {
"actualTotalBolusRequested": 2.9,
"bg": 254,
"bolusRequestOptions": "Automatic Bolus/Correction",
"bolusType": "Automatic Correction",
"carbSize": 0,
"correctionBolusSize": 2.9,
"correctionFactor": 30,
"declinedCorrection": 0,
"duration": 0,
"eventDateTime": "2022-07-21T11:53:08",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:0 - Target BG 110",
"eventHistoryReportEventDesc": "Correction Bolus",
"foodBolusSize": 0,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "572946",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-07-21T11:53:08",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-07-21T11:55:24",
"value": 2.9
},
"foodDelivered": 0,
"correctionDelivered": 2.9,
"insulinRequested": 2.9,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3361,
"bolusCompletionId": 3361
},
"standardPercent": 100,
"targetBG": 110,
"userOverride": 0,
"type": "Bolus",
"description": "Automatic Bolus/Correction",
"sourceRecId": 1171791787,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_correction_to_bolus(self):
e = BolusTherapyEvent.parse(self.correctionJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Automatic Bolus/Correction",
complete="1",
completion="Completed",
request_time="2022-07-21 11:53:08-04:00",
completion_time="2022-07-21 11:55:24-04:00",
insulin="2.9",
requested_insulin="2.9",
carbs="0",
bg="254",
user_override="0",
extended_bolus="0",
bolex_completion_time="",
bolex_start_time=""
)))
extendedBolusIncompleteJson = {
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"iob": 0,
"completionStatusId": 0,
"extendedBolusIsComplete": 0,
"insulinRequested": 0,
"bolexCompletionId": 0
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_extended_bolus_incomplete_to_bolus(self):
e = BolusTherapyEvent.parse(self.extendedBolusIncompleteJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Extended 50.00%/0.00",
complete="0",
completion="",
request_time="2022-08-09 23:19:15-04:00",
completion_time="2022-08-09 23:20:04-04:00",
insulin="0.2",
requested_insulin="0.2",
carbs="0",
bg="131",
user_override="1",
extended_bolus="1",
bolex_completion_time="",
bolex_start_time="2022-08-09 23:20:04-04:00"
)))
extendedBolusJson = {
"actualTotalBolusRequested": 0.4,
"bg": 131,
"bolex": {
"size": 0.2,
"bolexStartDateTime": "2022-08-09T23:20:04",
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:35:03",
"value": 0.2
},
"iob": 5.7,
"completionStatusId": 3.0,
"completionStatusDesc": "Completed",
"extendedBolusIsComplete": 1,
"insulinRequested": 0.2,
"bolexCompletionId": 16757133
},
"bolusRequestOptions": "Extended",
"bolusType": "Carb",
"carbSize": 0,
"correctionBolusSize": 0.0,
"correctionFactor": 30.0,
"declinedCorrection": 0,
"duration": 15,
"eventDateTime": "2022-08-09T23:19:15",
"eventHistoryReportDetails": "CF 1:30 - Carb Ratio 1:6 - Target BG 110<br/>Override: Pump calculated Bolus = 0.0 units",
"eventHistoryReportEventDesc": "Food Bolus: 50&#37; Extended 15 mins",
"foodBolusSize": 0.0,
"iob": 5.87,
"isQuickBolus": 0,
"note": {
"id": 0,
"indexId": "631597",
"eventTypeId": 64,
"sourceRecordId": 0,
"eventId": 0,
"active": False
},
"requestDateTime": "2022-08-09T23:19:15",
"standard": {
"insulinDelivered": {
"completionDateTime": "2022-08-09T23:20:04",
"value": 0.2
},
"foodDelivered": 0.0,
"correctionDelivered": 0.0,
"insulinRequested": 0.2,
"completionStatusId": 3,
"completionStatusDesc": "Completed",
"bolusIsComplete": 1,
"bolusRequestId": 3636.0,
"bolusCompletionId": 3636.0
},
"standardPercent": 50.0,
"targetBG": 110,
"userOverride": 1,
"type": "Bolus",
"description": "Extended 50.00%/0.00",
"sourceRecId": 1209631944,
"eventTypeId": 0,
"indexId": 0,
"uploadId": 0,
"interactive": 0,
"tempRateId": 0,
"tempRateCompleted": 0,
"tempRateActivated": 0
}
def test_extended_bolus_complete_to_bolus(self):
e = BolusTherapyEvent.parse(self.extendedBolusJson)
self.assertIsNotNone(e)
b = e.to_bolus()
self.assertEqual(dataclasses.asdict(b), dataclasses.asdict(Bolus(
description="Extended 50.00%/0.00",
complete="1",
completion="Completed",
request_time="2022-08-09 23:19:15-04:00",
completion_time="2022-08-09 23:20:04-04:00",
insulin="0.2",
requested_insulin="0.2",
carbs="0",
bg="131",
user_override="1",
extended_bolus="1",
bolex_completion_time="2022-08-09 23:35:03-04:00",
bolex_start_time="2022-08-09 23:20:04-04:00"
)))
BOLUS_FULL_EXAMPLES = [
TestBolusTherapyEvent.standardJson,
TestBolusTherapyEvent.correctionJson,
TestBolusTherapyEvent.extendedBolusJson
]
+4 -1
View File
@@ -20,12 +20,15 @@ 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):
def last_uploaded_entry(self, eventType, time_start=None, time_end=None):
raise NotImplementedError
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
+57 -27
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.parser.nightscout import NightscoutEntry, InvalidBolusTypeException
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
class TestNightscoutEntry(unittest.TestCase):
maxDiff = None
def test_basal(self):
self.assertEqual(
NightscoutEntry.basal(
@@ -19,7 +22,8 @@ class TestNightscoutEntry(unittest.TestCase):
"created_at": "2021-03-16 00:25:21-04:00",
"carbs": None,
"insulin": None,
"enteredBy": "Pump (tconnectsync)"
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": ""
}
)
@@ -38,7 +42,8 @@ class TestNightscoutEntry(unittest.TestCase):
"created_at": "2021-03-16 12:25:21-04:00",
"carbs": None,
"insulin": None,
"enteredBy": "Pump (tconnectsync)"
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": ""
}
)
@@ -54,7 +59,8 @@ class TestNightscoutEntry(unittest.TestCase):
"carbs": 45,
"insulin": 7.5,
"notes": "",
"enteredBy": "Pump (tconnectsync)"
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": ""
}
)
@@ -69,7 +75,8 @@ class TestNightscoutEntry(unittest.TestCase):
"carbs": 5,
"insulin": 0.5,
"notes": "",
"enteredBy": "Pump (tconnectsync)"
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": ""
}
)
@@ -89,7 +96,8 @@ class TestNightscoutEntry(unittest.TestCase):
"notes": "",
"enteredBy": "Pump (tconnectsync)",
"glucose": "123",
"glucoseType": "Sensor"
"glucoseType": "Sensor",
"pump_event_id": ""
}
)
@@ -108,28 +116,14 @@ class TestNightscoutEntry(unittest.TestCase):
"notes": "",
"enteredBy": "Pump (tconnectsync)",
"glucose": "150",
"glucoseType": "Finger"
"glucoseType": "Finger",
"pump_event_id": ""
}
)
def test_bolus_with_bg_invalid_type(self):
self.assertRaises(InvalidBolusTypeException,
NightscoutEntry.bolus,
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00",
bg="150")
self.assertRaises(InvalidBolusTypeException,
NightscoutEntry.bolus,
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00",
bg="150",
bg_type="")
self.assertRaises(InvalidBolusTypeException,
NightscoutEntry.bolus,
NightscoutEntry.bolus,
bolus=0.5,
carbs=5,
created_at="2021-03-16 12:25:21-04:00",
@@ -160,9 +154,10 @@ class TestNightscoutEntry(unittest.TestCase):
"date": 1635041834000,
"dateString": "2021-10-23T22:17:14-0400",
"device": "Pump (tconnectsync)",
"pump_event_id": ""
}
)
def test_sitechange(self):
self.assertEqual(
NightscoutEntry.sitechange(
@@ -173,10 +168,11 @@ class TestNightscoutEntry(unittest.TestCase):
"reason": "reason",
"notes": "reason",
"created_at": "2021-12-05T00:16:35.058Z",
"enteredBy": "Pump (tconnectsync)"
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": ""
}
)
def test_basalsuspension(self):
self.assertEqual(
NightscoutEntry.basalsuspension(
@@ -187,10 +183,44 @@ class TestNightscoutEntry(unittest.TestCase):
"reason": "reason",
"notes": "reason",
"created_at": "2021-12-05T00:16:35.058Z",
"enteredBy": "Pump (tconnectsync)"
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": ""
}
)
def test_profile_store(self):
self.assertEqual(
NightscoutEntry.profile_store(
profile=DEVICE_PROFILE_A,
device_settings=DEVICE_SETTINGS
),
NS_PROFILE_A
)
class TestTandemNightscoutTime(unittest.TestCase):
def test_tandem_to_ns_time(self):
self.assertEqual(tandem_to_ns_time('12:00 AM'), '00:00')
self.assertEqual(tandem_to_ns_time('12:30 AM'), '00:30')
self.assertEqual(tandem_to_ns_time('6:00 AM'), '06:00')
self.assertEqual(tandem_to_ns_time('6:30 AM'), '06:30')
self.assertEqual(tandem_to_ns_time('11:30 AM'), '11:30')
self.assertEqual(tandem_to_ns_time('12:00 PM'), '12:00')
self.assertEqual(tandem_to_ns_time('12:30 PM'), '12:30')
self.assertEqual(tandem_to_ns_time('06:30 PM'), '18:30')
self.assertEqual(tandem_to_ns_time('11:30 PM'), '23:30')
def test_tandem_to_ns_time_seconds(self):
self.assertEqual(tandem_to_ns_time_seconds('12:00 AM'), 0)
self.assertEqual(tandem_to_ns_time_seconds('12:30 AM'), 30*60)
self.assertEqual(tandem_to_ns_time_seconds('6:00 AM'), 6*60*60)
self.assertEqual(tandem_to_ns_time_seconds('6:30 AM'), 6*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('11:30 AM'), 11*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('12:00 PM'), 12*60*60)
self.assertEqual(tandem_to_ns_time_seconds('12:30 PM'), 12*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('06:30 PM'), 12*60*60 + 6*60*60 + 30*60)
self.assertEqual(tandem_to_ns_time_seconds('11:30 PM'), 12*60*60 + 11*60*60 + 30*60)
if __name__ == '__main__':
unittest.main()
+270
View File
@@ -0,0 +1,270 @@
from tconnectsync.domain.device_settings import Profile, ProfileSegment, DeviceSettings
from tconnectsync.secret import NIGHTSCOUT_PROFILE_CARBS_HR_VALUE, NIGHTSCOUT_PROFILE_DELAY_VALUE, TIMEZONE_NAME
DEVICE_PROFILE_A = Profile(
title='A',
active=False,
segments=[
ProfileSegment(
display_time='Midnight',
time='12:00 AM',
basal_rate=0.8,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='6:00 AM',
time='6:00 AM',
basal_rate=1.25,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='11:00 AM',
time='11:00 AM',
basal_rate=1.0,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='Noon',
time='12:00 PM',
basal_rate=0.8,
correction_factor=30.0,
carb_ratio=6.0,
target_bg_mgdl=110.0)
],
calculated_total_daily_basal=21.65,
insulin_duration_min=300,
carbs_enabled=True
)
DEVICE_PROFILE_B = Profile(
title='B',
active=False,
segments=[
ProfileSegment(
display_time='Midnight',
time='12:00 AM',
basal_rate=0.8,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='6:00 AM',
time='6:00 AM',
basal_rate=1.25,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='11:00 AM',
time='11:00 AM',
basal_rate=1.0,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0),
ProfileSegment(
display_time='Noon',
time='12:00 PM',
basal_rate=0.9,
correction_factor=30.0,
carb_ratio=12.0,
target_bg_mgdl=110.0)
],
calculated_total_daily_basal=22.85,
insulin_duration_min=300,
carbs_enabled=True
)
DEVICE_SETTINGS = DeviceSettings(
low_bg_threshold=80,
high_bg_threshold=200,
raw_settings={}
)
NS_PROFILE_A = {
"dia": "5.0",
"carbratio": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 6.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 6.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 6.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 6.0
}
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 30.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 30.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 30.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 30.0
}
],
"basal": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 0.8
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 1.25
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 1.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 0.8
}
],
"target_low": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 80
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 200
}
],
"timezone": TIMEZONE_NAME,
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
NS_PROFILE_B = {
"dia": "5.0",
"carbratio": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 12.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 12.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 12.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 12.0
}
],
"carbs_hr": NIGHTSCOUT_PROFILE_CARBS_HR_VALUE,
"delay": NIGHTSCOUT_PROFILE_DELAY_VALUE,
"sens": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 30.0
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 30.0
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 30.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 30.0
}
],
"basal": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 0.8
},
{
"time": "06:00",
"timeAsSeconds": 6*60*60,
"value": 1.25
},
{
"time": "11:00",
"timeAsSeconds": 11*60*60,
"value": 1.0
},
{
"time": "12:00",
"timeAsSeconds": 12*60*60,
"value": 0.9
}
],
"target_low": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 80
}
],
"target_high": [
{
"time": "00:00",
"timeAsSeconds": 0,
"value": 200
}
],
"timezone": TIMEZONE_NAME,
"startDate": "1970-01-01T00:00:00.000Z",
"units": "mg/dl"
}
NS_PROFILE_STORE = {
'A': NS_PROFILE_A,
'B': NS_PROFILE_B
}
+88 -10
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.domain.bolus import Bolus
from tconnectsync.parser.tconnect import TConnectEntry, UnknownBasalSuspensionEventException, UnknownCIQActivityEventException
class TestTConnectEntryBasal(unittest.TestCase):
@@ -177,7 +178,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_correction(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdCorrection),
{
Bolus(**{
"description": "Standard/Correction",
"complete": "1",
"completion": "Completed",
@@ -191,7 +192,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStd = {
"Type": "Bolus",
@@ -239,7 +240,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStd),
{
Bolus(**{
"description": "Standard",
"complete": "1",
"completion": "Completed",
@@ -253,7 +254,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStdAutomatic = {
"Type": "Bolus",
@@ -301,7 +302,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_automatic(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdAutomatic),
{
Bolus(**{
"description": "Automatic Bolus/Correction",
"complete": "1",
"completion": "Completed",
@@ -315,7 +316,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStdIncompleteZero = {
"Type": "Bolus",
@@ -363,7 +364,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_incomplete_zero(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdIncompleteZero),
{
Bolus(**{
"description": "Standard",
"complete": "",
"completion": "User Aborted",
@@ -377,7 +378,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"bolex_start_time": None
})
}))
entryStdIncompletePartial = {
"Type": "Bolus",
@@ -425,7 +426,7 @@ class TestTConnectEntryBolus(unittest.TestCase):
def test_parse_bolus_entry_std_incomplete_partial(self):
self.assertEqual(
TConnectEntry.parse_bolus_entry(self.entryStdIncompletePartial),
{
Bolus(**{
"description": "Standard/Correction",
"complete": "",
"completion": "Terminated by Alarm",
@@ -439,7 +440,72 @@ class TestTConnectEntryBolus(unittest.TestCase):
"extended_bolus": "",
"bolex_completion_time": None,
"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 = {
@@ -594,6 +660,18 @@ class TestTConnectEntryBasalSuspensionEvent(unittest.TestCase):
}
)
def test_parse_basalsuspension_event_tempprofile(self):
self.assertEqual(
TConnectEntry.parse_basalsuspension_event({
'EventDateTime': '/Date(1640541521000-0000)/',
'SuspendReason': 'temp-profile'
}),
{
"time": "2021-12-26 09:58:41-05:00",
"event_type": "Basal Rate Change"
}
)
def test_parse_basalsuspension_event_basalprofile_skipped(self):
self.assertIsNone(
TConnectEntry.parse_basalsuspension_event({
+15
View File
@@ -0,0 +1,15 @@
import importlib
def build_secrets(**kwargs):
secret = importlib.reload(importlib.import_module("tconnectsync.secret"))
class FakeSecret(object):
pass
fake = FakeSecret()
for k in dir(secret):
setattr(fake, k, getattr(secret, k))
for k, v in kwargs.items():
setattr(fake, k, v)
return fake
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import unittest
import arrow
from tconnectsync.sync.tandemsource.process_alarm import ProcessAlarm
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
class TestProcessAlarm(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessAlarm(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_resume_alarm_ignored(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-11-11 07:24:29-05:00
Event(b'\x00\x05\x1f\xb8.\xad\x00\x0e\x91\xee\x00\x00\x00\x12\x00\x00 w\x01N\x0b\x16\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAlarmActivated)
self.assertEqual(events[0].alarmid, eventtypes.LidAlarmActivated.AlarmidEnum.ResumePumpAlarm)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 0)
def test_empty_cartridge_alarm(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-11-17 08:44:17-05:00
Event(b'\x00\x05\x1f\xc0*a\x00\x0e\xf5\x90\x00\x00\x00\x08\x00\x00 1\x00\x00\x00gA\x1a\x1e\x84')
]
self.assertEqual(type(events[0]), eventtypes.LidAlarmActivated)
self.assertEqual(events[0].alarmid, eventtypes.LidAlarmActivated.AlarmidEnum.EmptyCartridgeAlarm)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'eventType': 'Alarm',
'created_at': '2024-11-17 08:44:17-05:00',
'enteredBy': 'Pump (tconnectsync)',
'notes': 'EmptyCartridgeAlarm',
'reason': 'EmptyCartridgeAlarm',
'pump_event_id': '980368'
})
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
import unittest
import struct
from tconnectsync.sync.tandemsource.process_device_status import ProcessDeviceStatus
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.events import UINT16
from tconnectsync.eventparser.generic import Event
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
class TestProcessDeviceStatus(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessDeviceStatus(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_single_event_no_last_uploaded(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'created_at': '2024-12-03 23:40:23-05:00',
'device': 'Pump (tconnectsync)',
'pump': {
'clock': '2024-12-03 23:40:23-05:00',
'battery': {
'status': '32%',
'percent': 32,
'voltage': 14.08
}
},
'pump_event_id': '1046436'
})
def test_single_event_already_uploaded(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 0)
def test_multiple_event_with_last_uploaded(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'),
Event(b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
self.assertEqual(events[1].batterychargepercentmsbRaw, 14)
self.assertEqual(events[1].batterychargepercentlsbRaw, 243)
self.assertEqual(events[1].batterylipomillivolts, 13824)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'created_at': '2024-12-04 02:30:23-05:00',
'device': 'Pump (tconnectsync)',
'pump': {
'clock': '2024-12-04 02:30:23-05:00',
'battery': {
'status': '31%',
'percent': 31,
'voltage': 13.824
}
},
'pump_event_id': '1046875'
})
def test_multiple_event_only_latest_applied(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: {'created_at': '2024-12-03 23:40:23-05:00'}
events = [
Event(b'\x00Q\x1f\xd6\x14g\x00\x0f\xf7\xa4A\xb2\xd3\xe2?L\xcc\xcd@~\xdeb\x0e\xf67\x00'),
Event(b'\x00Q\x1f\xd6<?\x00\x0f\xf9[@\r\xcd{?\x9b\xa5\xe3?\xe3\x9a;\x0e\xf36\x00'),
Event(b'\x00Q\x1f\xd6k\x1f\x00\x0f\xfc>A\x0e\xa3\x80?\x9a~\xfa@\x11z6\x0e\xee5\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidDailyBasal)
self.assertEqual(events[0].raw.timestampRaw, 534123623) # 2024-12-03 23:40:23-05:00
self.assertEqual(events[0].batterychargepercentmsbRaw, 14)
self.assertEqual(events[0].batterychargepercentlsbRaw, 246)
self.assertEqual(events[0].batterylipomillivolts, 14080)
self.assertEqual(type(events[1]), eventtypes.LidDailyBasal)
self.assertEqual(events[1].raw.timestampRaw, 534133823) # 2024-12-04 02:30:23-05:00
self.assertEqual(events[1].batterychargepercentmsbRaw, 14)
self.assertEqual(events[1].batterychargepercentlsbRaw, 243)
self.assertEqual(events[1].batterylipomillivolts, 13824)
self.assertEqual(type(events[2]), eventtypes.LidDailyBasal)
self.assertEqual(events[2].raw.timestampRaw, 534145823) # 2024-12-04 05:50:23-05:00
self.assertEqual(events[2].batterychargepercentmsbRaw, 14)
self.assertEqual(events[2].batterychargepercentlsbRaw, 238)
self.assertEqual(events[2].batterylipomillivolts, 13568)
p = self.process.process(events, time_start=None, time_end=None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'created_at': '2024-12-04 05:50:23-05:00',
'device': 'Pump (tconnectsync)',
'pump': {
'clock': '2024-12-04 05:50:23-05:00',
'battery': {
'status': '30%',
'percent': 30,
'voltage': 13.568
}
},
'pump_event_id': '1047614'
})
@unittest.skip
def test_device_status_battery_calculation(self):
self.nightscout.last_uploaded_devicestatus = lambda *args, **kwargs: None
def test_percentage_about(percent_str, event):
with self.subTest(percent_str, event=event):
# p = self.process.process([event], time_start=None, time_end=None)
# self.assertEqual(len(p), 1)
# self.assertEqual(p[0]['pump']['battery']['status'], percent_str)
msb = event.batterychargepercentmsbRaw
lsb = event.batterychargepercentlsbRaw
MAX = struct.unpack(UINT16, bytearray((16, 128)))[0]
MIN = struct.unpack(UINT16, bytearray((14, 128)))[0]
val = struct.unpack(UINT16, bytearray((msb, lsb)))[0]
pct = (val - MIN)/(MAX-MIN)
# print(pct)
# print(struct.unpack(UINT16, bytearray((msb, lsb))
# (14, 100) =~ 0%
# (15, 100) =~ 50%
# (16, 98) =~ 100%
# msb*256 + lsb - 14*256
# pct = (msb*256 + lsb - 14*256 - 100) / 512
calc_str = "%.0f%s" % (100*pct, '%')
print(f'comp {calc_str=} {percent_str=}')
self.assertEqual(calc_str, percent_str)
test_percentage_about('80%', Event(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'))
test_percentage_about('55%', Event(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00'))
test_percentage_about('45%', Event(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00'))
test_percentage_about('15%', Event(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00'))
test_percentage_about('20%', Event(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00'))
test_percentage_about('10%', Event(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00'))
test_percentage_about('10%', Event(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00'))
test_percentage_about('5%', Event(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00'))
# Mobi @ MAX seen
# bytearray(b'\x00Q \x19U=\x00\x01\x0f\xa8A\xa5\x04V?L\xcc\xcd?s\x83b\x10Pd\x01')
# batterychargepercentmsbRaw=16, batterychargepercentlsbRaw=80, batterylipomillivolts=25601
# Mobi @ ~80%
# bytearray(b'\x00Q\x1f\xfdm\xf5\x00\x00\x04P@4\xa7\xed?L\xcc\xcd@+\xd8\x81\x0f\xa1P\x00'):
# batterychargepercentmsbRaw=15, batterychargepercentlsbRaw=161, batterylipomillivolts=20480
# calc batteryChargePercent = 0.54296875
# Mobi @ ~55%
# bytearray(b'\x00Q \x04\x15\xdd\x00\x00E\xb1A\x86\xe0\xcf\x00\x00\x00\x00A9\xd2w\x0f\x1d=\x00')
# batterychargepercentmsbRaw=15, batterychargepercentlsbRaw=29, batterylipomillivolts=15616
# calc batteryChargePercent = 0.37109375
# Mobi @ ~45%
# bytearray(b'\x00Q \x04\xd8f\x00\x00L^A^\xc5\x9a>49X\x00\x00\x00\x00\x0e\xfa7\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=250 batterylipomillivolts=14080
# calc batteryChargePercent = 0.3255208333333333
# Mobi @ ~15%
# bytearray(b'\x00Q \x06#U\x00\x00X{A\t\xed\xeb?\tx\xd5<\xe0\x81[\x0e\xb5 \x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=181 batterylipomillivolts=8192
# calc batteryChargePercent = 0.23567708333333334
# Mobi @ ~20%
# bytearray(b'\x00Q \x1d\xbb\xa5\x00\x019zA\x1e\x1f`@%p\xa4>\xad\xaa\xf1\x0e\xc1$\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=193 batterylipomillivolts=9216
# calc batteryChargePercent = 0.2513020833333333
# Mobi @ ~10%
# bytearray(b'\x00Q \x1e\\m\x00\x01?}A\xa0\xe2\x8b?L\xcc\xcd?\xda\xeaj\x0e\xa1\x1b\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=161 batterylipomillivolts=6912
# calc batteryChargePercent = 0.20963541666666666
# Mobi @ ~10%
# bytearray(b'\x00Q \x1ej~\x00\x01@2A\xa5\xafZ\x00\x00\x00\x00@\x8c[\xed\x0e\x9f\x1a\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=159 batterylipomillivolts=6656
# calc batteryChargePercent = 0.20703125
# Mobi @ ~5%
# bytearray(b'\x00Q \x1e\xa2\xbd\x00\x01C\x1a?\xd9?}@MO\xdf@uz<\x0e\x88\x15\x00')
# batterychargepercentmsbRaw=14, batterychargepercentlsbRaw=136
# calc batteryChargePercent = 0.17708333333333334 batterylipomillivolts=5376
# Mobi @ ~5%
# bytearray(b'\x00Q \x1e\xb0\xcd\x00\x01C\xd3@L9W@#33@\x8b\x04\xd2\x0e\x88\x15\x00')
# batterychargepercentmsbRaw=14 batterychargepercentlsbRaw=136 batterylipomillivolts=5376
# calc 0.17708333333333334
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
import unittest
import arrow
from tconnectsync.sync.tandemsource.process_user_mode import ProcessUserMode
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.generic import Event
from ...api.fake import TConnectApi
from ...nightscout_fake import NightscoutApi
class TestProcessUserModeSleep(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessUserMode(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_single_start_sleep_active(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - sleep start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x01\x00\x01\x00\x00\x01\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled) - Not Ended',
"notes": 'Sleep (Scheduled) - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_single_stop_sleep_no_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 09:01:23-05:00 - sleep end
Event(b'\x00\xe5\x1f\xd6\x97\xe3\x00\x0f\xfe\xb0\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 0)
self.assertEqual(self.nightscout.deleted_entries, [])
def test_start_and_stop_sleep(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - sleep start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x01\x00\x01\x00\x00\x01\x00\x00\xf0\x01\x01\x00\x00\x00\x00'),
# 2024-12-05 09:01:23-05:00 - sleep end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Sleeping)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartSleep)
self.assertEqual(type(events[1]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[1].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
self.assertEqual(events[1].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[1].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled)',
"notes": 'Sleep (Scheduled)',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_stop_with_partial_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda eventType, **kwargs: {
'Sleep': {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled) - Not Ended',
"notes": 'Sleep (Scheduled) - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050',
"_id": "id_to_delete"
}
}.get(eventType)
events = [
# 2024-12-05 09:01:23-05:00 - sleep end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x02\x01\x00\x00\x00\x00\x00\x00\xf0\x01\x01\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Sleeping)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopSleep)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Sleep',
"reason": 'Sleep (Scheduled)',
"notes": 'Sleep (Scheduled)',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, ['treatments/id_to_delete'])
class TestProcessUserModeExercise(unittest.TestCase):
maxDiff = None
def setUp(self):
self.tconnect = TConnectApi()
self.nightscout = NightscoutApi()
self.tconnect_device_id = 'abcdef'
self.process = ProcessUserMode(self.tconnect, self.nightscout, self.tconnect_device_id, pretend=False)
def test_single_start_exercise_active(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - exercise start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x03\x00\x02\x00\x00\x01\x00\x00\xf0\x00\x01\x00\x00\x00\x00'),
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Exercising)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Exercise',
"reason": 'Exercise - Not Ended',
"notes": 'Exercise - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_single_stop_exercise_no_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 09:01:23-05:00 - exercise end
Event(b'\x00\xe5\x1f\xd6\x97\xe3\x00\x0f\xfe\xb0\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x01\x03\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
time_end = arrow.get('2024-12-04T23:00:30-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 0)
self.assertEqual(self.nightscout.deleted_entries, [])
def test_start_and_stop_exercise(self):
self.nightscout.last_uploaded_entry = lambda *args, **kwargs: None
events = [
# 2024-12-04 23:00:23-05:00 - exercise start
Event(b'\x00\xe5\x1f\xd7\\\x87\x00\x10\t\xaa\x00\x03\x00\x02\x00\x00\x01\x00\x00\xf0\x00\x01\x00\x00\x00\x00'),
# 2024-12-05 09:01:23-05:00 - exercise end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Normal)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Exercising)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StartExercise)
self.assertEqual(type(events[1]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[1].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
self.assertEqual(events[1].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[1].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Exercise',
"reason": 'Exercise',
"notes": 'Exercise',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, [])
def test_stop_with_partial_last_uploaded(self):
self.nightscout.last_uploaded_entry = lambda eventType, **kwargs: {
'Exercise': {
"eventType": 'Exercise',
"reason": 'Exercise - Not Ended',
"notes": 'Exercise - Not Ended',
"duration": 0.11666666666666667,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050',
"_id": 'id_to_delete'
}
}.get(eventType)
events = [
# 2024-12-05 09:01:23-05:00 - exercise end
Event(b'\x00\xe5\x1f\xd7\xe9c\x00\x10\x10\xf8\x00\x04\x02\x00\x00\x00\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x00')
]
self.assertEqual(type(events[0]), eventtypes.LidAaUserModeChange)
self.assertEqual(events[0].previoususermode, eventtypes.LidAaUserModeChange.PrevioususermodeEnum.Exercising)
self.assertEqual(events[0].currentusermode, eventtypes.LidAaUserModeChange.CurrentusermodeEnum.Normal)
self.assertEqual(events[0].requestedaction, eventtypes.LidAaUserModeChange.RequestedactionEnum.StopExercise)
time_end = arrow.get('2024-12-05T10:00:00-05:00')
p = self.process.process(events, time_start=None, time_end=time_end)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
"eventType": 'Exercise',
"reason": 'Exercise',
"notes": 'Exercise',
"duration": 601.0,
"created_at": '2024-12-04 23:00:23-05:00',
"enteredBy": "Pump (tconnectsync)",
"pump_event_id": '1051050,1052920'
})
self.assertEqual(self.nightscout.deleted_entries, ['treatments/id_to_delete'])
if __name__ == '__main__':
unittest.main()
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env python3
import unittest
import copy
from tconnectsync.sync.basal import process_ciq_basal_events
from tconnectsync.parser.tconnect import TConnectEntry
class TestBasalSync(unittest.TestCase):
maxDiff = None
base = {
"basal": {
"profileRates": [],
"tempDeliveryEvents": [],
"algorithmDeliveryEvents": [],
"profileDeliveryEvents": []
},
"events": [],
"suspensionDeliveryEvents": [],
"softwareUpdates": [],
"pumpFeatures": []
}
@staticmethod
def get_example_ciq_basal_events():
data = copy.deepcopy(TestBasalSync.base)
data["basal"]["tempDeliveryEvents"] = [
{
"y": 0.8,
"duration": 1221,
"x": 1615878000 # 12:00:00
}
]
data["basal"]["algorithmDeliveryEvents"] = [
{
"y": 0.797,
"duration": 300,
"x": 1615879521 # 12:25:21
},
{
"y": 0,
"duration": 2693,
"x": 1615879821 # 12:30:21
},
]
data["basal"]["profileDeliveryEvents"] = [
{
"y": 0.799,
"duration": 300,
"x": 1615879221 # 12:20:21
}
]
data["suspensionDeliveryEvents"] = [
{
"suspendReason": "control-iq",
"continuation": None,
"x": 1615879821 # 12:30:21
},
]
return data
def test_process_ciq_basal_events(self):
data = TestBasalSync.get_example_ciq_basal_events()
basalEvents = process_ciq_basal_events(data)
self.assertEqual(len(basalEvents), 4)
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["tempDeliveryEvents"][0], delivery_type="tempDelivery"))
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["profileDeliveryEvents"][0], delivery_type="profileDelivery"))
self.assertEqual(basalEvents[2], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][1],
delivery_type="algorithmDelivery (control-iq suspension)")
)
@staticmethod
def get_example_ciq_basal_events_with_manual_suspension():
data = copy.deepcopy(TestBasalSync.base)
data["basal"]["tempDeliveryEvents"] = []
data["basal"]["algorithmDeliveryEvents"] = [
{
"y": 1.14,
"duration": 300,
"x": 1635187357 # 11:42:37
},
{
"y": 0.8,
"duration": 1198,
"x": 1635187657 # 11:47:37
},
{
"y": 0.8,
"duration": 599,
"x": 1635190967 # 12:42:47
}
]
data["basal"]["profileDeliveryEvents"] = []
data["suspensionDeliveryEvents"] = [
{
"suspendReason": "manual",
"continuation": None,
"x": 1635188855 # 12:07:35
},
{
"suspendReason": "manual",
"continuation": None,
"x": 1635191566 # 12:52:46
},
]
return data
def test_process_ciq_basal_events_with_manual_suspension(self):
data = TestBasalSync.get_example_ciq_basal_events_with_manual_suspension()
basalEvents = process_ciq_basal_events(data)
self.assertEqual(len(basalEvents), 4)
self.assertEqual(basalEvents[0], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][0], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[1], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][1], delivery_type="algorithmDelivery"))
self.assertEqual(basalEvents[2], TConnectEntry.manual_suspension_to_basal_entry(
TConnectEntry.parse_suspension_entry(data["suspensionDeliveryEvents"][0]),
seconds=2112, # 2112 seconds between 12:07:35 and 12:42:47
))
self.assertEqual(basalEvents[3], TConnectEntry.parse_ciq_basal_entry(
data["basal"]["algorithmDeliveryEvents"][2], delivery_type="algorithmDelivery"))
if __name__ == '__main__':
unittest.main()
-176
View File
@@ -1,176 +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)
if __name__ == '__main__':
unittest.main()
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.cgm import find_event_at, process_cgm_events
from tconnectsync.parser.tconnect import TConnectEntry
from ..parser.test_tconnect import TestTConnectEntryReading
class TestProcessCGMEvents(unittest.TestCase):
def test_process_cgm_events(self):
rawReadings = [
TestTConnectEntryReading.entry1,
TestTConnectEntryReading.entry2,
TestTConnectEntryReading.entry3,
TestTConnectEntryReading.entry4
]
self.assertListEqual(
process_cgm_events(rawReadings),
[TConnectEntry.parse_reading_entry(r) for r in rawReadings]
)
class TestFindEventAt(unittest.TestCase):
readingData = [
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry1),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry2),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry3),
TConnectEntry.parse_reading_entry(TestTConnectEntryReading.entry4)
]
def test_find_event_at_exact(self):
for r in self.readingData:
self.assertEqual(find_event_at(self.readingData, r["time"]), r)
def test_find_event_at_before_not_found(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-22 10:30:00-04:00"), None)
def test_find_event_at_large_gap(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 13:30:00-04:00"), self.readingData[0])
def test_find_event_at_between_close(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:17:52-04:00"), self.readingData[1])
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:21:52-04:00"), self.readingData[2])
self.assertEqual(find_event_at(self.readingData, "2021-10-23 16:25:59-04:00"), self.readingData[3])
def test_find_event_at_most_recent(self):
self.assertEqual(find_event_at(self.readingData, "2021-10-23 18:00:00-04:00"), self.readingData[3])
if __name__ == '__main__':
unittest.main()
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env python3
import unittest
from tconnectsync.sync.iob import process_iob_events
from tconnectsync.parser.tconnect import TConnectEntry
from ..parser.test_tconnect import TestTConnectEntryIOB
class TestIOBSync(unittest.TestCase):
@staticmethod
def get_example_csv_iob_events():
return [
TestTConnectEntryIOB.entry1,
TestTConnectEntryIOB.entry2,
]
def test_process_iob_events(self):
iobData = TestIOBSync.get_example_csv_iob_events()
iobEvents = process_iob_events(iobData)
self.assertEqual(len(iobEvents), len(iobData))
self.assertListEqual(iobEvents, [
TConnectEntry.parse_iob_entry(d) for d in iobData
])
if __name__ == '__main__':
unittest.main()
-701
View File
@@ -1,701 +0,0 @@
#!/usr/bin/env python3
import unittest
import datetime
import pprint
import copy
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, PUMP_EVENTS
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
class TestProcessTimeRange(unittest.TestCase):
maxDiff = None
def stub_therapy_timeline(self, time_start, time_end):
return copy.deepcopy(TestBasalSync.base)
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):
return None
def stub_last_uploaded_activity(self, activity_type):
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.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
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, [])
"""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.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
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, [])
"""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.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
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
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, [])
"""
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.ws2.therapy_timeline_csv = self.stub_therapy_timeline_csv
nightscout = NightscoutApi()
def fake_last_uploaded_entry(event_type):
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
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, [])
"""No data in Nightscout. Uploads all bolus data from tconnect."""
def test_new_ciq_bolus_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
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
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, [])
"""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
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, [])
"""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
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
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, [])
"""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
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
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, [])
"""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):
if activityType == IOB_ACTIVITYTYPE:
return iobData[0]
return self.stub_last_uploaded_activity(activityType)
nightscout.last_uploaded_activity = fake_last_uploaded_activity
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"
])
"""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
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, [])
"""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.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
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, [])
"""
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):
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()
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
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"
])
"""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
process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PUMP_EVENTS])
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, [])
"""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):
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()
nightscout.last_uploaded_entry = fake_last_uploaded_entry
nightscout.last_uploaded_activity = self.stub_last_uploaded_activity
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": [
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, [])
"""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
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, [])
if __name__ == '__main__':
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
import unittest
import unittest.mock
import tempfile
import importlib
import contextlib
import pathlib
import os
@contextlib.contextmanager
def chdir(dir):
orig_cwd = os.getcwd()
os.chdir(dir)
try:
yield
finally:
os.chdir(orig_cwd)
class TestSecretDotEnv(unittest.TestCase):
maxDiff = None
def setUp(self):
if 'TCONNECT_EMAIL' in os.environ:
del os.environ['TCONNECT_EMAIL']
if 'NS_URL' in os.environ:
del os.environ['NS_URL']
def write_test_dotenv_file(self, path, type):
with open(os.path.join(path, ".env"), "w") as f:
f.write("""
TCONNECT_EMAIL=test_%s_email@email.com
NS_URL=http://test_%s_url
""" % (type, type))
f.close()
def import_secret(self):
return importlib.reload(importlib.import_module("tconnectsync.secret"))
def test_dotenv_in_current_working_directory(self):
with tempfile.TemporaryDirectory(prefix='dotenv_cwd') as dir, chdir(dir):
self.write_test_dotenv_file(dir, "dotenv_cwd")
secret = self.import_secret()
self.assertEqual(secret.TCONNECT_EMAIL, "test_dotenv_cwd_email@email.com")
self.assertEqual(secret.NS_URL, "http://test_dotenv_cwd_url")
def test_dotenv_in_homedir_config_folder(self):
with tempfile.TemporaryDirectory(prefix='dotenv_homedir_config') as dir, chdir(dir):
config_dir = os.path.join(dir, '.config/tconnectsync')
os.makedirs(config_dir)
self.write_test_dotenv_file(config_dir, "dotenv_homedir_config")
with unittest.mock.patch.object(pathlib.Path, "home") as mock_home:
mock_home.return_value = dir
secret = self.import_secret()
self.assertEqual(secret.TCONNECT_EMAIL, "test_dotenv_homedir_config_email@email.com")
self.assertEqual(secret.NS_URL, "http://test_dotenv_homedir_config_url")
def test_no_dotenv_file_reads_from_environment(self):
with tempfile.TemporaryDirectory(prefix='dotenv_environ') as dir, chdir(dir):
environ = {
"TCONNECT_EMAIL": "test_environ_email@email.com",
"NS_URL": "http://test_environ_url"
}
with unittest.mock.patch.dict(os.environ, environ):
secret = self.import_secret()
self.assertEqual(secret.TCONNECT_EMAIL, environ["TCONNECT_EMAIL"])
self.assertEqual(secret.NS_URL, environ["NS_URL"])
def test_environment_merges_with_dotenv_file(self):
with tempfile.TemporaryDirectory(prefix='env_plus_environ') as dir, chdir(dir):
self.write_test_dotenv_file(dir, "dotenv_cwd_defaults")
environ = {
"TCONNECT_EMAIL": "test_environ_override_email@email.com"
}
with unittest.mock.patch.dict(os.environ, environ):
secret = self.import_secret()
self.assertEqual(secret.TCONNECT_EMAIL, "test_environ_override_email@email.com")
self.assertEqual(secret.NS_URL, "http://test_dotenv_cwd_defaults_url")
def test_dotenv_in_current_working_directory_overrides_homedir_config(self):
with tempfile.TemporaryDirectory(prefix='dotenv_cwd') as dir, chdir(dir):
self.write_test_dotenv_file(dir, "dotenv_cwd")
config_dir = os.path.join(dir, '.config/tconnectsync')
os.makedirs(config_dir)
self.write_test_dotenv_file(config_dir, "dotenv_homedir_config")
with unittest.mock.patch.object(pathlib.Path, "home") as mock_home:
mock_home.return_value = dir
secret = self.import_secret()
self.assertEqual(secret.TCONNECT_EMAIL, "test_dotenv_cwd_email@email.com")
self.assertEqual(secret.NS_URL, "http://test_dotenv_cwd_url")
if __name__ == '__main__':
unittest.main()