mirror of
https://github.com/jwoglom/tconnectsync.git
synced 2026-08-24 18:24:11 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d8dabb3ef | ||
|
|
798225c606 | ||
|
|
b08b9b8c76 | ||
|
|
90bbd5dc63 | ||
|
|
5e56b41b62 | ||
|
|
e8fa6c5a7c | ||
|
|
fae1d4de2c | ||
|
|
76085e8d65 | ||
|
|
b4d0dad267 | ||
|
|
7b22d612c3 | ||
|
|
8b1ece3ac3 | ||
|
|
7b06f1c457 | ||
|
|
3a400390de | ||
|
|
e2b30687e4 | ||
|
|
0d956516d7 | ||
|
|
50cb57f11b | ||
|
|
2e515efc54 | ||
|
|
b52006b3b0 | ||
|
|
07e5761178 | ||
|
|
631d1c7b89 | ||
|
|
0253956377 | ||
|
|
59816225e0 | ||
|
|
be113e9cf7 | ||
|
|
90bede8b75 | ||
|
|
7a8d00da88 | ||
|
|
52cb287ce8 | ||
|
|
27ba5d9a64 | ||
|
|
2140bd356e | ||
|
|
a74b63ab05 |
@@ -2,8 +2,11 @@ name: Publish Docker image
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
@@ -21,3 +24,35 @@ jobs:
|
||||
registry: docker.pkg.github.com
|
||||
repository: jwoglom/tconnectsync/tconnectsync
|
||||
tag_with_ref: true
|
||||
|
||||
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 }}
|
||||
|
||||
@@ -7,3 +7,4 @@ build
|
||||
*.swa
|
||||
*.egg-info
|
||||
.env
|
||||
tconnectsync-check-output.log
|
||||
|
||||
@@ -7,7 +7,7 @@ verify_ssl = true
|
||||
ptpython = "*"
|
||||
|
||||
[packages]
|
||||
tconnectsync = {editable = true, path = "."}
|
||||
tconnectsync = {path = "."}
|
||||
requests = "*"
|
||||
bs4 = "*"
|
||||
arrow = "*"
|
||||
|
||||
Generated
+94
-82
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "413f8bbd66e702656262deda1b6586d42b5a2e9fa52f6d23416b1ea1efbc2591"
|
||||
"sha256": "84349eb022c87848b29854316de1740d1223d444f06eb9dcbf7df92aa98475e2"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
@@ -16,11 +16,11 @@
|
||||
"default": {
|
||||
"arrow": {
|
||||
"hashes": [
|
||||
"sha256:16fc29bbd9e425e3eb0fef3018297910a0f4568f21116fc31771e2760a50e074",
|
||||
"sha256:8fb7d9d3d4bf90e49e734c22fa077bdd0964135c4b8120de2510575a8d1f620c"
|
||||
"sha256:6b2914ef3997d1fd7b37a71ce9dd61a6e329d09e1c7b44f4d3099ca4a5c0933e",
|
||||
"sha256:c2dde3c382d9f7e6922ce636bf0b318a7a853df40ecb383b29192e6c5cc82840"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.2.0"
|
||||
"version": "==1.2.1"
|
||||
},
|
||||
"beautifulsoup4": {
|
||||
"hashes": [
|
||||
@@ -46,11 +46,11 @@
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0",
|
||||
"sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"
|
||||
"sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd",
|
||||
"sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"
|
||||
],
|
||||
"markers": "python_version >= '3'",
|
||||
"version": "==2.0.7"
|
||||
"version": "==2.0.10"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
@@ -62,57 +62,69 @@
|
||||
},
|
||||
"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:0607ff0988ad7e173e5ddf7bf55ee65534bd18a5461183c33e8e41a59e89edf4",
|
||||
"sha256:09b738360af8cb2da275998a8bf79517a71225b0de41ab47339c2beebfff025f",
|
||||
"sha256:0a5f0e4747f31cff87d1eb32a6000bde1e603107f632ef4666be0dc065889c7a",
|
||||
"sha256:0b5e96e25e70917b28a5391c2ed3ffc6156513d3db0e1476c5253fcd50f7a944",
|
||||
"sha256:1104a8d47967a414a436007c52f533e933e5d52574cab407b1e49a4e9b5ddbd1",
|
||||
"sha256:13dbb5c7e8f3b6a2cf6e10b0948cacb2f4c9eb05029fe31c60592d08ac63180d",
|
||||
"sha256:2a906c3890da6a63224d551c2967413b8790a6357a80bf6b257c9a7978c2c42d",
|
||||
"sha256:317bd63870b4d875af3c1be1b19202de34c32623609ec803b81c99193a788c1e",
|
||||
"sha256:34c22eb8c819d59cec4444d9eebe2e38b95d3dcdafe08965853f8799fd71161d",
|
||||
"sha256:36b16fecb10246e599f178dd74f313cbdc9f41c56e77d52100d1361eed24f51a",
|
||||
"sha256:38d9759733aa04fb1697d717bfabbedb21398046bd07734be7cccc3d19ea8675",
|
||||
"sha256:3e26ad9bc48d610bf6cc76c506b9e5ad9360ed7a945d9be3b5b2c8535a0145e3",
|
||||
"sha256:41358bfd24425c1673f184d7c26c6ae91943fe51dfecc3603b5e08187b4bcc55",
|
||||
"sha256:447d5009d6b5447b2f237395d0018901dcc673f7d9f82ba26c1b9f9c3b444b60",
|
||||
"sha256:44f552e0da3c8ee3c28e2eb82b0b784200631687fc6a71277ea8ab0828780e7d",
|
||||
"sha256:490712b91c65988012e866c411a40cc65b595929ececf75eeb4c79fcc3bc80a6",
|
||||
"sha256:4c093c571bc3da9ebcd484e001ba18b8452903cd428c0bc926d9b0141bcb710e",
|
||||
"sha256:50d3dba341f1e583265c1a808e897b4159208d814ab07530202b6036a4d86da5",
|
||||
"sha256:534e946bce61fd162af02bad7bfd2daec1521b71d27238869c23a672146c34a5",
|
||||
"sha256:585ea241ee4961dc18a95e2f5581dbc26285fcf330e007459688096f76be8c42",
|
||||
"sha256:59e7da839a1238807226f7143c68a479dee09244d1b3cf8c134f2fce777d12d0",
|
||||
"sha256:5b0f782f0e03555c55e37d93d7a57454efe7495dab33ba0ccd2dbe25fc50f05d",
|
||||
"sha256:5bee1b0cbfdb87686a7fb0e46f1d8bd34d52d6932c0723a86de1cc532b1aa489",
|
||||
"sha256:610807cea990fd545b1559466971649e69302c8a9472cefe1d6d48a1dee97440",
|
||||
"sha256:6308062534323f0d3edb4e702a0e26a76ca9e0e23ff99be5d82750772df32a9e",
|
||||
"sha256:67fa5f028e8a01e1d7944a9fb616d1d0510d5d38b0c41708310bd1bc45ae89f6",
|
||||
"sha256:6a2ab9d089324d77bb81745b01f4aeffe4094306d939e92ba5e71e9a6b99b71e",
|
||||
"sha256:6c198bfc169419c09b85ab10cb0f572744e686f40d1e7f4ed09061284fc1303f",
|
||||
"sha256:6e56521538f19c4a6690f439fefed551f0b296bd785adc67c1777c348beb943d",
|
||||
"sha256:6ec829058785d028f467be70cd195cd0aaf1a763e4d09822584ede8c9eaa4b03",
|
||||
"sha256:718d7208b9c2d86aaf0294d9381a6acb0158b5ff0f3515902751404e318e02c9",
|
||||
"sha256:735e3b4ce9c0616e85f302f109bdc6e425ba1670a73f962c9f6b98a6d51b77c9",
|
||||
"sha256:772057fba283c095db8c8ecde4634717a35c47061d24f889468dc67190327bcd",
|
||||
"sha256:7b5e2acefd33c259c4a2e157119c4373c8773cf6793e225006a1649672ab47a6",
|
||||
"sha256:82d16a64236970cb93c8d63ad18c5b9f138a704331e4b916b2737ddfad14e0c4",
|
||||
"sha256:87c1b0496e8c87ec9db5383e30042357b4839b46c2d556abd49ec770ce2ad868",
|
||||
"sha256:8e54945dd2eeb50925500957c7c579df3cd07c29db7810b83cf30495d79af267",
|
||||
"sha256:9393a05b126a7e187f3e38758255e0edf948a65b22c377414002d488221fdaa2",
|
||||
"sha256:9fbc0dee7ff5f15c4428775e6fa3ed20003140560ffa22b88326669d53b3c0f4",
|
||||
"sha256:a1613838aa6b89af4ba10a0f3a972836128801ed008078f8c1244e65958f1b24",
|
||||
"sha256:a1bbc4efa99ed1310b5009ce7f3a1784698082ed2c1ef3895332f5df9b3b92c2",
|
||||
"sha256:a555e06566c6dc167fbcd0ad507ff05fd9328502aefc963cb0a0547cfe7f00db",
|
||||
"sha256:a58d78653ae422df6837dd4ca0036610b8cb4962b5cfdbd337b7b24de9e5f98a",
|
||||
"sha256:a5edc58d631170de90e50adc2cc0248083541affef82f8cd93bea458e4d96db8",
|
||||
"sha256:a5f623aeaa24f71fce3177d7fee875371345eb9102b355b882243e33e04b7175",
|
||||
"sha256:adaab25be351fff0d8a691c4f09153647804d09a87a4e4ea2c3f9fe9e8651851",
|
||||
"sha256:ade74f5e3a0fd17df5782896ddca7ddb998845a5f7cd4b0be771e1ffc3b9aa5b",
|
||||
"sha256:b1d381f58fcc3e63fcc0ea4f0a38335163883267f77e4c6e22d7a30877218a0e",
|
||||
"sha256:bf6005708fc2e2c89a083f258b97709559a95f9a7a03e59f805dd23c93bc3986",
|
||||
"sha256:d546431636edb1d6a608b348dd58cc9841b81f4116745857b6cb9f8dadb2725f",
|
||||
"sha256:d5618d49de6ba63fe4510bdada62d06a8acfca0b4b5c904956c777d28382b419",
|
||||
"sha256:dfd0d464f3d86a1460683cd742306d1138b4e99b79094f4e07e1ca85ee267fe7",
|
||||
"sha256:e18281a7d80d76b66a9f9e68a98cf7e1d153182772400d9a9ce855264d7d0ce7",
|
||||
"sha256:e410cf3a2272d0a85526d700782a2fa92c1e304fdcc519ba74ac80b8297adf36",
|
||||
"sha256:e662c6266e3a275bdcb6bb049edc7cd77d0b0f7e119a53101d367c841afc66dc",
|
||||
"sha256:ec9027d0beb785a35aa9951d14e06d48cfbf876d8ff67519403a2522b181943b",
|
||||
"sha256:eed394099a7792834f0cb4a8f615319152b9d801444c1c9e1b1a2c36d2239f9e",
|
||||
"sha256:f76dbe44e31abf516114f6347a46fa4e7c2e8bceaa4b6f7ee3a0a03c8eba3c17",
|
||||
"sha256:fc15874816b9320581133ddc2096b644582ab870cf6a6ed63684433e7af4b0d3",
|
||||
"sha256:fc9fb11b65e7bc49f7f75aaba1b700f7181d95d4e151cf2f24d51bfd14410b77"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.6.3"
|
||||
"version": "==4.7.1"
|
||||
},
|
||||
"python-dateutil": {
|
||||
"hashes": [
|
||||
@@ -124,19 +136,19 @@
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8",
|
||||
"sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a"
|
||||
"sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3",
|
||||
"sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.19.1"
|
||||
"version": "==0.19.2"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
|
||||
"sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
|
||||
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
|
||||
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.26.0"
|
||||
"version": "==2.27.1"
|
||||
},
|
||||
"requests-mock": {
|
||||
"hashes": [
|
||||
@@ -156,23 +168,23 @@
|
||||
},
|
||||
"soupsieve": {
|
||||
"hashes": [
|
||||
"sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc",
|
||||
"sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"
|
||||
"sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb",
|
||||
"sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.2.1"
|
||||
"version": "==2.3.1"
|
||||
},
|
||||
"tconnectsync": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
"path": ".",
|
||||
"version": "==0.6.4"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
|
||||
"sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
|
||||
"sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed",
|
||||
"sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"
|
||||
],
|
||||
"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.8"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@@ -185,27 +197,27 @@
|
||||
},
|
||||
"jedi": {
|
||||
"hashes": [
|
||||
"sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93",
|
||||
"sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"
|
||||
"sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d",
|
||||
"sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.18.0"
|
||||
"version": "==0.18.1"
|
||||
},
|
||||
"parso": {
|
||||
"hashes": [
|
||||
"sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398",
|
||||
"sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"
|
||||
"sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0",
|
||||
"sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.8.2"
|
||||
"version": "==0.8.3"
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c",
|
||||
"sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c"
|
||||
"sha256:1bb05628c7d87b645974a1bad3f17612be0c29fa39af9f7688030163f680bad6",
|
||||
"sha256:e56f2ff799bacecd3e88165b1e2f5ebf9bcd59e80e06d395fa0cc4b8bd7bb506"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.2'",
|
||||
"version": "==3.0.20"
|
||||
"version": "==3.0.24"
|
||||
},
|
||||
"ptpython": {
|
||||
"hashes": [
|
||||
@@ -217,11 +229,11 @@
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380",
|
||||
"sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"
|
||||
"sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65",
|
||||
"sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==2.10.0"
|
||||
"version": "==2.11.2"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
Tconnectsync synchronizes data one-way from the Tandem Diabetes t:connect web/mobile application 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!
|
||||
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!
|
||||
|
||||
If you have an Android phone, you can use [tconnectpatcher](https://github.com/jwoglom/tconnectpatcher) to modify the t:connect Android app to upload more frequently. By default, pump data is uploaded to Tandem's servers every hour, but with tconnectpatcher the frequency can be brought down to **as low as every five minutes**! This allows for nearly real-time (albeit not fully instantaneous) pump data updates, almost like your pump uploads data directly to Nightscout!
|
||||
|
||||
## How It Works
|
||||
|
||||
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.
|
||||
|
||||
@@ -16,20 +21,29 @@ When you run the program with no arguments, it performs a single cycle of the fo
|
||||
* 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.
|
||||
|
||||
If run with the `--auto-update` flag, then the application performs the following steps:
|
||||
|
||||
* 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.
|
||||
|
||||
The following synchronization features are enabled by default:
|
||||
## What Gets Synced
|
||||
|
||||
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 with to _enable the `PUMP_EVENTS` 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:
|
||||
The following synchronization features can be optionally enabled:
|
||||
|
||||
* `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)
|
||||
@@ -37,18 +51,40 @@ The following synchronization feature is disabled by default, but can be enabled
|
||||
* 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)
|
||||
* `IOB`: Insulin-on-board data. Only the most recent IOB entry is saved to Nightscout, as an "activity". The Nightscout UI does not currently display this information. In order to read this value, you need to query the Nightscout activity API endpoint. If you don't know what that means, then there is no reason to enable this option.
|
||||
|
||||
The following synchronization features are under development, [**but are not yet ready for use**](https://github.com/jwoglom/tconnectsync/issues/16):
|
||||
* `BOLUS_BG`: Adds BG readings which are associated with boluses on the pump into the Nightscout treatment object. It will determine whether the BG reading was automatically filled via the Dexcom connection on the pump or was manually entered by seeing if the BG reading matches the current CGM reading as known to the pump at that time. Support for this is nearly complete.
|
||||
* `CGM`: Adds Dexcom CGM readings from the pump to Nightscout as SGV (sensor glucose value) entries. This should only be used in a situation where xDrip/Dexcom Share/etc. is not used and the pump connection to the CGM will be the only source of CGM data to Nightscout. This requires additional testing before it should be considered ready.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
@@ -59,29 +95,40 @@ TCONNECT_PASSWORD='password'
|
||||
# Your pump's serial number (numeric)
|
||||
PUMP_SERIAL_NUMBER=11111111
|
||||
|
||||
# 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'
|
||||
```
|
||||
|
||||
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`
|
||||
* **On Windows:** Install Ubuntu under the [Windows Subsystem for Linux](https://ubuntu.com/wsl).
|
||||
Open the Ubuntu Terminal, then run `sudo apt install python3 python3-pip`.
|
||||
Perform the remainder of the steps under the Ubuntu environment.
|
||||
|
||||
Now install the `tconnectsync` package with pip:
|
||||
|
||||
```
|
||||
$ pip3 install 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:
|
||||
```
|
||||
$ tconnectsync --help
|
||||
@@ -105,7 +152,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)
|
||||
|
||||
```
|
||||
$ tconnectsync --check-login
|
||||
```
|
||||
@@ -154,7 +206,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 +225,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 +247,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = tconnectsync
|
||||
version = 0.6
|
||||
version = 0.6.6
|
||||
author = James Woglom
|
||||
author_email = j@wogloms.net
|
||||
description = Syncs Tandem t:connect pump data to Nightscout for the t:slim X2
|
||||
@@ -34,4 +34,4 @@ exclude =
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
tconnectsync = tconnectsync:main
|
||||
tconnectsync = tconnectsync:main
|
||||
|
||||
@@ -7,7 +7,7 @@ import pkg_resources
|
||||
|
||||
from .api import TConnectApi
|
||||
from .process import process_time_range
|
||||
from .autoupdate import process_auto_update
|
||||
from .autoupdate import Autoupdate
|
||||
from .check import check_login
|
||||
from .nightscout import NightscoutApi
|
||||
from .features import DEFAULT_FEATURES, ALL_FEATURES
|
||||
@@ -19,6 +19,7 @@ try:
|
||||
NS_URL,
|
||||
NS_SECRET
|
||||
)
|
||||
from . import secret
|
||||
except Exception:
|
||||
print('Unable to read secret.py')
|
||||
sys.exit(1)
|
||||
@@ -30,7 +31,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')
|
||||
@@ -82,7 +83,8 @@ def main(*args, **kwargs):
|
||||
|
||||
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 = Autoupdate(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)
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from .android import AndroidApi
|
||||
from .controliq import ControlIQApi
|
||||
from .ws2 import WS2Api
|
||||
from .webui import WebUIScraper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,13 +12,13 @@ class TConnectApi:
|
||||
email = None
|
||||
password = None
|
||||
|
||||
_ciq = None
|
||||
_ws2 = None
|
||||
_android = None
|
||||
|
||||
def __init__(self, email, password):
|
||||
self.email = email
|
||||
self.password = password
|
||||
self._ciq = None
|
||||
self._ws2 = None
|
||||
self._android = None
|
||||
self._webui = None
|
||||
|
||||
|
||||
@property
|
||||
@@ -53,4 +54,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
|
||||
|
||||
|
||||
+14
-15
@@ -29,6 +29,8 @@ class AndroidApi:
|
||||
ANDROID_API_USERNAME = base64.b64decode('QzIzMzFDRDYtRDQ1MC00OTVFLTlDMTktNjcyMTUyMzBDODVD').decode()
|
||||
ANDROID_API_PASSWORD = base64.b64decode('dHo0MzNLVzVRREM5VjdmIXo2QF4ybyZZNlNHR1lo').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
|
||||
@@ -51,7 +53,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)
|
||||
)
|
||||
|
||||
@@ -82,7 +87,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 = requests.get(self.BASE_URL + endpoint, 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))
|
||||
@@ -134,6 +143,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 +166,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))
|
||||
|
||||
@@ -13,7 +13,7 @@ from .common import parse_date, base_headers, 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'
|
||||
|
||||
userGuid = None
|
||||
@@ -45,6 +45,7 @@ class ControlIQApi:
|
||||
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)))
|
||||
self.loginSession = s
|
||||
return True
|
||||
|
||||
def _build_login_data(self, email, password, soup):
|
||||
@@ -106,7 +107,7 @@ class ControlIQApi:
|
||||
startDate = parse_date(start)
|
||||
endDate = parse_date(end)
|
||||
|
||||
return self.get('therapytimeline/users/%s' % (self.userGuid), {
|
||||
return self.get('tconnect/controliq/api/therapytimeline/users/%s' % (self.userGuid), {
|
||||
"startDate": startDate,
|
||||
"endDate": endDate
|
||||
})
|
||||
@@ -122,7 +123,7 @@ class ControlIQApi:
|
||||
startDate = parse_date(start)
|
||||
endDate = parse_date(end)
|
||||
|
||||
return self.get('summary/users/%s' % (self.userGuid), {
|
||||
return self.get('tconnect/controliq/api/summary/users/%s' % (self.userGuid), {
|
||||
"startDate": startDate,
|
||||
"endDate": endDate
|
||||
})
|
||||
@@ -132,4 +133,17 @@ class ControlIQApi:
|
||||
[{"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), {})
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import requests
|
||||
import urllib
|
||||
import datetime
|
||||
import arrow
|
||||
import time
|
||||
import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
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())
|
||||
|
||||
if r.status_code != 200:
|
||||
raise ApiException(r.status_code, "WebUIScraper HTTP %s response: %s" % (str(r.status_code), r.text))
|
||||
return r
|
||||
|
||||
|
||||
def get(self, endpoint, tries=0):
|
||||
try:
|
||||
return self._get(endpoint)
|
||||
except ApiException as e:
|
||||
logger.warning("Received ApiException in WebUIScraper with endpoint '%s' (tries %d): %s" % (endpoint, tries, e))
|
||||
if tries > 0:
|
||||
raise ApiException(e.status_code, "WebUIScraper HTTP %d on retry #%d: %s", e.status_code, tries, e)
|
||||
|
||||
# Trigger automatic re-login, and try again once
|
||||
if e.status_code == 401:
|
||||
logger.info("Performing automatic re-login to ControlIQApi after HTTP 401 for ControlIQApi")
|
||||
self.controliq.accessTokenExpiresAt = time.time()
|
||||
self.controliq.login(self.controliq._email, self.controliq._password)
|
||||
|
||||
return self.get(endpoint, tries=tries+1)
|
||||
|
||||
if e.status_code == 500:
|
||||
return self.get(endpoint, tries=tries+1)
|
||||
|
||||
raise e
|
||||
|
||||
def strip(self, txt):
|
||||
# Remove errant whitespace between litearl newlines (and literal )
|
||||
sep = '\r\n'
|
||||
if sep not in txt and '\n' in txt:
|
||||
sep = '\n'
|
||||
return ' '.join([i.replace('\xa0',' ').strip() for i in txt.strip().split(sep)])
|
||||
|
||||
|
||||
"""
|
||||
Returns a mapping between pump/device IDs and information about that device,
|
||||
including the GUID used for obtaining pump settings.
|
||||
"""
|
||||
def my_devices(self):
|
||||
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] = {
|
||||
'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):
|
||||
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))
|
||||
|
||||
return profiles, settings
|
||||
|
||||
def _parse_profile_tbl(self, tbl):
|
||||
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"] = []
|
||||
|
||||
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": self.strip(tds[1].text),
|
||||
"correction_factor": self.strip(tds[2].text),
|
||||
"carb_ratio": self.strip(tds[3].text),
|
||||
"target_bg": self.strip(tds[4].text)
|
||||
}
|
||||
profile["segments"].append(segment)
|
||||
continue
|
||||
|
||||
if tr.find(text='Calculated Total Daily Basal'):
|
||||
profile["calculated_total_daily_basal"] = self.strip(tds[1].text)
|
||||
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"] = val
|
||||
elif key == 'Carbohydrates':
|
||||
profile["carbohydrates"] = val
|
||||
|
||||
|
||||
return 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
|
||||
|
||||
"""
|
||||
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):
|
||||
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))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+178
-67
@@ -4,92 +4,203 @@ 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
|
||||
)
|
||||
from . import secret
|
||||
|
||||
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.
|
||||
class Autoupdate:
|
||||
"""Wrap access to secrets for easier testing."""
|
||||
def __init__(self, secret):
|
||||
self.secret = secret
|
||||
self.autoupdate_invocations = 0
|
||||
self.last_event_index = None
|
||||
self.last_event_time = None
|
||||
self.last_successful_process_time_range = None
|
||||
self.time_diffs_between_updates = []
|
||||
self.last_attempt_time = None
|
||||
self.time_diffs_between_attempts = []
|
||||
|
||||
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:
|
||||
"""
|
||||
Performs the auto-update functionality. Runs indefinitely in a loop
|
||||
until stopped (ctrl+c), or a maximum of AUTOUPDATE_MAX_LOOP_INVOCATIONS times.
|
||||
Stops if AUTOUPDATE_RESTART_ON_FAILURE is set and an error occurs.
|
||||
"""
|
||||
def process(self, tconnect, nightscout, time_start, time_end, pretend, features=None):
|
||||
if features is None:
|
||||
features = DEFAULT_FEATURES
|
||||
|
||||
# Read from android api, find exact interval to cut down on API calls
|
||||
# Refresh API token. If failure, die, have wrapper script re-run.
|
||||
|
||||
self.autoupdate_start = time.time()
|
||||
|
||||
while True:
|
||||
logger.debug("autoupdate loop")
|
||||
now = time.time()
|
||||
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], last_event_index))
|
||||
last_event = tconnect.android.last_event_uploaded(self.secret.PUMP_SERIAL_NUMBER)
|
||||
if not self.last_event_index or last_event['maxPumpEventIndex'] > self.last_event_index:
|
||||
logger.info('New reported t:connect data. (event index: %s last: %s)' % (last_event['maxPumpEventIndex'], self.last_event_index))
|
||||
|
||||
if pretend:
|
||||
logger.info('Would update now if not in pretend mode')
|
||||
else:
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
|
||||
logger.info('Added %d items from process_time_range' % added)
|
||||
if added == 0:
|
||||
if 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.')
|
||||
if pretend:
|
||||
logger.info('Would update now if not in pretend mode')
|
||||
else:
|
||||
last_process_time_range = now
|
||||
added = process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=features)
|
||||
logger.info('Added %d items from process_time_range' % added)
|
||||
if added == 0:
|
||||
# If we've been unable to find new events, but the last_event_index is increasing,
|
||||
# suggesting there are more events being added, we might be in a bugged
|
||||
# situation where we can't get any more data without restarting.
|
||||
# We skip this check on the first process cycle, since we might
|
||||
# just already be in sync with tconnect's pump data.
|
||||
if self.last_event_index:
|
||||
|
||||
# Find the timestamp of the last time we've successfully obtained data,
|
||||
# or the time when the autoupdate run started, if we haven't at all.
|
||||
last_action_or_start = self.last_successful_process_time_range
|
||||
if not last_action_or_start:
|
||||
last_action_or_start = self.autoupdate_start
|
||||
|
||||
# If it's been AUTOUPDATE_FAILURE_MINUTES in the state of not seeing
|
||||
# event index changes reflected in the tconnect data we're pulling,
|
||||
# raise an error and potentially restart.
|
||||
# This is likely a tconnectsync problem, not a problem with the pump or app
|
||||
# (we can see the indexes increasing, so we know something's happening!)
|
||||
if (now - last_action_or_start) >= 60 * self.secret.AUTOUPDATE_FAILURE_MINUTES:
|
||||
logger.error(AutoupdateFailureError(
|
||||
"An event index change was recorded, but no new data was found via the API. " +
|
||||
"The %s was %d minutes ago. This is a problem with tconnectsync." %
|
||||
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
|
||||
|
||||
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
|
||||
return 1
|
||||
else:
|
||||
logger.warn(AutoupdateFailureWarning("An event index change was recorded, but no new data was found via the API. " +
|
||||
"The %s was %d minutes ago. Resetting TConnectApi to attempt to solve this problem." %
|
||||
("last processed event" if self.last_successful_process_time_range else "start of autoupdate", (now - last_action_or_start)//60)))
|
||||
|
||||
# As a stop-gap, try to re-initialize TConnectApi (triggering a re-login)
|
||||
# Use __class__ instead of direct TConnectApi invocation to avoid initializing a real TConnectApi over a fake
|
||||
tconnect = tconnect.__class__(self.secret.TCONNECT_EMAIL, self.secret.TCONNECT_PASSWORD)
|
||||
else:
|
||||
# Mark the last successful time we got data from tconnect
|
||||
self.last_successful_process_time_range = now
|
||||
|
||||
|
||||
if last_event_index:
|
||||
time_diffs.append(now - last_event_time)
|
||||
logger.debug('Updating tracking of time since last update: %s' % time_diffs)
|
||||
# Track the time it took to find a new event between runs,
|
||||
# but skip this calculation the first process cycle (since
|
||||
# we don't know at what exact point the event index changed)
|
||||
if self.last_event_index:
|
||||
self.time_diffs_between_updates.append(now - self.last_event_time)
|
||||
logger.debug('Updating tracking of time since last update: %s' % self.time_diffs_between_updates)
|
||||
|
||||
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()
|
||||
# Mark the last event index uploaded from the pump and timestamp
|
||||
self.last_event_index = last_event['maxPumpEventIndex']
|
||||
self.last_event_time = now
|
||||
self.last_attempt_time = now
|
||||
self.time_diffs_between_attempts = []
|
||||
else:
|
||||
logger.info('No new reported t:connect data. (last event index: %s)' % last_event['maxPumpEventIndex'])
|
||||
|
||||
if 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 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(
|
||||
"No new data event indexes have been detected for %d minutes. " % ((now - self.last_event_time)//60) +
|
||||
"The t:connect app might no longer be functioning."))
|
||||
|
||||
if AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
sys.exit(1)
|
||||
# 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
|
||||
|
||||
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."))
|
||||
# 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(
|
||||
"No new data has been detected via the API for %d minutes. " % (now - self.last_successful_process_time_range)//60 +
|
||||
"tconnectsync might not be functioning properly."))
|
||||
|
||||
if AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
sys.exit(1)
|
||||
if self.secret.AUTOUPDATE_RESTART_ON_FAILURE:
|
||||
logger.error("Exiting with error code due to AUTOUPDATE_RESTART_ON_FAILURE")
|
||||
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.warn(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
|
||||
|
||||
|
||||
if len(time_diffs) > 2:
|
||||
logger.info('Sleeping 60 seconds after unexpected no index change. (New data might be delayed.)')
|
||||
time.sleep(60)
|
||||
continue
|
||||
class AutoupdateError(RuntimeError):
|
||||
def __str__(self):
|
||||
return "%s: %s" % (self.__class__.__name__, super().__str__())
|
||||
|
||||
sleep_secs = AUTOUPDATE_DEFAULT_SLEEP_SECONDS
|
||||
if AUTOUPDATE_USE_FIXED_SLEEP != 1:
|
||||
if len(time_diffs) > 10:
|
||||
time_diffs = time_diffs[1:]
|
||||
class AutoupdateWarning(RuntimeWarning):
|
||||
def __str__(self):
|
||||
return "%s: %s" % (self.__class__.__name__, super().__str__())
|
||||
class AutoupdateFailureError(AutoupdateError):
|
||||
pass
|
||||
|
||||
if len(time_diffs) > 2:
|
||||
sleep_secs = sum(time_diffs) / len(time_diffs)
|
||||
class AutoupdateFailureWarning(AutoupdateWarning):
|
||||
pass
|
||||
|
||||
if sleep_secs > AUTOUPDATE_MAX_SLEEP_SECONDS:
|
||||
sleep_secs = AUTOUPDATE_MAX_SLEEP_SECONDS
|
||||
class AutoupdateNoEventIndexesDetectedError(AutoupdateError):
|
||||
pass
|
||||
|
||||
# Sleep for a rolling average of time between updates
|
||||
logger.info('Sleeping for %d sec' % sleep_secs)
|
||||
time.sleep(sleep_secs)
|
||||
class AutoupdateNoNewDataDetectedError(AutoupdateError):
|
||||
pass
|
||||
|
||||
class AutoupdateFailureException(RuntimeError):
|
||||
class AutoupdateNoIndexChangeWarning(AutoupdateWarning):
|
||||
pass
|
||||
+185
-24
@@ -1,58 +1,219 @@
|
||||
import sys
|
||||
import time
|
||||
import arrow
|
||||
import logging
|
||||
import pkg_resources
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
|
||||
from .nightscout import NightscoutApi
|
||||
from .parser.nightscout import BASAL_EVENTTYPE, BOLUS_EVENTTYPE
|
||||
from .parser.tconnect import TConnectEntry
|
||||
from .sync.basal import process_ciq_basal_events
|
||||
|
||||
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=False):
|
||||
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 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:
|
||||
from .secret import TCONNECT_EMAIL, TCONNECT_PASSWORD, PUMP_SERIAL_NUMBER, NS_URL, NS_SECRET, TIMEZONE_NAME
|
||||
except ImportError as e:
|
||||
log("Error: Unable to load config file. Please check your .env file or environment variables")
|
||||
log(e)
|
||||
|
||||
if not TCONNECT_EMAIL or TCONNECT_EMAIL == 'email@email.com':
|
||||
log("Error: You have not specified a TCONNECT_EMAIL")
|
||||
errors += 1
|
||||
|
||||
if not TCONNECT_PASSWORD or TCONNECT_PASSWORD == 'password':
|
||||
log("Error: You have not specified a TCONNECT_PASSWORD")
|
||||
errors += 1
|
||||
|
||||
if not PUMP_SERIAL_NUMBER or PUMP_SERIAL_NUMBER == '11111111':
|
||||
log("Error: You have not specified a PUMP_SERIAL_NUMBER")
|
||||
errors += 1
|
||||
|
||||
if not NS_URL or NS_URL == 'https://yournightscouturl/':
|
||||
log("Error: You have not specified a NS_URL")
|
||||
errors += 1
|
||||
|
||||
if not NS_SECRET or NS_SECRET == 'apisecret':
|
||||
log("Error: You have not specified a NS_SECRET")
|
||||
errors += 1
|
||||
|
||||
log("TIMEZONE_NAME: %s" % TIMEZONE_NAME)
|
||||
|
||||
log("-----")
|
||||
|
||||
log("Logging in to t:connect ControlIQ API...")
|
||||
try:
|
||||
summary = tconnect.controliq.dashboard_summary(time_start, time_end)
|
||||
print("ControlIQ dashboard summary: %s" % summary)
|
||||
debug("ControlIQ dashboard summary: \n%s" % pformat(summary))
|
||||
except Exception as e:
|
||||
print("Error occurred querying ControlIQ API: %s" % e)
|
||||
log("Error occurred querying ControlIQ API for dashboard_summary:")
|
||||
log(e)
|
||||
errors += 1
|
||||
|
||||
log("Querying ControlIQ therapy_timeline...")
|
||||
lastBasalTime = None
|
||||
lastBasalDuration = None
|
||||
try:
|
||||
tt = tconnect.controliq.therapy_timeline(time_start, time_end)
|
||||
debug("ControlIQ therapy_timeline: \n%s" % pformat(tt))
|
||||
if tt:
|
||||
processed_tt = process_ciq_basal_events(tt)
|
||||
debug("ControlIQ processed therapy_timeline: \n%s" % pformat(processed_tt))
|
||||
if processed_tt:
|
||||
log("Last ControlIQ processed therapy_timeline event: \n%s" % pformat(processed_tt[-1]))
|
||||
lastBasalTime = processed_tt[-1]['time']
|
||||
lastBasalDuration = processed_tt[-1]['duration_mins']
|
||||
except Exception as e:
|
||||
log("Error occurred querying ControlIQ therapy_timeline:")
|
||||
log(e)
|
||||
errors += 1
|
||||
|
||||
log("Querying ControlIQ therapy_events...")
|
||||
try:
|
||||
androidevents = tconnect.controliq.therapy_events(time_start, time_end)
|
||||
debug("controliq therapy_events: \n%s" % pformat(androidevents))
|
||||
except Exception as e:
|
||||
log("Error occurred querying ControlIQ therapy_events:")
|
||||
log(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
|
||||
print("\nLogging in to t:connect WS2 API...")
|
||||
log("Logging in to t:connect WS2 API...")
|
||||
try:
|
||||
summary = tconnect.ws2.basaliqtech(time_start, time_end)
|
||||
print("WS2 basaliq status: %s" % summary)
|
||||
debug("WS2 basaliq status: \n%s" % pformat(summary))
|
||||
except Exception as e:
|
||||
print("Error occurred querying WS2 API: %s" % e)
|
||||
log("Error occurred querying WS2 API:")
|
||||
log(e)
|
||||
errors += 1
|
||||
|
||||
log("Querying WS2 therapy_timeline_csv...")
|
||||
lastReadingTime = None
|
||||
try:
|
||||
ttcsv = tconnect.ws2.therapy_timeline_csv(time_start, time_end)
|
||||
debug("therapy_timeline_csv: \n%s" % pformat(ttcsv))
|
||||
if ttcsv and "readingData" in ttcsv and len(ttcsv["readingData"]) > 0:
|
||||
log("Last therapy_timeline_csv reading: \n%s" % pformat(ttcsv["readingData"][-1]))
|
||||
lastReadingTime = TConnectEntry._datetime_parse(ttcsv["readingData"][-1]['EventDateTime'])
|
||||
except Exception as e:
|
||||
log("Error occurred querying WS2 therapy_timeline_csv:")
|
||||
log(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
|
||||
print("\nLogging in to t:connect Android API...")
|
||||
log("Logging in to t:connect Android API...")
|
||||
summary = None
|
||||
try:
|
||||
summary = tconnect.android.user_profile()
|
||||
print("Android user profile: %s" % summary)
|
||||
|
||||
from .secret import PUMP_SERIAL_NUMBER
|
||||
debug("Android user profile: \n%s" % pformat(summary))
|
||||
|
||||
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.")
|
||||
debug("Android last uploaded event: \n%s" % pformat(event))
|
||||
except Exception as e:
|
||||
print("Error occurred querying Android API: %s" % e)
|
||||
log("Error occurred querying Android API:")
|
||||
log(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
|
||||
print("\nLogging in to Nightscout...")
|
||||
log("Logging in to Nightscout...")
|
||||
try:
|
||||
from .secret import NS_URL, NS_SECRET
|
||||
nightscout = NightscoutApi(NS_URL, NS_SECRET)
|
||||
status = nightscout.api_status()
|
||||
debug("Nightscout status: \n%s" % pformat(status))
|
||||
|
||||
status = NightscoutApi(NS_URL, NS_SECRET).api_status()
|
||||
print("\nNightscout status: %s" % status)
|
||||
except ImportError:
|
||||
print("Error: Unable to load config file.")
|
||||
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:
|
||||
print("Error occurred querying Nightscout API: %s" % e)
|
||||
log("Error occurred querying Nightscout API:")
|
||||
log(e)
|
||||
errors += 1
|
||||
|
||||
log("-----")
|
||||
|
||||
def time_ago(t):
|
||||
return '%s ago' % (arrow.now() - arrow.get(t)) if t else 'n/a'
|
||||
|
||||
log("Last basal start time: %s (%s)" % (lastBasalTime, time_ago(lastBasalTime)))
|
||||
log("Last basal duration: %s" % lastBasalDuration)
|
||||
log("Last reading time: %s (%s)" % (lastReadingTime, time_ago(lastReadingTime)))
|
||||
|
||||
log("-----")
|
||||
|
||||
if errors == 0:
|
||||
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 summary:
|
||||
sanitizedData.update({
|
||||
'ANDROID_PROFILE_USERID': summary.get('userID'),
|
||||
'ANDROID_PROFILE_PATIENT_FULLNAME': summary.get('patientFullName'),
|
||||
'ANDROID_PROFILE_CAREGIVER_FULLNAME': summary.get('caregiverFullName')
|
||||
})
|
||||
|
||||
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
|
||||
@@ -10,8 +10,7 @@ PUMP_EVENTS = "PUMP_EVENTS"
|
||||
|
||||
DEFAULT_FEATURES = [
|
||||
BASAL,
|
||||
BOLUS,
|
||||
IOB
|
||||
BOLUS
|
||||
]
|
||||
|
||||
ALL_FEATURES = [
|
||||
|
||||
@@ -117,8 +117,9 @@ 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
|
||||
@@ -156,7 +157,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 = {
|
||||
|
||||
+29
-8
@@ -1,15 +1,25 @@
|
||||
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)
|
||||
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_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)
|
||||
@@ -26,15 +36,26 @@ PUMP_SERIAL_NUMBER = 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')
|
||||
|
||||
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
|
||||
|
||||
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_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'false')
|
||||
AUTOUPDATE_NO_DATA_FAILURE_MINUTES = get_number('AUTOUPDATE_NO_DATA_FAILURE_MINUTES', '180') # 3 hours
|
||||
AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15 minutes
|
||||
AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true')
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS = get_number('AUTOUPDATE_MAX_LOOP_INVOCATIONS', '-1')
|
||||
|
||||
ENABLE_TESTING_MODES = get_bool('ENABLE_TESTING_MODES', 'false')
|
||||
SKIP_NS_LAST_UPLOADED_CHECK = get_bool('SKIP_NS_LAST_UPLOADED_CHECK', 'false')
|
||||
|
||||
@@ -69,7 +69,7 @@ def process_ciq_basal_events(data):
|
||||
seconds = (existingTime - unprocessedTime).seconds
|
||||
|
||||
newEvent = TConnectEntry.manual_suspension_to_basal_entry(suspension, seconds)
|
||||
logger.debug("Creating basal event for unprocessed suspension: %s" % newEvent)
|
||||
logger.debug("Adding basal event for unprocessed suspension: %s" % newEvent)
|
||||
newEvents.append(newEvent)
|
||||
|
||||
# Any remaining suspensions which have not been processed have not ended,
|
||||
|
||||
+9
-2
@@ -38,9 +38,16 @@ 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
|
||||
|
||||
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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -594,6 +594,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({
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
import unittest
|
||||
import datetime
|
||||
import importlib
|
||||
import contextlib
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tconnectsync.autoupdate import Autoupdate, AutoupdateFailureError, AutoupdateFailureWarning, AutoupdateNoEventIndexesDetectedError, AutoupdateNoIndexChangeWarning
|
||||
from tconnectsync.secret import AUTOUPDATE_FAILURE_MINUTES, AUTOUPDATE_NO_DATA_FAILURE_MINUTES, AUTOUPDATE_RESTART_ON_FAILURE, TCONNECT_PASSWORD
|
||||
|
||||
from .api.fake import TConnectApi
|
||||
from .nightscout_fake import NightscoutApi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def stub(*args, **kwargs):
|
||||
pass
|
||||
|
||||
@contextlib.contextmanager
|
||||
def build_mock_logger():
|
||||
def fake_error(*args, **kwargs):
|
||||
logger.error(*args, **kwargs)
|
||||
|
||||
def fake_warn(*args, **kwargs):
|
||||
logger.warn(*args, **kwargs)
|
||||
|
||||
with patch("tconnectsync.autoupdate.logger.error") as mock_error, patch("tconnectsync.autoupdate.logger.warn") as mock_warn:
|
||||
mock_error.side_effect = fake_error
|
||||
mock_warn.side_effect = fake_warn
|
||||
yield (mock_error, mock_warn)
|
||||
|
||||
def num_instances_of(cls, m):
|
||||
return sum([isinstance(i[0][0], cls) for i in m.call_args_list])
|
||||
|
||||
class TestAutoupdate(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
# datetimes are unused
|
||||
start = datetime.datetime(2021, 4, 20, 12, 0)
|
||||
end = datetime.datetime(2021, 4, 21, 12, 0)
|
||||
|
||||
# each time the returned function is called, it returns the next argument
|
||||
# until the end is reached, at which point it will repeat the last argument
|
||||
def fake_last_event_uploaded(self, *indexes):
|
||||
index = 0
|
||||
def fake(*args, **kwargs):
|
||||
nonlocal index
|
||||
if index < len(indexes):
|
||||
index += 1
|
||||
data = {'maxPumpEventIndex': indexes[index-1], 'processingStatus': 1}
|
||||
return data
|
||||
|
||||
return fake
|
||||
|
||||
# each time the returned function is called, it returns the next argument
|
||||
# until the end is reached, at which point it will repeat the last argument
|
||||
def fake_process_time_range(self, *returns):
|
||||
index = 0
|
||||
def fake(*args, **kwargs):
|
||||
nonlocal index
|
||||
if index < len(returns):
|
||||
index += 1
|
||||
return returns[index - 1]
|
||||
|
||||
return fake
|
||||
|
||||
def build_secrets(self, **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
|
||||
|
||||
"""process_time_range should always be invoked the first time"""
|
||||
def test_process_time_range_called_on_start(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
|
||||
mock_process_time_range.return_value = 0
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
|
||||
|
||||
"""process_time_range should never be called with pretend"""
|
||||
def test_process_time_range_never_called_with_pretend(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
|
||||
mock_process_time_range.return_value = 0
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=True)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 0)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
|
||||
|
||||
"""
|
||||
If the event index increases without process_time_range detecting new data,
|
||||
AutoupdateFailureWarning should be raised.
|
||||
"""
|
||||
def test_autoupdate_failure_warning_on_index_process_time_range_discrepancy(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=2,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
|
||||
TCONNECT_EMAIL="test@email.com",
|
||||
TCONNECT_PASSWORD="testpassword"
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 2)
|
||||
self.assertEqual(u.autoupdate_invocations, 2)
|
||||
self.assertEqual(u.last_event_index, 2)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 1)
|
||||
|
||||
"""
|
||||
On the first attempt, an AutoupdateFailureWarning should never be raised.
|
||||
"""
|
||||
def test_autoupdate_no_failure_warning_on_index_process_time_range_discrepancy_first_attempt(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureWarning, mock_warn), 0)
|
||||
|
||||
|
||||
"""
|
||||
If the event index increases without process_time_range detecting new data
|
||||
for AUTOUPDATE_FAILURE_MINUTES, an AutoupdateFailureError should be raised.
|
||||
"""
|
||||
def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=2,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
|
||||
AUTOUPDATE_FAILURE_MINUTES=0,
|
||||
AUTOUPDATE_RESTART_ON_FAILURE=False,
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 2)
|
||||
self.assertEqual(u.autoupdate_invocations, 2)
|
||||
self.assertEqual(u.last_event_index, 2)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1)
|
||||
|
||||
"""
|
||||
If the event index increases without process_time_range detecting new data
|
||||
for AUTOUPDATE_FAILURE_MINUTES, and AUTOUPDATE_RESTART_ON_FAILURE is true,
|
||||
then an AutoupdateFailureError should be raised AND 1 should be returned.
|
||||
"""
|
||||
def test_autoupdate_failure_error_on_index_process_time_range_discrepancy_for_failure_minutes_performs_restart(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1, 2)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=3,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0,
|
||||
AUTOUPDATE_FAILURE_MINUTES=0,
|
||||
AUTOUPDATE_RESTART_ON_FAILURE=True,
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 1)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 2)
|
||||
self.assertEqual(u.autoupdate_invocations, 1) # exits before invocations is incremented
|
||||
self.assertEqual(u.last_event_index, 1) # exits before changed to 2
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateFailureError, mock_error), 1)
|
||||
|
||||
|
||||
"""Validate state after first successful update"""
|
||||
def test_state_after_first_successful_update(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range:
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(1)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
self.assertTrue(u.last_event_time == u.last_attempt_time == u.last_successful_process_time_range)
|
||||
self.assertEqual(len(u.time_diffs_between_updates), 0)
|
||||
|
||||
"""Validate sleep occurs for the given fixed length when set"""
|
||||
def test_sleep_for_fixed_length_when_set(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
sleep_length = 3 # sentinel
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=1,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=sleep_length
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep:
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(1)
|
||||
mock_sleep.side_effect = None
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(mock_process_time_range.call_count, 1)
|
||||
self.assertEqual(u.autoupdate_invocations, 1)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
self.assertTrue(mock_sleep.called)
|
||||
self.assertEqual(mock_sleep.call_args[0], (sleep_length,))
|
||||
|
||||
|
||||
"""
|
||||
If there is no event index update for AUTOUPDATE_NO_DATA_FAILURE_MINUTES,
|
||||
then a AutoupdateNoEventIndexesDetectedError should be raised and
|
||||
a restart should be triggered with AUTOUPDATE_RESTART_ON_FAILURE.
|
||||
"""
|
||||
def test_autoupdate_no_event_indexes_detected_error_on_no_index_change_less_than_three_iterations(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=100,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1,
|
||||
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2,
|
||||
AUTOUPDATE_RESTART_ON_FAILURE=True,
|
||||
AUTOUPDATE_NO_DATA_FAILURE_MINUTES=1/600 # 0.1 second
|
||||
)
|
||||
|
||||
# with more than 3 iterations, the sleep iteration will change
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(1, 0)
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 1)
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateNoEventIndexesDetectedError, mock_error), 1)
|
||||
|
||||
|
||||
"""
|
||||
If there has been 3 failed attempts since the last time we found new data,
|
||||
we should sleep for AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS and
|
||||
a AutoupdateNoIndexChangeWarning should be logged.
|
||||
"""
|
||||
def test_autoupdate_no_index_change_warning_on_unexpected_no_index_sleep(self):
|
||||
tconnect = TConnectApi()
|
||||
tconnect._android.last_event_uploaded = self.fake_last_event_uploaded(1)
|
||||
|
||||
nightscout = NightscoutApi()
|
||||
|
||||
secret = self.build_secrets(
|
||||
AUTOUPDATE_MAX_LOOP_INVOCATIONS=5,
|
||||
AUTOUPDATE_USE_FIXED_SLEEP=True,
|
||||
AUTOUPDATE_DEFAULT_SLEEP_SECONDS=0.1,
|
||||
AUTOUPDATE_UNEXPECTED_NO_INDEX_SLEEP_SECONDS=0.2
|
||||
)
|
||||
|
||||
with patch("tconnectsync.autoupdate.process_time_range") as mock_process_time_range, patch("tconnectsync.autoupdate.time.sleep") as mock_sleep, build_mock_logger() as (mock_error, mock_warn):
|
||||
mock_process_time_range.side_effect = self.fake_process_time_range(0)
|
||||
mock_sleep.side_effect = None
|
||||
|
||||
u = Autoupdate(secret)
|
||||
ret = u.process(tconnect, nightscout, self.start, self.end, pretend=False)
|
||||
self.assertEqual(ret, 0)
|
||||
|
||||
self.assertEqual(u.autoupdate_invocations, 5)
|
||||
self.assertEqual(u.last_event_index, 1)
|
||||
self.assertTrue(mock_sleep.called)
|
||||
self.assertEqual(len(mock_sleep.call_args_list), 5)
|
||||
self.assertEqual(mock_sleep.call_args_list[0][0], (0.1,))
|
||||
self.assertEqual(mock_sleep.call_args_list[1][0], (0.1,))
|
||||
self.assertEqual(mock_sleep.call_args_list[2][0], (0.1,))
|
||||
self.assertEqual(mock_sleep.call_args_list[3][0], (0.2,))
|
||||
self.assertEqual(mock_sleep.call_args_list[4][0], (0.2,))
|
||||
|
||||
self.assertEqual(num_instances_of(AutoupdateNoIndexChangeWarning, mock_warn), 2)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user