fitbit-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include tests *.py *.txt *.yml
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.1
2
+ Name: fitbit-cli
3
+ Version: 0.1.0
4
+ Summary: Access your Fitbit data at your terminal.
5
+ Home-page: https://github.com/veerendra2/fitbit-cli
6
+ Download-URL: https://github.com/veerendra2/fitbit-cli/archive/0.1.0.tar.gz
7
+ Author: veerendra2
8
+ Author-email: vk.tyk23@simplelogin.com
9
+ License: MIT
10
+ Project-URL: Documentation, https://github.com/veerendra2/fitbit-cli
11
+ Keywords: fitbit,cli,python
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Natural Language :: English
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ Requires-Dist: requests==2.32.3
29
+ Requires-Dist: rich==13.9.4
30
+
31
+ # Fitbit CLI
32
+
33
+ > ⚠️ This is not an official Fitbit CLI
34
+
35
+ Access your Fitbit data directly from your terminal 💻. View 💤 sleep logs, ❤️ heart rate, 🏋️‍♂️ activity levels, 🩸 SpO2, and more, all presented in a simple, easy-to-read table format!
36
+
37
+ <p align="center">
38
+ <img alt="Fitbit logo", width="250" src="./assets/Fitbit_Logo_White_RGB.jpg">
39
+ </p>
40
+
41
+ ## Supported Web APIs
42
+
43
+ > Only `GET` APIs are supported!
44
+
45
+ | API | Status |
46
+ | ----------------------------------------------------------------------------------------------------------------------- | ------ |
47
+ | [User](https://dev.fitbit.com/build/reference/web-api/user/) | ✅ |
48
+ | [Sleep](https://dev.fitbit.com/build/reference/web-api/sleep/) | ✅ |
49
+ | [SpO2](https://dev.fitbit.com/build/reference/web-api/spo2/) | ✅ |
50
+ | [Heart Rate Time Series](https://dev.fitbit.com/build/reference/web-api/heartrate-timeseries/) | ✅ |
51
+ | [Active Zone Minutes (AZM) Time Series](https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/) | ✅ |
52
+ | [Activity](https://dev.fitbit.com/build/reference/web-api/activity/) | 👷 |
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ python -m pip install fitbit-cli
58
+
59
+ fitbit-cli -h
60
+ usage: fitbit-cli [-h] [-d | -i] [-s [DATE[,DATE]]] [-o [DATE[,DATE]]] [-e [DATE[,DATE]]] [-a [DATE[,DATE]]] [-u] [-v]
61
+
62
+ Fitbit CLI -- Access your Fitbit data at your terminal.
63
+
64
+ options:
65
+ -h, --help show this help message and exit
66
+ -d, --json-dump Dump all your Fitbit data in json files.
67
+ -i, --init Run interative setup to fetch token.
68
+ -v, --version Show fitbit-cli version
69
+
70
+ APIs:
71
+ Specify date ranges (ISO 8601 format: YYYY-MM-DD) for the following arguments.
72
+ You can provide a single date or a range (start,end). If not provided, defaults to today's date.
73
+
74
+ -s, --sleep [DATE[,DATE]]
75
+ Sleep data
76
+ -o, --spo2 [DATE[,DATE]]
77
+ SpO2 data
78
+ -e, --heart [DATE[,DATE]]
79
+ Heart Rate Time Series
80
+ -a, --active-zone [DATE[,DATE]]
81
+ Active Zone Minutes (AZM) Time Series
82
+ -u, --show-user-profile
83
+ Show user profile data
84
+ ```
85
+
86
+ ## Register Fitbit App
87
+
88
+ 1. Go to [https://dev.fitbit.com/apps](https://dev.fitbit.com/apps)
89
+ 2. Click on "REGISTER AN APP" tab
90
+ 3. Follow below example and register an app
91
+
92
+ <p align="left">
93
+ <img alt="Fitbit logo", width="700" src="./assets/fitbit-app-registration.png">
94
+ </p>
95
+
96
+ ## Local Development
97
+
98
+ - [Fitbit Docs](https://dev.fitbit.com/build/reference/web-api/)
99
+ - [OAuth Tutorial](https://dev.fitbit.com/build/reference/web-api/troubleshooting-guide/oauth2-tutorial/)
100
+
101
+ ```bash
102
+ git clone git@github.com:veerendra2/fitbit-cli.git
103
+ cd fitbit-cli
104
+
105
+ python -m venv venv
106
+ source venv/bin/activate
107
+ python -m pip install -e .
108
+ ```
@@ -0,0 +1,78 @@
1
+ # Fitbit CLI
2
+
3
+ > ⚠️ This is not an official Fitbit CLI
4
+
5
+ Access your Fitbit data directly from your terminal 💻. View 💤 sleep logs, ❤️ heart rate, 🏋️‍♂️ activity levels, 🩸 SpO2, and more, all presented in a simple, easy-to-read table format!
6
+
7
+ <p align="center">
8
+ <img alt="Fitbit logo", width="250" src="./assets/Fitbit_Logo_White_RGB.jpg">
9
+ </p>
10
+
11
+ ## Supported Web APIs
12
+
13
+ > Only `GET` APIs are supported!
14
+
15
+ | API | Status |
16
+ | ----------------------------------------------------------------------------------------------------------------------- | ------ |
17
+ | [User](https://dev.fitbit.com/build/reference/web-api/user/) | ✅ |
18
+ | [Sleep](https://dev.fitbit.com/build/reference/web-api/sleep/) | ✅ |
19
+ | [SpO2](https://dev.fitbit.com/build/reference/web-api/spo2/) | ✅ |
20
+ | [Heart Rate Time Series](https://dev.fitbit.com/build/reference/web-api/heartrate-timeseries/) | ✅ |
21
+ | [Active Zone Minutes (AZM) Time Series](https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/) | ✅ |
22
+ | [Activity](https://dev.fitbit.com/build/reference/web-api/activity/) | 👷 |
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ python -m pip install fitbit-cli
28
+
29
+ fitbit-cli -h
30
+ usage: fitbit-cli [-h] [-d | -i] [-s [DATE[,DATE]]] [-o [DATE[,DATE]]] [-e [DATE[,DATE]]] [-a [DATE[,DATE]]] [-u] [-v]
31
+
32
+ Fitbit CLI -- Access your Fitbit data at your terminal.
33
+
34
+ options:
35
+ -h, --help show this help message and exit
36
+ -d, --json-dump Dump all your Fitbit data in json files.
37
+ -i, --init Run interative setup to fetch token.
38
+ -v, --version Show fitbit-cli version
39
+
40
+ APIs:
41
+ Specify date ranges (ISO 8601 format: YYYY-MM-DD) for the following arguments.
42
+ You can provide a single date or a range (start,end). If not provided, defaults to today's date.
43
+
44
+ -s, --sleep [DATE[,DATE]]
45
+ Sleep data
46
+ -o, --spo2 [DATE[,DATE]]
47
+ SpO2 data
48
+ -e, --heart [DATE[,DATE]]
49
+ Heart Rate Time Series
50
+ -a, --active-zone [DATE[,DATE]]
51
+ Active Zone Minutes (AZM) Time Series
52
+ -u, --show-user-profile
53
+ Show user profile data
54
+ ```
55
+
56
+ ## Register Fitbit App
57
+
58
+ 1. Go to [https://dev.fitbit.com/apps](https://dev.fitbit.com/apps)
59
+ 2. Click on "REGISTER AN APP" tab
60
+ 3. Follow below example and register an app
61
+
62
+ <p align="left">
63
+ <img alt="Fitbit logo", width="700" src="./assets/fitbit-app-registration.png">
64
+ </p>
65
+
66
+ ## Local Development
67
+
68
+ - [Fitbit Docs](https://dev.fitbit.com/build/reference/web-api/)
69
+ - [OAuth Tutorial](https://dev.fitbit.com/build/reference/web-api/troubleshooting-guide/oauth2-tutorial/)
70
+
71
+ ```bash
72
+ git clone git@github.com:veerendra2/fitbit-cli.git
73
+ cd fitbit-cli
74
+
75
+ python -m venv venv
76
+ source venv/bin/activate
77
+ python -m pip install -e .
78
+ ```
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ fitbit_cli Module
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,106 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ CLI Arguments Parser
4
+ """
5
+
6
+ import argparse
7
+ from datetime import datetime
8
+
9
+ from . import __version__
10
+
11
+
12
+ def parse_date_range(date_str):
13
+ """Date parser"""
14
+
15
+ dates = date_str.split(",")
16
+ start_date = datetime.strptime(dates[0], "%Y-%m-%d").date()
17
+ try:
18
+ end_date = datetime.strptime(dates[1], "%Y-%m-%d").date()
19
+ if start_date > end_date:
20
+ raise ValueError("Start date must not be after end date")
21
+ except IndexError:
22
+ end_date = None
23
+
24
+ return (start_date, end_date)
25
+
26
+
27
+ def parse_arguments():
28
+ """Argument parser"""
29
+
30
+ parser = argparse.ArgumentParser(
31
+ description="Fitbit CLI -- Access your Fitbit data at your terminal.",
32
+ formatter_class=argparse.RawTextHelpFormatter,
33
+ )
34
+
35
+ mutex_group = parser.add_mutually_exclusive_group(required=False)
36
+
37
+ mutex_group.add_argument(
38
+ "-d",
39
+ "--json-dump",
40
+ action="store_true",
41
+ help="Dump all your Fitbit data in json files.",
42
+ )
43
+ mutex_group.add_argument(
44
+ "-i",
45
+ "--init",
46
+ action="store_true",
47
+ help="Run interative setup to fetch token.",
48
+ )
49
+
50
+ group = parser.add_argument_group(
51
+ "APIs",
52
+ "Specify date ranges (ISO 8601 format: YYYY-MM-DD) for the following arguments.\n"
53
+ "You can provide a single date or a range (start,end). If not provided, defaults to today's date.",
54
+ )
55
+ group.add_argument(
56
+ "-s",
57
+ "--sleep",
58
+ type=parse_date_range,
59
+ nargs="?",
60
+ const=(datetime.today().date(), None),
61
+ metavar="DATE[,DATE]",
62
+ help="Sleep data",
63
+ )
64
+ group.add_argument(
65
+ "-o",
66
+ "--spo2",
67
+ type=parse_date_range,
68
+ nargs="?",
69
+ const=(datetime.today().date(), None),
70
+ metavar="DATE[,DATE]",
71
+ help="SpO2 data",
72
+ )
73
+ group.add_argument(
74
+ "-e",
75
+ "--heart",
76
+ type=parse_date_range,
77
+ nargs="?",
78
+ const=(datetime.today().date(), None),
79
+ metavar="DATE[,DATE]",
80
+ help="Heart Rate Time Series",
81
+ )
82
+ group.add_argument(
83
+ "-a",
84
+ "--active-zone",
85
+ type=parse_date_range,
86
+ nargs="?",
87
+ const=(datetime.today().date(), None),
88
+ metavar="DATE[,DATE]",
89
+ help="Active Zone Minutes (AZM) Time Series",
90
+ )
91
+ group.add_argument(
92
+ "-u",
93
+ "--show-user-profile",
94
+ action="store_true",
95
+ help="Show user profile data",
96
+ )
97
+
98
+ parser.add_argument(
99
+ "-v",
100
+ "--version",
101
+ action="version",
102
+ version=f"%(prog)s v{__version__}",
103
+ help="Show fitbit-cli version",
104
+ )
105
+
106
+ return parser.parse_args()
@@ -0,0 +1,20 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Exceptions for Fitbit CLI
4
+ """
5
+
6
+
7
+ class FitbitInitError(Exception):
8
+ """Custom exception for Fitbit initial setup"""
9
+
10
+ def __init__(self, message):
11
+ super().__init__(message)
12
+ self.message = message
13
+
14
+
15
+ class FitbitAPIError(Exception):
16
+ """Custom exception for Fitbit API"""
17
+
18
+ def __init__(self, message):
19
+ super().__init__(message)
20
+ self.message = message
@@ -0,0 +1,142 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Fitbit API
4
+ """
5
+
6
+ import requests
7
+
8
+ from .exceptions import FitbitAPIError
9
+
10
+
11
+ class FitbitAPI:
12
+ """Fitbit API"""
13
+
14
+ TOKEN_API = "https://api.fitbit.com/oauth2/token"
15
+
16
+ def __init__(self, client_id, client_secret, access_token, refresh_token):
17
+ self.client_id = client_id
18
+ self.client_secret = client_secret
19
+ self.access_token = access_token
20
+ self.refresh_token = refresh_token
21
+ self.headers = self._create_headers()
22
+
23
+ def _create_headers(self):
24
+ return {
25
+ "Authorization": f"Bearer {self.access_token}",
26
+ "Content-Type": "application/json",
27
+ }
28
+
29
+ def refresh_access_token(self):
30
+ """Refresh token"""
31
+
32
+ payload = {
33
+ "grant_type": "refresh_token",
34
+ "client_id": self.client_id,
35
+ "refresh_token": self.refresh_token,
36
+ }
37
+ headers = {
38
+ "Authorization": f"Basic {self.client_secret}",
39
+ "Content-Type": "application/x-www-form-urlencoded",
40
+ }
41
+ response = requests.post(
42
+ FitbitAPI.TOKEN_API, data=payload, headers=headers, timeout=5
43
+ )
44
+
45
+ if response.status_code == 200:
46
+ tokens = response.json()
47
+ self.access_token = tokens.get("access_token")
48
+ self.refresh_token = tokens.get("refresh_token")
49
+ self.headers = self._create_headers()
50
+ else:
51
+ raise FitbitAPIError("Failed to refresh access token")
52
+
53
+ def make_request(self, method, url, **kwargs):
54
+ """Make an API request and handle token refresh if needed."""
55
+
56
+ try:
57
+ response = requests.request(
58
+ method, url, headers=self.headers, timeout=5, **kwargs
59
+ )
60
+ response.raise_for_status()
61
+ except requests.exceptions.HTTPError as e:
62
+ if response.status_code == 401:
63
+ self.refresh_access_token()
64
+ response = requests.request(
65
+ method, url, headers=self.headers, timeout=5, **kwargs
66
+ )
67
+ response.raise_for_status()
68
+ else:
69
+ raise FitbitAPIError(f"HTTP error occurred: {response.json()}") from e
70
+
71
+ return response
72
+
73
+ def get_user_profile(self):
74
+ """Get Profile"""
75
+
76
+ url = "https://api.fitbit.com/1/user/-/profile.json"
77
+ response = self.make_request("GET", url)
78
+ return response.json()
79
+
80
+ def get_sleep_log(self, start_date, end_date=None):
81
+ """Get Sleep Logs by Date Range and Date"""
82
+
83
+ date_range = f"{start_date}/{end_date}" if end_date else start_date
84
+ url = f"https://api.fitbit.com/1.2/user/-/sleep/date/{date_range}.json"
85
+ response = self.make_request("GET", url)
86
+ return response.json()
87
+
88
+ def get_heart_rate_time_series(self, start_date, end_date=None):
89
+ """Get Heart Rate Time Series by Date Range and Date"""
90
+
91
+ date_range = f"{start_date}/{end_date}" if end_date else f"{start_date}/1d"
92
+ url = f"https://api.fitbit.com/1/user/-/activities/heart/date/{date_range}.json"
93
+ response = self.make_request("GET", url)
94
+ return response.json()
95
+
96
+ def get_spo2_summary(self, start_date, end_date=None):
97
+ """Get SpO2 Summary by Interval and Date"""
98
+
99
+ date_range = f"{start_date}/{end_date}" if end_date else start_date
100
+ url = f"https://api.fitbit.com/1/user/-/spo2/date/{date_range}.json"
101
+ response = self.make_request("GET", url)
102
+ return response.json()
103
+
104
+ def get_spo2_intraday(self, start_date, end_date=None):
105
+ """Get SpO2 Intraday by Interval and Date"""
106
+
107
+ date_range = f"{start_date}/{end_date}" if end_date else start_date
108
+ url = f"https://api.fitbit.com/1/user/-/spo2/date/{date_range}/all.json"
109
+ response = self.make_request("GET", url)
110
+ return response.json()
111
+
112
+ def get_azm_time_series(self, start_date, end_date=None):
113
+ """Get AZM Time Series by Interval and Data"""
114
+
115
+ date_range = f"{start_date}/{end_date}" if end_date else f"{start_date}/1d"
116
+ url = f"https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/{date_range}.json"
117
+ response = self.make_request("GET", url)
118
+ return response.json()
119
+
120
+ def get_azm_intraday(self, start_date, end_date=None):
121
+ """Get AZM Intraday by Interval and Data"""
122
+
123
+ date_range = f"{start_date}/{end_date}" if end_date else f"{start_date}/1d"
124
+ url = f"https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/{date_range}/1min.json"
125
+ response = self.make_request("GET", url)
126
+ return response.json()
127
+
128
+ def get_breathing_rate_summary(self, start_date, end_date=None):
129
+ """Get Breathing Rate Summary by Interval and Data"""
130
+
131
+ date_range = f"{start_date}/{end_date}" if end_date else start_date
132
+ url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}.json"
133
+ response = self.make_request("GET", url)
134
+ return response.json()
135
+
136
+ def get_breathing_rate_intraday(self, start_date, end_date=None):
137
+ """Get Breathing Rate Intraday by Interval and Data"""
138
+
139
+ date_range = f"{start_date}/{end_date}" if end_date else start_date
140
+ url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}/all.json"
141
+ response = self.make_request("GET", url)
142
+ return response.json()
@@ -0,0 +1,165 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Fitbit initial setup
4
+ """
5
+
6
+ import getpass
7
+ import json
8
+ import secrets
9
+ import string
10
+ import threading
11
+ import webbrowser
12
+ from base64 import b64encode, urlsafe_b64encode
13
+ from hashlib import sha256
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from pathlib import Path
16
+ from urllib.parse import parse_qs, urlparse
17
+
18
+ import requests
19
+ from rich.prompt import Prompt
20
+
21
+ from .exceptions import FitbitInitError
22
+ from .formatter import CONSOLE
23
+
24
+ BASE_URL = "https://www.fitbit.com/oauth2/authorize"
25
+ FITBIT_TOKEN_PATH = f"{Path.home()}/.fitbit/token.json"
26
+ SCOPE = [
27
+ "activity",
28
+ "cardio_fitness",
29
+ "electrocardiogram",
30
+ "heartrate",
31
+ "irregular_rhythm_notifications",
32
+ "location",
33
+ "nutrition",
34
+ "oxygen_saturation",
35
+ "profile",
36
+ "respiratory_rate",
37
+ "settings",
38
+ "sleep",
39
+ "social",
40
+ "temperature",
41
+ "weight",
42
+ ]
43
+ TOKEN_URL = "https://api.fitbit.com/oauth2/token"
44
+
45
+
46
+ class RequestHandler(BaseHTTPRequestHandler):
47
+ """Simple HTTP request handler"""
48
+
49
+ code = None
50
+
51
+ def do_GET(self): # pylint: disable=C0103
52
+ """Handle GET request"""
53
+ parsed_url = urlparse(self.path)
54
+ query_params = parse_qs(parsed_url.query)
55
+ RequestHandler.code = query_params.get("code", [""])[0]
56
+
57
+ self.send_response(200)
58
+ self.send_header("Content-type", "text/html")
59
+ self.end_headers()
60
+ self.wfile.write(b"You can close this window now")
61
+
62
+ threading.Thread(target=self.server.shutdown).start()
63
+
64
+
65
+ def start_server():
66
+ """Start simple HTTP server to catch token"""
67
+
68
+ with HTTPServer(("127.0.0.1", 8080), RequestHandler) as httpd:
69
+ CONSOLE.print(":computer: Serving on http://127.0.0.1:8080")
70
+ httpd.serve_forever()
71
+ return RequestHandler.code
72
+
73
+
74
+ def fitbit_init_setup():
75
+ """Fitbit initial setup"""
76
+
77
+ # --------- Generate code challenge using random strings ---------
78
+ code_verifier = "".join(
79
+ secrets.choice(string.ascii_letters + string.digits) for _ in range(128)
80
+ )
81
+ code_challenge = (
82
+ urlsafe_b64encode(sha256(code_verifier.encode("utf-8")).digest())
83
+ .rstrip(b"=")
84
+ .decode("utf-8")
85
+ )
86
+
87
+ # --------- Get client ID and constructs authorization URL ---------
88
+ client_id = Prompt.ask(":bust_in_silhouette: Enter your Client ID")
89
+ assert client_id, "Invalid Client ID"
90
+
91
+ authorization_url = (
92
+ f"{BASE_URL}?client_id={client_id}&response_type=code"
93
+ f"&code_challenge={code_challenge}&code_challenge_method=S256"
94
+ f"&scope={'+'.join(SCOPE)}"
95
+ )
96
+
97
+ # --------- Get authorization code ---------
98
+ CONSOLE.print(
99
+ f":earth_asia: Opening below URL in browser to authorize the app \n\n{authorization_url}\n"
100
+ )
101
+ try:
102
+ browser_status = webbrowser.open(authorization_url)
103
+ if browser_status:
104
+ CONSOLE.print(
105
+ ":satellite: Waiting for authorization... "
106
+ + "(Check your browser or press 'Ctrl+C', authrize the app by opening the"
107
+ + " above URL in your browser and past the redirect URL manually.)\n"
108
+ )
109
+ authorization_code = start_server()
110
+ else:
111
+ raise FitbitInitError("Failed to open the URL in browser")
112
+ except (KeyboardInterrupt, FitbitInitError):
113
+ CONSOLE.print("\n:unamused: Error while opening the URL...")
114
+ CONSOLE.print(
115
+ ":neutral_face: Authrize the app by opening the above URL in your"
116
+ + "browser and past the redirect URL"
117
+ )
118
+ redirect_url = Prompt.ask(":clipboard: Paste the redirect URL: ")
119
+ assert redirect_url, "Invalid URL"
120
+
121
+ authorization_code = parse_qs(urlparse(redirect_url).query).get("code", [None])[
122
+ 0
123
+ ]
124
+
125
+ # --------- Get access and refresh tokens ---------
126
+ client_secret = getpass.getpass("\n🔑 Enter your Client Secret: ")
127
+ assert client_secret, "Invalid Client Secret"
128
+
129
+ encoded_auth = b64encode(f"{client_id}:{client_secret}".encode()).decode()
130
+ response = requests.post(
131
+ TOKEN_URL,
132
+ headers={
133
+ "Authorization": f"Basic {encoded_auth}",
134
+ "Content-Type": "application/x-www-form-urlencoded",
135
+ },
136
+ data={
137
+ "client_id": client_id,
138
+ "grant_type": "authorization_code",
139
+ "code": authorization_code,
140
+ "code_verifier": code_verifier,
141
+ },
142
+ timeout=5,
143
+ )
144
+
145
+ if response.status_code == 200:
146
+ response_json = response.json()
147
+ Path(FITBIT_TOKEN_PATH).parent.mkdir(parents=True, exist_ok=True)
148
+ token_content = {
149
+ "access_token": response_json.get("access_token", ""),
150
+ "refresh_token": response_json.get("refresh_token", ""),
151
+ "client_id": client_id,
152
+ "secret": encoded_auth,
153
+ }
154
+ with open(FITBIT_TOKEN_PATH, "w", encoding="utf-8") as f:
155
+ json.dump(token_content, f)
156
+
157
+ CONSOLE.print(
158
+ f":floppy_disk: Saving fitbit token in {FITBIT_TOKEN_PATH}",
159
+ style="bold green",
160
+ )
161
+ else:
162
+ CONSOLE.print(
163
+ f":unamused: Failed to get tokens: {response.json()['errors'][0]['errorType']}",
164
+ style="bold red",
165
+ )
@@ -0,0 +1,147 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Json Data Formatter
4
+ """
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ CONSOLE = Console()
9
+
10
+
11
+ def display_user_profile(user_data):
12
+ """User data formatter"""
13
+
14
+ table = Table(
15
+ title=f"Hello, {user_data['user']['displayName']} :wave:", show_header=False
16
+ )
17
+
18
+ table.add_column("")
19
+ table.add_column("")
20
+
21
+ table.add_row(":bust_in_silhouette: First Name", user_data["user"]["firstName"])
22
+ table.add_row(":family: Last Name", user_data["user"]["lastName"])
23
+ table.add_row(":birthday: Date Of Birth", user_data["user"]["dateOfBirth"])
24
+ table.add_row(":hourglass_flowing_sand: Age", str(user_data["user"]["age"]))
25
+ table.add_row(":restroom: Gender", user_data["user"]["gender"])
26
+ table.add_row(":straight_ruler: Height", f"{user_data['user']['height']:.1f}")
27
+ table.add_row(":weight_lifter: Weight", f"{user_data['user']['weight']:.1f}")
28
+ table.add_row(
29
+ ":footprints: Average Daily Steps", str(user_data["user"]["averageDailySteps"])
30
+ )
31
+ table.add_row(":calendar: Member Since", user_data["user"]["memberSince"])
32
+ table.add_row(":clock1: Time Zone", user_data["user"]["timezone"])
33
+
34
+ CONSOLE.print(table)
35
+
36
+
37
+ def display_sleep(sleep_data):
38
+ """Sleep data formatter"""
39
+
40
+ table = Table(title="Sleep Data Summary :sleeping:", show_header=True)
41
+
42
+ table.add_column("Date :calendar:")
43
+ table.add_column("Deep Sleep :bed:")
44
+ table.add_column("Light Sleep :zzz:")
45
+ table.add_column("REM Sleep :crescent_moon:")
46
+ table.add_column("Wake Time :alarm_clock:")
47
+ table.add_column("Efficiency :100:")
48
+
49
+ for sleep in sleep_data["sleep"]:
50
+ table.add_row(
51
+ sleep["dateOfSleep"],
52
+ f"{sleep['levels']['summary'].get('deep', {}).get('minutes', 'N/A')} min",
53
+ f"{sleep['levels']['summary'].get('light', {}).get('minutes', 'N/A')} min",
54
+ f"{sleep['levels']['summary'].get('rem', {}).get('minutes', 'N/A')} min",
55
+ f"{sleep['levels']['summary'].get('wake', {}).get('minutes', 'N/A')} min",
56
+ f"{sleep['efficiency']}%",
57
+ )
58
+
59
+ CONSOLE.print(table)
60
+
61
+
62
+ def display_spo2(spo2_data):
63
+ """SpO2 data formatter"""
64
+
65
+ table = Table(title="SpO2 Data Summary :heart:", show_header=True)
66
+
67
+ table.add_column("Date :calendar:")
68
+ table.add_column("Minimum :red_circle:")
69
+ table.add_column("Average :blue_circle:")
70
+ table.add_column("Maximum :green_circle:")
71
+
72
+ if isinstance(spo2_data, dict):
73
+ spo2_data = [spo2_data]
74
+
75
+ for spo2 in spo2_data:
76
+ table.add_row(
77
+ spo2["dateTime"],
78
+ str(spo2["value"].get("min", "N/A")),
79
+ str(spo2["value"].get("avg", "N/A")),
80
+ str(spo2["value"].get("max", "N/A")),
81
+ )
82
+
83
+ CONSOLE.print(table)
84
+
85
+
86
+ def display_heart_data(heart_data):
87
+ """Heart data formatter"""
88
+
89
+ table = Table(title="Heart Rate Time Series :heart:", show_header=True)
90
+
91
+ table.add_column("Date :calendar:")
92
+ table.add_column("Resting Heart Rate :heartpulse:")
93
+ table.add_column("Heart Rate Zones :dart:")
94
+
95
+ for activity in heart_data.get("activities-heart", []):
96
+ date = activity.get("dateTime", "N/A")
97
+ value = activity.get("value", {})
98
+ resting_heart_rate = value.get("restingHeartRate", "N/A")
99
+
100
+ zones_table = Table(show_header=True, header_style="bold magenta")
101
+ zones_table.add_column("Zone :dart:")
102
+ zones_table.add_column("Min :arrow_down:")
103
+ zones_table.add_column("Max :arrow_up:")
104
+ zones_table.add_column("Minutes :hourglass:")
105
+ zones_table.add_column("Calories Out :fire:")
106
+
107
+ for zone in value.get("heartRateZones", []):
108
+ zones_table.add_row(
109
+ zone.get("name", "N/A"),
110
+ str(zone.get("min", "N/A")),
111
+ str(zone.get("max", "N/A")),
112
+ str(zone.get("minutes", "N/A")),
113
+ (
114
+ f"{zone.get('caloriesOut', 'N/A'):.2f}"
115
+ if isinstance(zone.get("caloriesOut"), (int, float))
116
+ else "N/A"
117
+ ),
118
+ )
119
+
120
+ table.add_row(date, str(resting_heart_rate), zones_table)
121
+
122
+ CONSOLE.print(table)
123
+
124
+
125
+ def display_azm_time_series(azm_data):
126
+ """AZM Time Series data formatter"""
127
+
128
+ table = Table(title="AZM Time Series :runner:", show_header=True)
129
+
130
+ table.add_column("Date :calendar:")
131
+ table.add_column("Active Zone Minutes :stopwatch:")
132
+ table.add_column("Fat Burn :fire:")
133
+ table.add_column("Cardio :heart:")
134
+ table.add_column("Peak :mountain:")
135
+
136
+ for activity in azm_data.get("activities-active-zone-minutes", []):
137
+ date = activity.get("dateTime", "N/A")
138
+ value = activity.get("value", {})
139
+ table.add_row(
140
+ date,
141
+ str(value.get("activeZoneMinutes", "N/A")),
142
+ str(value.get("fatBurnActiveZoneMinutes", "N/A")),
143
+ str(value.get("cardioActiveZoneMinutes", "N/A")),
144
+ str(value.get("peakActiveZoneMinutes", "N/A")),
145
+ )
146
+
147
+ CONSOLE.print(table)
@@ -0,0 +1,53 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Main Module
4
+ """
5
+
6
+ import json
7
+ import sys
8
+
9
+ from . import formatter
10
+ from .cli import parse_arguments
11
+ from .fitbit_api import FitbitAPI
12
+ from .fitbit_init import FITBIT_TOKEN_PATH, fitbit_init_setup
13
+
14
+
15
+ def main():
16
+ """Main function"""
17
+
18
+ args = parse_arguments()
19
+
20
+ if args.init:
21
+ fitbit_init_setup()
22
+
23
+ try:
24
+ with open(FITBIT_TOKEN_PATH, encoding="utf-8") as f:
25
+ credentials = json.load(f)
26
+ except FileNotFoundError:
27
+ formatter.CONSOLE.print(
28
+ ":grimacing: Fitbit token file not found. "
29
+ "If this is your first time running the CLI, use '--init' argument to set up the token.",
30
+ style="bold red",
31
+ )
32
+ sys.exit(1)
33
+
34
+ fitbit = FitbitAPI(
35
+ client_id=credentials["client_id"],
36
+ client_secret=credentials["secret"],
37
+ access_token=credentials["access_token"],
38
+ refresh_token=credentials["refresh_token"],
39
+ )
40
+
41
+ with formatter.CONSOLE.status("[bold green]Fetching data...") as _:
42
+ if args.show_user_profile:
43
+ formatter.display_user_profile(fitbit.get_user_profile())
44
+ if args.sleep:
45
+ formatter.display_sleep(fitbit.get_sleep_log(*args.sleep))
46
+ if args.spo2:
47
+ formatter.display_spo2(fitbit.get_spo2_summary(*args.spo2))
48
+ if args.heart:
49
+ formatter.display_heart_data(fitbit.get_heart_rate_time_series(*args.heart))
50
+ if args.active_zone:
51
+ formatter.display_azm_time_series(
52
+ fitbit.get_azm_time_series(*args.active_zone)
53
+ )
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.1
2
+ Name: fitbit-cli
3
+ Version: 0.1.0
4
+ Summary: Access your Fitbit data at your terminal.
5
+ Home-page: https://github.com/veerendra2/fitbit-cli
6
+ Download-URL: https://github.com/veerendra2/fitbit-cli/archive/0.1.0.tar.gz
7
+ Author: veerendra2
8
+ Author-email: vk.tyk23@simplelogin.com
9
+ License: MIT
10
+ Project-URL: Documentation, https://github.com/veerendra2/fitbit-cli
11
+ Keywords: fitbit,cli,python
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Natural Language :: English
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ Requires-Dist: requests==2.32.3
29
+ Requires-Dist: rich==13.9.4
30
+
31
+ # Fitbit CLI
32
+
33
+ > ⚠️ This is not an official Fitbit CLI
34
+
35
+ Access your Fitbit data directly from your terminal 💻. View 💤 sleep logs, ❤️ heart rate, 🏋️‍♂️ activity levels, 🩸 SpO2, and more, all presented in a simple, easy-to-read table format!
36
+
37
+ <p align="center">
38
+ <img alt="Fitbit logo", width="250" src="./assets/Fitbit_Logo_White_RGB.jpg">
39
+ </p>
40
+
41
+ ## Supported Web APIs
42
+
43
+ > Only `GET` APIs are supported!
44
+
45
+ | API | Status |
46
+ | ----------------------------------------------------------------------------------------------------------------------- | ------ |
47
+ | [User](https://dev.fitbit.com/build/reference/web-api/user/) | ✅ |
48
+ | [Sleep](https://dev.fitbit.com/build/reference/web-api/sleep/) | ✅ |
49
+ | [SpO2](https://dev.fitbit.com/build/reference/web-api/spo2/) | ✅ |
50
+ | [Heart Rate Time Series](https://dev.fitbit.com/build/reference/web-api/heartrate-timeseries/) | ✅ |
51
+ | [Active Zone Minutes (AZM) Time Series](https://dev.fitbit.com/build/reference/web-api/active-zone-minutes-timeseries/) | ✅ |
52
+ | [Activity](https://dev.fitbit.com/build/reference/web-api/activity/) | 👷 |
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ python -m pip install fitbit-cli
58
+
59
+ fitbit-cli -h
60
+ usage: fitbit-cli [-h] [-d | -i] [-s [DATE[,DATE]]] [-o [DATE[,DATE]]] [-e [DATE[,DATE]]] [-a [DATE[,DATE]]] [-u] [-v]
61
+
62
+ Fitbit CLI -- Access your Fitbit data at your terminal.
63
+
64
+ options:
65
+ -h, --help show this help message and exit
66
+ -d, --json-dump Dump all your Fitbit data in json files.
67
+ -i, --init Run interative setup to fetch token.
68
+ -v, --version Show fitbit-cli version
69
+
70
+ APIs:
71
+ Specify date ranges (ISO 8601 format: YYYY-MM-DD) for the following arguments.
72
+ You can provide a single date or a range (start,end). If not provided, defaults to today's date.
73
+
74
+ -s, --sleep [DATE[,DATE]]
75
+ Sleep data
76
+ -o, --spo2 [DATE[,DATE]]
77
+ SpO2 data
78
+ -e, --heart [DATE[,DATE]]
79
+ Heart Rate Time Series
80
+ -a, --active-zone [DATE[,DATE]]
81
+ Active Zone Minutes (AZM) Time Series
82
+ -u, --show-user-profile
83
+ Show user profile data
84
+ ```
85
+
86
+ ## Register Fitbit App
87
+
88
+ 1. Go to [https://dev.fitbit.com/apps](https://dev.fitbit.com/apps)
89
+ 2. Click on "REGISTER AN APP" tab
90
+ 3. Follow below example and register an app
91
+
92
+ <p align="left">
93
+ <img alt="Fitbit logo", width="700" src="./assets/fitbit-app-registration.png">
94
+ </p>
95
+
96
+ ## Local Development
97
+
98
+ - [Fitbit Docs](https://dev.fitbit.com/build/reference/web-api/)
99
+ - [OAuth Tutorial](https://dev.fitbit.com/build/reference/web-api/troubleshooting-guide/oauth2-tutorial/)
100
+
101
+ ```bash
102
+ git clone git@github.com:veerendra2/fitbit-cli.git
103
+ cd fitbit-cli
104
+
105
+ python -m venv venv
106
+ source venv/bin/activate
107
+ python -m pip install -e .
108
+ ```
@@ -0,0 +1,17 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ fitbit_cli/__init__.py
6
+ fitbit_cli/cli.py
7
+ fitbit_cli/exceptions.py
8
+ fitbit_cli/fitbit_api.py
9
+ fitbit_cli/fitbit_init.py
10
+ fitbit_cli/formatter.py
11
+ fitbit_cli/main.py
12
+ fitbit_cli.egg-info/PKG-INFO
13
+ fitbit_cli.egg-info/SOURCES.txt
14
+ fitbit_cli.egg-info/dependency_links.txt
15
+ fitbit_cli.egg-info/entry_points.txt
16
+ fitbit_cli.egg-info/requires.txt
17
+ fitbit_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fitbit-cli = fitbit_cli.main:main
@@ -0,0 +1,2 @@
1
+ requests==2.32.3
2
+ rich==13.9.4
@@ -0,0 +1 @@
1
+ fitbit_cli
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.black]
6
+ line-length = 88
7
+
8
+ [tool.pylint.messages_control]
9
+ disable = ["E0401"]
10
+
11
+ [tool.isort]
12
+ profile = "black"
13
+
14
+ [tool.mypy]
15
+ ignore_missing_imports = true
16
+
17
+ [tool.pylint.report]
18
+ output-format = "colorized"
19
+
20
+ [tool.pylint.reporter]
21
+ max-line-length = 120
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,56 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ setup.py
4
+ """
5
+ # pylint: disable=C0301
6
+
7
+ import re
8
+
9
+ from setuptools import find_packages, setup
10
+
11
+ with open("fitbit_cli/__init__.py", encoding="utf-8") as file:
12
+ REGEX_VERSION = r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]'
13
+ version = re.search(REGEX_VERSION, file.read(), re.MULTILINE).group(1) # type: ignore[union-attr]
14
+
15
+ with open("README.md", encoding="utf-8") as file:
16
+ readme = file.read()
17
+
18
+ setup(
19
+ name="fitbit-cli",
20
+ version=version,
21
+ packages=find_packages(),
22
+ description="Access your Fitbit data at your terminal.",
23
+ long_description=readme,
24
+ long_description_content_type="text/markdown",
25
+ author="veerendra2",
26
+ author_email="vk.tyk23@simplelogin.com",
27
+ url="https://github.com/veerendra2/fitbit-cli",
28
+ download_url=f"https://github.com/veerendra2/fitbit-cli/archive/{version}.tar.gz",
29
+ project_urls={
30
+ "Documentation": "https://github.com/veerendra2/fitbit-cli",
31
+ },
32
+ keywords=["fitbit", "cli", "python"],
33
+ license="MIT",
34
+ classifiers=[
35
+ "Development Status :: 4 - Beta",
36
+ "Intended Audience :: End Users/Desktop",
37
+ "License :: OSI Approved :: Apache Software License",
38
+ "Natural Language :: English",
39
+ "Operating System :: POSIX :: Linux",
40
+ "Programming Language :: Python",
41
+ "Programming Language :: Python :: 3",
42
+ "Programming Language :: Python :: 3 :: Only",
43
+ "Programming Language :: Python :: 3.9",
44
+ "Programming Language :: Python :: 3.10",
45
+ "Programming Language :: Python :: 3.11",
46
+ "Programming Language :: Python :: 3.12",
47
+ "Programming Language :: Python :: 3.13",
48
+ "Topic :: Utilities",
49
+ ],
50
+ install_requires=[
51
+ "requests==2.32.3",
52
+ "rich==13.9.4",
53
+ ],
54
+ python_requires=">=3.9",
55
+ entry_points={"console_scripts": ["fitbit-cli = fitbit_cli.main:main"]},
56
+ )