mirror of
https://github.com/hanzoskill/withings-family.git
synced 2026-10-30 21:00:42 +00:00
311 lines
9.9 KiB
Python
311 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Withings Health Data CLI
|
|
Supports multiple users with per-user token files.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta
|
|
|
|
BASE_DIR = Path.home() / '.bot' / 'withings-family'
|
|
|
|
# Load .env if present
|
|
env_file = BASE_DIR / '.env'
|
|
if env_file.exists():
|
|
for line in env_file.read_text().splitlines():
|
|
if '=' in line and not line.strip().startswith('#'):
|
|
key, value = line.split('=', 1)
|
|
os.environ[key.strip()] = value.strip().strip('"\'')
|
|
|
|
CLIENT_ID = os.environ.get('WITHINGS_CLIENT_ID')
|
|
CLIENT_SECRET = os.environ.get('WITHINGS_CLIENT_SECRET')
|
|
REDIRECT_URI = 'http://localhost:18081'
|
|
|
|
|
|
def get_token_file(user_id='default'):
|
|
"""Get token file path for a user."""
|
|
return BASE_DIR / f'tokens-{user_id}.json'
|
|
|
|
|
|
def save_tokens(data, user_id='default'):
|
|
"""Save tokens with expiry calculation."""
|
|
expiry = time.time() + data['expires_in']
|
|
payload = {**data, 'expiry_date': expiry}
|
|
get_token_file(user_id).write_text(json.dumps(payload, indent=2))
|
|
return payload
|
|
|
|
|
|
def post_request(endpoint, params):
|
|
"""Make a POST request to Withings API."""
|
|
data = urllib.parse.urlencode(params).encode()
|
|
req = urllib.request.Request(
|
|
f'https://wbsapi.withings.net{endpoint}',
|
|
data=data,
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
|
)
|
|
|
|
with urllib.request.urlopen(req) as response:
|
|
result = json.loads(response.read())
|
|
if result['status'] != 0:
|
|
raise Exception(f"API Error: {result['status']} - {result}")
|
|
return result.get('body', result)
|
|
|
|
|
|
def get_valid_token(user_id='default'):
|
|
"""Get a valid access token, refreshing if needed."""
|
|
token_file = get_token_file(user_id)
|
|
|
|
if not token_file.exists():
|
|
raise Exception(f"No token found for '{user_id}'. Run: python3 withings.py {user_id} auth")
|
|
|
|
tokens = json.loads(token_file.read_text())
|
|
|
|
# Fix expiry if it's in milliseconds instead of seconds
|
|
if tokens['expiry_date'] > time.time() * 1000:
|
|
tokens['expiry_date'] = tokens['expiry_date'] / 1000
|
|
token_file.write_text(json.dumps(tokens, indent=2))
|
|
|
|
# Refresh if expiring in less than 60 seconds
|
|
if time.time() > (tokens['expiry_date'] - 60):
|
|
print("Token expired, refreshing...", file=sys.stderr)
|
|
new_tokens = post_request('/v2/oauth2', {
|
|
'action': 'requesttoken',
|
|
'grant_type': 'refresh_token',
|
|
'client_id': CLIENT_ID,
|
|
'client_secret': CLIENT_SECRET,
|
|
'refresh_token': tokens['refresh_token']
|
|
})
|
|
tokens = save_tokens(new_tokens, user_id)
|
|
|
|
return tokens['access_token']
|
|
|
|
|
|
def auth(user_id, code=None):
|
|
"""Handle OAuth flow."""
|
|
if not code:
|
|
params = {
|
|
'response_type': 'code',
|
|
'client_id': CLIENT_ID,
|
|
'redirect_uri': REDIRECT_URI,
|
|
'scope': 'user.metrics,user.activity',
|
|
'state': 'init'
|
|
}
|
|
url = f"https://account.withings.com/oauth2_user/authorize2?{urllib.parse.urlencode(params)}"
|
|
|
|
print("\n=== AUTHENTICATION REQUIRED ===")
|
|
print(f"User: {user_id}")
|
|
print("1. Open this link in your browser:")
|
|
print(url)
|
|
print("\n2. After login, you'll be redirected to an error page (this is normal).")
|
|
print("3. Copy the code from the URL (e.g., ?code=my_code&...)")
|
|
print(f"4. Run: python3 withings.py {user_id} auth YOUR_CODE_HERE\n")
|
|
return
|
|
|
|
tokens = post_request('/v2/oauth2', {
|
|
'action': 'requesttoken',
|
|
'grant_type': 'authorization_code',
|
|
'client_id': CLIENT_ID,
|
|
'client_secret': CLIENT_SECRET,
|
|
'code': code,
|
|
'redirect_uri': REDIRECT_URI
|
|
})
|
|
|
|
save_tokens(tokens, user_id)
|
|
print(f"Authentication successful for '{user_id}'! Tokens saved.")
|
|
|
|
|
|
def get_weight(user_id='default'):
|
|
"""Fetch recent weight measurements."""
|
|
token = get_valid_token(user_id)
|
|
data = post_request('/measure', {
|
|
'action': 'getmeas',
|
|
'access_token': token,
|
|
'meastype': 1, # Weight
|
|
'category': 1
|
|
})
|
|
|
|
measures = []
|
|
for grp in data['measuregrps']:
|
|
date = datetime.fromtimestamp(grp['date']).isoformat()
|
|
meas = next((m for m in grp['measures'] if m['type'] == 1), None)
|
|
if meas:
|
|
weight = meas['value'] * (10 ** meas['unit'])
|
|
measures.append({
|
|
'date': date,
|
|
'weight': f"{weight:.2f} kg"
|
|
})
|
|
|
|
print(json.dumps(measures[:5], indent=2))
|
|
|
|
|
|
def get_body(user_id='default', limit=None):
|
|
"""Fetch body composition data."""
|
|
token = get_valid_token(user_id)
|
|
data = post_request('/measure', {
|
|
'action': 'getmeas',
|
|
'access_token': token,
|
|
'category': 1
|
|
})
|
|
|
|
measure_types = {
|
|
1: ('weight', 'kg', 2),
|
|
4: ('height', 'm', 2),
|
|
6: ('fat_percent', '%', 1),
|
|
8: ('fat_mass', 'kg', 2),
|
|
76: ('muscle_mass', 'kg', 2),
|
|
77: ('hydration', '%', 1),
|
|
88: ('bone_mass', 'kg', 2)
|
|
}
|
|
|
|
measures = []
|
|
for grp in data['measuregrps']:
|
|
date = datetime.fromtimestamp(grp['date']).isoformat()
|
|
result = {'date': date}
|
|
|
|
for meas in grp['measures']:
|
|
if meas['type'] in measure_types:
|
|
name, unit, decimals = measure_types[meas['type']]
|
|
value = meas['value'] * (10 ** meas['unit'])
|
|
result[name] = f"{value:.{decimals}f} {unit}"
|
|
|
|
if len(result) > 1:
|
|
measures.append(result)
|
|
|
|
# Apply limit if specified (default None = all)
|
|
if limit:
|
|
measures = measures[:limit]
|
|
|
|
print(json.dumps(measures, indent=2))
|
|
|
|
|
|
def get_activity(user_id='default', days=7):
|
|
"""Fetch activity data."""
|
|
token = get_valid_token(user_id)
|
|
end_date = datetime.now()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
data = post_request('/v2/measure', {
|
|
'action': 'getactivity',
|
|
'access_token': token,
|
|
'startdateymd': start_date.strftime('%Y-%m-%d'),
|
|
'enddateymd': end_date.strftime('%Y-%m-%d')
|
|
})
|
|
|
|
if not data.get('activities'):
|
|
print("[]")
|
|
return
|
|
|
|
activities = []
|
|
for a in data['activities']:
|
|
activities.append({
|
|
'date': a['date'],
|
|
'steps': a.get('steps', 0),
|
|
'distance': f"{(a.get('distance', 0) / 1000):.2f} km",
|
|
'calories': a.get('calories', 0),
|
|
'active_calories': a.get('active_calories', 0),
|
|
'soft_activity': f"{a.get('soft', 0)} min",
|
|
'moderate_activity': f"{a.get('moderate', 0)} min",
|
|
'intense_activity': f"{a.get('intense', 0)} min"
|
|
})
|
|
|
|
print(json.dumps(list(reversed(activities)), indent=2))
|
|
|
|
|
|
def get_sleep(user_id='default', days=7):
|
|
"""Fetch sleep data."""
|
|
token = get_valid_token(user_id)
|
|
end_date = datetime.now()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
data = post_request('/v2/sleep', {
|
|
'action': 'getsummary',
|
|
'access_token': token,
|
|
'startdateymd': start_date.strftime('%Y-%m-%d'),
|
|
'enddateymd': end_date.strftime('%Y-%m-%d')
|
|
})
|
|
|
|
if not data.get('series'):
|
|
print("[]")
|
|
return
|
|
|
|
def format_duration(seconds):
|
|
if not seconds:
|
|
return '0min'
|
|
h = seconds // 3600
|
|
m = (seconds % 3600) // 60
|
|
return f"{h}h {m}min" if h > 0 else f"{m}min"
|
|
|
|
def format_time(timestamp):
|
|
return datetime.fromtimestamp(timestamp).strftime('%H:%M')
|
|
|
|
sleep_data = []
|
|
for s in data['series']:
|
|
d = s.get('data', {})
|
|
sleep_data.append({
|
|
'date': s['date'],
|
|
'start': format_time(s['startdate']),
|
|
'end': format_time(s['enddate']),
|
|
'duration': format_duration(d.get('total_sleep_time') or d.get('durationtosleep', 0)),
|
|
'deep_sleep': format_duration(d.get('deepsleepduration', 0)),
|
|
'light_sleep': format_duration(d.get('lightsleepduration', 0)),
|
|
'rem_sleep': format_duration(d.get('remsleepduration', 0)),
|
|
'awake': format_duration(d.get('wakeupduration', 0)),
|
|
'sleep_score': d.get('sleep_score')
|
|
})
|
|
|
|
print(json.dumps(list(reversed(sleep_data)), indent=2))
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
|
|
# Parse: [userId] <command> [params...]
|
|
valid_commands = ['auth', 'weight', 'body', 'activity', 'sleep']
|
|
|
|
user_id = 'default'
|
|
command = args[0] if args else None
|
|
params = args[1:] if args else []
|
|
|
|
# If first arg is not a command, treat it as userId
|
|
if len(args) > 1 and args[0] not in valid_commands:
|
|
user_id = args[0]
|
|
command = args[1]
|
|
params = args[2:]
|
|
|
|
if command == 'auth':
|
|
auth(user_id, params[0] if params else None)
|
|
elif command == 'weight':
|
|
get_weight(user_id)
|
|
elif command == 'body':
|
|
limit = int(params[0]) if params and params[0] != 'all' else None
|
|
get_body(user_id, limit)
|
|
elif command == 'activity':
|
|
days = int(params[0]) if params else 7
|
|
get_activity(user_id, days)
|
|
elif command == 'sleep':
|
|
days = int(params[0]) if params else 7
|
|
get_sleep(user_id, days)
|
|
else:
|
|
print("Usage: python3 withings.py [userId] <command> [params]")
|
|
print("Commands: auth [code], weight, body [limit|all], activity [days], sleep [days]")
|
|
print("\nExamples:")
|
|
print(" python3 withings.py alice auth")
|
|
print(" python3 withings.py alice auth CODE_HERE")
|
|
print(" python3 withings.py bob weight")
|
|
print(" python3 withings.py charlie body all")
|
|
print(" python3 withings.py alice activity 30")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|