Initial commit.
This commit is contained in:
commit
90913631a6
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/auth.py
|
||||||
|
/spo2.db3
|
12
Pipfile
Normal file
12
Pipfile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[[source]]
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
garminconnect = "*"
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.9"
|
89
Pipfile.lock
generated
Normal file
89
Pipfile.lock
generated
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"hash": {
|
||||||
|
"sha256": "9a50d04f5b70c0f051d0e240e3cc06c311ecb455e788a312b32cbf4f85fd4e79"
|
||||||
|
},
|
||||||
|
"pipfile-spec": 6,
|
||||||
|
"requires": {
|
||||||
|
"python_version": "3.9"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "pypi",
|
||||||
|
"url": "https://pypi.org/simple",
|
||||||
|
"verify_ssl": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"certifi": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
|
||||||
|
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
|
||||||
|
],
|
||||||
|
"version": "==2021.10.8"
|
||||||
|
},
|
||||||
|
"charset-normalizer": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd",
|
||||||
|
"sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3'",
|
||||||
|
"version": "==2.0.10"
|
||||||
|
},
|
||||||
|
"cloudscraper": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:674fd739f9412188aae8d6614e3e6316939fc0670ef5646abd3d316f1a59d3c2",
|
||||||
|
"sha256:dda29028c5628b5ba3e4dc43816ed38fd46bd945ef938c420f185586a6d8dff2"
|
||||||
|
],
|
||||||
|
"version": "==1.2.58"
|
||||||
|
},
|
||||||
|
"garminconnect": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:cddd59eca3009ae6b0c2fd216b8049759680b6f88390cea49afce1ed68ce1bed"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.1.44"
|
||||||
|
},
|
||||||
|
"idna": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||||
|
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3'",
|
||||||
|
"version": "==3.3"
|
||||||
|
},
|
||||||
|
"pyparsing": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea",
|
||||||
|
"sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==3.0.7"
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
|
||||||
|
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||||
|
"version": "==2.27.1"
|
||||||
|
},
|
||||||
|
"requests-toolbelt": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
|
||||||
|
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
|
||||||
|
],
|
||||||
|
"version": "==0.9.1"
|
||||||
|
},
|
||||||
|
"urllib3": {
|
||||||
|
"hashes": [
|
||||||
|
"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.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"develop": {}
|
||||||
|
}
|
30
README.md
Normal file
30
README.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
Garmin SpO2 to Apple Health
|
||||||
|
===========================
|
||||||
|
|
||||||
|
For some reason, Garmin Connect on iOS doesn't sync Blood Oxygen (SpO2) data into Apple Health
|
||||||
|
despite syncing various other metrics perfectly fine already.
|
||||||
|
|
||||||
|
There's a [feature request](https://forums.garmin.com/apps-software/mobile-apps-web/f/garmin-connect-mobile-ios/254977/request-for-spo2-vo2-max-and-respiration-data-to-be-shared-to-apple-health-app)
|
||||||
|
in their forums from February 2021, but apart from "We've added this to our feature request list"
|
||||||
|
nothing came of it in the past 11 months. And I found similar requests as old as [over 4 years (June 2018)](https://forums.garmin.com/outdoor-recreation/outdoor-recreation/f/fenix-5-plus-series/147239/spo2-measurements).
|
||||||
|
|
||||||
|
Until Garmin finds the 5 minutes to implement this feature, I've resorted to use the wonderful
|
||||||
|
[garminconnect](https://github.com/cyberjunky/python-garminconnect) Python-library to download
|
||||||
|
my SpO2 data, a PHP script to serve the data from the last 2 days as JSON and an iOS Shortcut to
|
||||||
|
sync that to Apple Health.
|
||||||
|
|
||||||
|
While it's not the most elegant solution, it works.
|
||||||
|
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
1. Copy `auth.py.example` to `auth.py` and edit it to specify your Garmin Connect credentials
|
||||||
|
2. Copy `spo2_empty.db3` to `spo2.db3`
|
||||||
|
3. Run `pipenv install` to install dependencies
|
||||||
|
4. Make a cronjob running `pipenv run ./fetch.py` regularly
|
||||||
|
5. Copy the script from the `php` folder to a webserver and edit the file to point to the SQLite
|
||||||
|
database file.
|
||||||
|
6. Install the [SpO2 Inserter Shortcut](https://www.icloud.com/shortcuts/7f6f94eb536e4fb1857993bfbc181ccb)
|
||||||
|
on your iPhone and point it to the PHP script.
|
||||||
|
7. Optional: Create an Automation in Shortcuts to run this automatically.
|
6
auth.py.example
Normal file
6
auth.py.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Your Garmin Connect credentials
|
||||||
|
USERNAME = "changeme@changeme.com"
|
||||||
|
PASSWORD = "changeme"
|
||||||
|
|
||||||
|
# Number of days to fetch (default: last 3 days)
|
||||||
|
DAYS_TO_FETCH = 3
|
61
fetch.py
Executable file
61
fetch.py
Executable file
@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import sqlite3
|
||||||
|
from auth import USERNAME, PASSWORD, DAYS_TO_FETCH
|
||||||
|
from pprint import pprint
|
||||||
|
from time import sleep
|
||||||
|
from garminconnect import (
|
||||||
|
Garmin,
|
||||||
|
GarminConnectConnectionError
|
||||||
|
)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
startfrom = today - datetime.timedelta(days=DAYS_TO_FETCH)
|
||||||
|
|
||||||
|
print(f"Logging into Garmin Connect using {USERNAME}.")
|
||||||
|
api = Garmin(USERNAME, PASSWORD)
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
api.login()
|
||||||
|
break
|
||||||
|
except GarminConnectConnectionError as e:
|
||||||
|
tries += 1
|
||||||
|
wait = tries
|
||||||
|
if wait > 2:
|
||||||
|
wait = 2
|
||||||
|
print(f"Error during login! Retry #{tries} in {wait} sec.", flush=True)
|
||||||
|
sleep(wait)
|
||||||
|
|
||||||
|
db = sqlite3.connect("spo2.db3")
|
||||||
|
|
||||||
|
reqday = startfrom
|
||||||
|
while reqday <= today:
|
||||||
|
print("Querying data for: {}".format(reqday.isoformat()))
|
||||||
|
sleep = api.get_sleep_data(reqday.isoformat())
|
||||||
|
#pprint(sleep)
|
||||||
|
|
||||||
|
if not "wellnessEpochSPO2DataDTOList" in sleep:
|
||||||
|
print("No SpO2 data for {}. Skipping day.".format(reqday.isoformat()))
|
||||||
|
reqday += datetime.timedelta(days=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
spo2 = sleep["wellnessEpochSPO2DataDTOList"]
|
||||||
|
#pprint(spo2)
|
||||||
|
|
||||||
|
print("Got {} records.".format(len(spo2)))
|
||||||
|
|
||||||
|
for rec in spo2:
|
||||||
|
ts = datetime.datetime.fromisoformat(rec["epochTimestamp"][:-2]) # strip hundreds of seconds
|
||||||
|
sql = "INSERT OR IGNORE INTO spo2 VALUES (?, ?, ?)"
|
||||||
|
db.execute(sql, [ts.timestamp(), rec["spo2Reading"], rec["readingConfidence"]])
|
||||||
|
|
||||||
|
reqday += datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
api.logout()
|
35
php/gcserve.php
Normal file
35
php/gcserve.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
$DB_FILE = '/home/mbirth/opt/garmin-fetch/spo2.db3'; // path to SQLite database
|
||||||
|
$DAYS_BACK = 1; // Today minus $DAYS_BACK days
|
||||||
|
|
||||||
|
$pdo = new \PDO('sqlite:' . $DB_FILE);
|
||||||
|
|
||||||
|
$now = gmmktime(0, 0, 0); // get today at 00:00:00 according to UTC/GMT
|
||||||
|
$servefrom = $now - $DAYS_BACK * 24 * 60 * 60;
|
||||||
|
#$servefrom = 1643001299;
|
||||||
|
|
||||||
|
$pq = $pdo->prepare('SELECT * FROM spo2 WHERE timestamp_utc >= ?');
|
||||||
|
$pq->execute(array($servefrom));
|
||||||
|
$res = $pq->fetchAll();
|
||||||
|
|
||||||
|
#header('Content-Type: text/plain');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$result = array();
|
||||||
|
foreach ($res as $row) {
|
||||||
|
$dto = new DateTime('@' . $row['timestamp_utc'], new DateTimeZone('UTC'));
|
||||||
|
$result[] = array(
|
||||||
|
'unixtime' => intval($row['timestamp_utc']),
|
||||||
|
#'datestr' => $dto->format('Y.m.d \A\D \a\t H:i:s \U\T\C'),
|
||||||
|
'datestr' => $dto->format('c'),
|
||||||
|
'spo2_value' => intval($row['spo2_percent']),
|
||||||
|
'spo2_confidence' => intval($row['spo2_confidence']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#var_dump($result);
|
||||||
|
echo json_encode($result);
|
BIN
spo2_empty.db3
Normal file
BIN
spo2_empty.db3
Normal file
Binary file not shown.
Reference in New Issue
Block a user