Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""Integrations module for external APIs."""
|
||||
from backend.integrations.zepp_api import (
|
||||
ZeppApiClient,
|
||||
ZeppApiError,
|
||||
create_zepp_client,
|
||||
sync_zepp_data
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ZeppApiClient",
|
||||
"ZeppApiError",
|
||||
"create_zepp_client",
|
||||
"sync_zepp_data",
|
||||
]
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Zepp Data Import Module
|
||||
|
||||
Imports health data from Zepp/Mi Band/Amazfit export files into HabitForge database.
|
||||
Supports: Activity, Sleep, Heart Rate, Body measurements, and Sport/Workouts.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models import DailyHealthMetrics, User, WorkoutSession
|
||||
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
|
||||
from backend.database import SessionLocal, engine, Base
|
||||
|
||||
# --- Configuration ---
|
||||
EXPORT_DIR = "export-zepp"
|
||||
USER_ID = 1 # Default user ID since we are single user for now or running locally
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Parse YYYY-MM-DD date string."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Parse datetime string with timezone (e.g., 2025-12-28 18:25:54+0000)."""
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
# Remove timezone for simplicity
|
||||
clean_str = dt_str.split('+')[0].split('-0')[0]
|
||||
return datetime.datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_export_dir(base_dir):
|
||||
"""Find the numbered directory inside export-zepp."""
|
||||
subdirs = glob.glob(os.path.join(base_dir, "*"))
|
||||
if not subdirs:
|
||||
return None
|
||||
# Return the most recent one (sorted by name, which includes timestamp)
|
||||
return sorted(subdirs)[-1]
|
||||
|
||||
|
||||
def import_activity(session, export_path):
|
||||
"""Import daily activity summary (steps, distance, calories)."""
|
||||
activity_dir = os.path.join(export_path, "ACTIVITY")
|
||||
csv_files = glob.glob(os.path.join(activity_dir, "*.csv"))
|
||||
if not csv_files:
|
||||
print("No Activity CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Activity: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
steps = int(row.get('steps', 0))
|
||||
distance = float(row.get('distance', 0))
|
||||
calories = float(row.get('calories', 0))
|
||||
|
||||
# Upsert
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
# Overwrite with Zepp data as it's authoritative
|
||||
if steps > 0:
|
||||
metric.step_count = steps
|
||||
if distance > 0:
|
||||
metric.distance_meters = distance
|
||||
if calories > 0:
|
||||
metric.calories_burned = calories
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} activity records.")
|
||||
|
||||
|
||||
def import_sleep(session, export_path):
|
||||
"""Import sleep data with phases (deep, light, REM, awake)."""
|
||||
sleep_dir = os.path.join(export_path, "SLEEP")
|
||||
csv_files = glob.glob(os.path.join(sleep_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sleep: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
d_date = parse_date(row.get('date'))
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
try:
|
||||
deep = int(row.get('deepSleepTime', 0))
|
||||
light = int(row.get('shallowSleepTime', 0))
|
||||
wake = int(row.get('wakeTime', 0))
|
||||
rem = int(row.get('REMTime', 0))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
total_sleep = deep + light + rem
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
if total_sleep > 0:
|
||||
metric.sleep_duration_minutes = total_sleep
|
||||
metric.deep_sleep_minutes = deep
|
||||
metric.light_sleep_minutes = light
|
||||
metric.rem_sleep_minutes = rem
|
||||
metric.awake_duration_minutes = wake
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} sleep records.")
|
||||
|
||||
|
||||
def import_heart_rate(session, export_path):
|
||||
"""Import manual heart rate readings."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
daily_hr = {} # date -> [hr_values]
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not ts_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_heart_rate_auto(session, export_path):
|
||||
"""Import automatic heart rate monitoring data."""
|
||||
hr_dir = os.path.join(export_path, "HEARTRATE_AUTO")
|
||||
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No HEARTRATE_AUTO CSV found.")
|
||||
return
|
||||
|
||||
daily_hr = {}
|
||||
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Heart Rate Auto: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
date_str = row.get('date')
|
||||
hr_val = int(row.get('heartRate', 0))
|
||||
|
||||
if not date_str or hr_val <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
d_date = parse_date(date_str)
|
||||
if not d_date:
|
||||
continue
|
||||
|
||||
if d_date not in daily_hr:
|
||||
daily_hr[d_date] = []
|
||||
daily_hr[d_date].append(hr_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate and Save
|
||||
for d_date, values in daily_hr.items():
|
||||
if not values:
|
||||
continue
|
||||
|
||||
avg_hr = sum(values) // len(values)
|
||||
min_hr = min(values)
|
||||
max_hr = max(values)
|
||||
rhr = min_hr # Resting HR approximation
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.avg_heart_rate = avg_hr
|
||||
metric.min_heart_rate = min_hr
|
||||
metric.max_heart_rate = max_hr
|
||||
metric.resting_heart_rate = rhr
|
||||
|
||||
session.commit()
|
||||
print(f" Imported auto heart rate data for {len(daily_hr)} days.")
|
||||
|
||||
|
||||
def import_body(session, export_path):
|
||||
"""Import body measurements (weight, etc.)."""
|
||||
body_dir = os.path.join(export_path, "BODY")
|
||||
csv_files = glob.glob(os.path.join(body_dir, "*.csv"))
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Body: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
ts_str = row.get('time')
|
||||
w_str = row.get('weight', '0')
|
||||
weight = float(w_str)
|
||||
|
||||
if not ts_str or weight <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
|
||||
d_date = dt.date()
|
||||
|
||||
metric = session.query(DailyHealthMetrics).filter_by(
|
||||
user_id=USER_ID, date=d_date
|
||||
).first()
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
|
||||
session.add(metric)
|
||||
|
||||
metric.weight = weight
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} body measurements.")
|
||||
|
||||
|
||||
def import_sport(session, export_path):
|
||||
"""Import sport/workout sessions."""
|
||||
sport_dir = os.path.join(export_path, "SPORT")
|
||||
csv_files = glob.glob(os.path.join(sport_dir, "*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No SPORT CSV found.")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for csv_file in csv_files:
|
||||
print(f"Processing Sport: {csv_file}")
|
||||
with open(csv_file, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
# Columns: type, startTime, sportTime(s), maxPace(/meter), minPace(/meter),
|
||||
# distance(m), avgPace(/meter), calories(kcal)
|
||||
for row in reader:
|
||||
try:
|
||||
type_id = int(row.get('type', 0))
|
||||
start_time_str = row.get('startTime')
|
||||
duration_seconds = int(row.get('sportTime(s)', 0))
|
||||
distance = float(row.get('distance(m)', 0))
|
||||
calories = float(row.get('calories(kcal)', 0))
|
||||
max_pace = float(row.get('maxPace(/meter)', 0))
|
||||
min_pace = float(row.get('minPace(/meter)', 0))
|
||||
avg_pace = float(row.get('avgPace(/meter)', 0))
|
||||
|
||||
start_time = parse_datetime(start_time_str)
|
||||
if not start_time:
|
||||
continue
|
||||
|
||||
end_time = start_time + datetime.timedelta(seconds=duration_seconds)
|
||||
activity_name = WorkoutSession.get_activity_name(type_id)
|
||||
|
||||
# Check if workout already exists (by start time and user)
|
||||
existing = session.query(WorkoutSession).filter_by(
|
||||
user_id=USER_ID, start_time=start_time
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.activity_type = activity_name
|
||||
existing.activity_type_id = type_id
|
||||
existing.duration_seconds = duration_seconds
|
||||
existing.distance_meters = distance
|
||||
existing.calories = calories
|
||||
existing.avg_pace = avg_pace if avg_pace > 0 else None
|
||||
existing.max_pace = max_pace if max_pace > 0 else None
|
||||
existing.min_pace = min_pace if min_pace > 0 else None
|
||||
else:
|
||||
# Create new
|
||||
workout = WorkoutSession(
|
||||
user_id=USER_ID,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
activity_type=activity_name,
|
||||
activity_type_id=type_id,
|
||||
duration_seconds=duration_seconds,
|
||||
distance_meters=distance,
|
||||
calories=calories,
|
||||
avg_pace=avg_pace if avg_pace > 0 else None,
|
||||
max_pace=max_pace if max_pace > 0 else None,
|
||||
min_pace=min_pace if min_pace > 0 else None
|
||||
)
|
||||
session.add(workout)
|
||||
count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error processing workout: {e}")
|
||||
continue
|
||||
|
||||
session.commit()
|
||||
print(f" Imported {count} workout sessions.")
|
||||
|
||||
|
||||
def calculate_pai_scores(session):
|
||||
"""Calculate PAI scores for all days based on activity and heart rate data."""
|
||||
print("Calculating PAI scores...")
|
||||
|
||||
metrics = session.query(DailyHealthMetrics).filter_by(user_id=USER_ID).all()
|
||||
|
||||
for metric in metrics:
|
||||
# Simple PAI calculation based on activity
|
||||
# Real PAI requires continuous HR data, this is an approximation
|
||||
pai = 0.0
|
||||
|
||||
# Base PAI from steps (rough approximation)
|
||||
if metric.step_count:
|
||||
pai += metric.step_count / 1000 # 1 PAI per 1000 steps
|
||||
|
||||
# Bonus from calories burned (beyond baseline)
|
||||
if metric.calories_burned and metric.calories_burned > 0:
|
||||
pai += metric.calories_burned / 50 # 1 PAI per 50 cal
|
||||
|
||||
# Bonus from elevated heart rate activities
|
||||
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
|
||||
intensity_bonus = (metric.avg_heart_rate - 100) / 10
|
||||
pai += intensity_bonus
|
||||
|
||||
metric.pai_score = round(min(pai, 100), 1) # Cap at 100
|
||||
|
||||
session.commit()
|
||||
print(f" Updated PAI scores for {len(metrics)} days.")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main import function."""
|
||||
print("=" * 60)
|
||||
print("Starting Zepp Data Import...")
|
||||
print("=" * 60)
|
||||
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
base_export = get_latest_export_dir(EXPORT_DIR)
|
||||
if not base_export:
|
||||
print(f"No export directory found in {EXPORT_DIR}")
|
||||
return
|
||||
|
||||
print(f"Using export directory: {base_export}\n")
|
||||
|
||||
# Import all data types
|
||||
import_activity(session, base_export)
|
||||
import_sleep(session, base_export)
|
||||
import_heart_rate(session, base_export)
|
||||
import_heart_rate_auto(session, base_export)
|
||||
import_body(session, base_export)
|
||||
import_sport(session, base_export)
|
||||
|
||||
# Calculate derived metrics
|
||||
calculate_pai_scores(session)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Import completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
Zepp/Huami API Client
|
||||
|
||||
Provides authentication and data fetching from the Zepp/Huami cloud API
|
||||
for Amazfit and Mi Band devices.
|
||||
|
||||
Based on the Huami API technical documentation:
|
||||
- Authentication: account.huami.com
|
||||
- Data: api-mifit-de2.zepp.com (EU GDPR compliant)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from backend.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Constants ---
|
||||
AUTH_URL = "https://account.huami.com/v2/client/login"
|
||||
API_BASE_EU = "https://api-mifit-de2.zepp.com"
|
||||
API_BASE_GLOBAL = "https://api-mifit.huami.com"
|
||||
|
||||
# App identification (mimics official Zepp app)
|
||||
APP_NAME = "com.huami.watch.hmwatchmanager"
|
||||
CLIENT_ID = "HuaMi"
|
||||
APP_VERSION = "6.9.3"
|
||||
|
||||
|
||||
class ZeppApiError(Exception):
|
||||
"""Custom exception for Zepp API errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ZeppApiClient:
|
||||
"""
|
||||
Client for interacting with the Zepp/Huami API.
|
||||
|
||||
Handles authentication, token management, and data fetching.
|
||||
"""
|
||||
|
||||
def __init__(self, email: str = None, password: str = None):
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.app_token: Optional[str] = None
|
||||
self.login_token: Optional[str] = None
|
||||
self.user_id: Optional[str] = None
|
||||
self.token_expiry: Optional[datetime.datetime] = None
|
||||
self.api_base = API_BASE_EU # Default to EU for GDPR
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
"User-Agent": f"Zepp/{APP_VERSION} (Android)",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
|
||||
def authenticate(self, email: str = None, password: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Authenticate with the Huami/Zepp API.
|
||||
|
||||
Args:
|
||||
email: Zepp account email
|
||||
password: Zepp account password
|
||||
|
||||
Returns:
|
||||
Dict containing user_id, app_token, and other session info
|
||||
"""
|
||||
email = email or self.email
|
||||
password = password or self.password
|
||||
|
||||
if not email or not password:
|
||||
raise ZeppApiError("Email and password are required")
|
||||
|
||||
# Generate unique device ID
|
||||
device_id = uuid.uuid4().hex[:16].upper()
|
||||
|
||||
# Prepare authentication request
|
||||
auth_data = {
|
||||
"grant_type": "access_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"app_name": APP_NAME,
|
||||
"app_version": APP_VERSION,
|
||||
"third_name": "huami",
|
||||
"device_id": device_id,
|
||||
"device_model": "android_phone",
|
||||
"allow_registration": "false",
|
||||
"dn": "api-user.huami.com,api-mifit-de2.zepp.com,app-analytics.huami.com",
|
||||
"country_code": "DE",
|
||||
"lang": "en",
|
||||
"state": "REDIRECTION",
|
||||
"redirect_uri": "https://s3-us-west-2.amazonws.com/hm-registration/successs498.html",
|
||||
}
|
||||
|
||||
# Add credentials
|
||||
auth_data["email"] = email
|
||||
auth_data["password"] = password
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._session.post(
|
||||
AUTH_URL,
|
||||
data=auth_data,
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
|
||||
if "error_code" in result:
|
||||
error_msg = result.get("message", "Authentication failed")
|
||||
raise ZeppApiError(f"Auth error: {error_msg}")
|
||||
|
||||
# Extract tokens
|
||||
token_info = result.get("token_info", {})
|
||||
self.app_token = token_info.get("app_token")
|
||||
self.login_token = token_info.get("login_token")
|
||||
self.user_id = token_info.get("user_id")
|
||||
|
||||
if not self.app_token or not self.user_id:
|
||||
raise ZeppApiError("Invalid authentication response: missing tokens")
|
||||
|
||||
# Store credentials for refresh
|
||||
self.email = email
|
||||
self.password = password
|
||||
|
||||
# Set token expiry (typically 30 days, be conservative with 7)
|
||||
self.token_expiry = datetime.datetime.now() + datetime.timedelta(days=7)
|
||||
|
||||
logger.info(f"Successfully authenticated Zepp user: {self.user_id}")
|
||||
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"app_token": self.app_token,
|
||||
"login_token": self.login_token,
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Zepp authentication request failed: {e}")
|
||||
raise ZeppApiError(f"Network error during authentication: {e}")
|
||||
|
||||
def _ensure_authenticated(self):
|
||||
"""Ensure we have valid tokens, re-authenticate if needed."""
|
||||
if not self.app_token or not self.user_id:
|
||||
if self.email and self.password:
|
||||
self.authenticate()
|
||||
else:
|
||||
raise ZeppApiError("Not authenticated. Call authenticate() first.")
|
||||
|
||||
# Check token expiry
|
||||
if self.token_expiry and datetime.datetime.now() > self.token_expiry:
|
||||
logger.info("Token expired, re-authenticating...")
|
||||
self.authenticate()
|
||||
|
||||
def _make_request(self, endpoint: str, params: Dict = None, method: str = "GET") -> Dict:
|
||||
"""
|
||||
Make an authenticated request to the Zepp API.
|
||||
"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
url = f"{self.api_base}{endpoint}"
|
||||
|
||||
headers = {
|
||||
"apptoken": self.app_token,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
response = self._session.get(url, params=params, headers=headers, timeout=30)
|
||||
else:
|
||||
response = self._session.post(url, data=params, headers=headers, timeout=30)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Zepp API request failed: {e}")
|
||||
raise ZeppApiError(f"API request failed: {e}")
|
||||
|
||||
def fetch_band_data(
|
||||
self,
|
||||
from_date: datetime.date,
|
||||
to_date: datetime.date,
|
||||
query_type: str = "summary"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch daily health data (steps, sleep, heart rate).
|
||||
|
||||
Args:
|
||||
from_date: Start date
|
||||
to_date: End date
|
||||
query_type: "summary" for daily totals, "detail" for minute-by-minute
|
||||
|
||||
Returns:
|
||||
Dict containing decoded health data
|
||||
"""
|
||||
params = {
|
||||
"query_type": query_type,
|
||||
"from_date": from_date.strftime("%Y-%m-%d"),
|
||||
"to_date": to_date.strftime("%Y-%m-%d"),
|
||||
"userid": self.user_id,
|
||||
"device_type": "android_phone",
|
||||
}
|
||||
|
||||
response = self._make_request("/v1/data/band_data.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
raise ZeppApiError(f"band_data request failed: {response.get('message')}")
|
||||
|
||||
# Decode the Base64 summary data
|
||||
data_list = response.get("data", [])
|
||||
decoded_data = []
|
||||
|
||||
for entry in data_list:
|
||||
decoded_entry = self._decode_band_data_entry(entry)
|
||||
if decoded_entry:
|
||||
decoded_data.append(decoded_entry)
|
||||
|
||||
return {"data": decoded_data, "raw_response": response}
|
||||
|
||||
def _decode_band_data_entry(self, entry: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
Decode a single band_data entry with Base64 encoded fields.
|
||||
"""
|
||||
try:
|
||||
date_str = entry.get("date_time", entry.get("date"))
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"date": date_str,
|
||||
"summary": {},
|
||||
}
|
||||
|
||||
# Decode summary field (Base64 JSON)
|
||||
summary_b64 = entry.get("summary")
|
||||
if summary_b64:
|
||||
try:
|
||||
summary_json = base64.b64decode(summary_b64).decode("utf-8")
|
||||
result["summary"] = json.loads(summary_json)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode summary: {e}")
|
||||
|
||||
# Extract steps data
|
||||
stp = result["summary"].get("stp", {})
|
||||
result["steps"] = stp.get("ttl", 0)
|
||||
result["distance"] = stp.get("dis", 0)
|
||||
result["calories"] = stp.get("cal", 0)
|
||||
|
||||
# Extract sleep data
|
||||
slp = result["summary"].get("slp", {})
|
||||
if slp:
|
||||
result["sleep"] = {
|
||||
"start_time": slp.get("st"),
|
||||
"end_time": slp.get("ed"),
|
||||
"deep_sleep_minutes": slp.get("dp", 0),
|
||||
"light_sleep_minutes": slp.get("lt", 0),
|
||||
"rem_sleep_minutes": slp.get("rm", 0),
|
||||
"awake_minutes": slp.get("wk", 0),
|
||||
}
|
||||
# Calculate total
|
||||
result["sleep"]["total_minutes"] = (
|
||||
result["sleep"]["deep_sleep_minutes"] +
|
||||
result["sleep"]["light_sleep_minutes"] +
|
||||
result["sleep"]["rem_sleep_minutes"]
|
||||
)
|
||||
|
||||
# Decode heart rate blob if present
|
||||
hr_blob = entry.get("data_hr")
|
||||
if hr_blob:
|
||||
hr_values = self._decode_heart_rate_blob(hr_blob)
|
||||
if hr_values:
|
||||
valid_hr = [v for v in hr_values if 30 < v < 220]
|
||||
if valid_hr:
|
||||
result["heart_rate"] = {
|
||||
"avg": sum(valid_hr) // len(valid_hr),
|
||||
"min": min(valid_hr),
|
||||
"max": max(valid_hr),
|
||||
"resting": min(valid_hr), # Approximation
|
||||
}
|
||||
|
||||
# Decode SpO2 if present
|
||||
spo2_blob = entry.get("data_spo2")
|
||||
if spo2_blob:
|
||||
spo2_values = self._decode_spo2_blob(spo2_blob)
|
||||
if spo2_values:
|
||||
valid_spo2 = [v for v in spo2_values if 70 < v <= 100]
|
||||
if valid_spo2:
|
||||
result["spo2"] = {
|
||||
"avg": sum(valid_spo2) / len(valid_spo2),
|
||||
"min": min(valid_spo2),
|
||||
"max": max(valid_spo2),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding band_data entry: {e}")
|
||||
return None
|
||||
|
||||
def _decode_heart_rate_blob(self, b64_data: str) -> List[int]:
|
||||
"""
|
||||
Decode Base64-encoded heart rate binary data.
|
||||
Format: sequence of 1-byte or 2-byte integers.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64_data)
|
||||
# Try single-byte format first (most common)
|
||||
values = list(raw)
|
||||
# Filter out invalid values (0, 255 are typically "no reading")
|
||||
return [v for v in values if v > 0 and v < 255]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode HR blob: {e}")
|
||||
return []
|
||||
|
||||
def _decode_spo2_blob(self, b64_data: str) -> List[float]:
|
||||
"""
|
||||
Decode Base64-encoded SpO2 binary data.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64_data)
|
||||
# SpO2 is typically stored as single bytes (percentage)
|
||||
values = list(raw)
|
||||
return [float(v) for v in values if v > 0]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode SpO2 blob: {e}")
|
||||
return []
|
||||
|
||||
def fetch_workout_history(self, days: int = 30) -> List[Dict]:
|
||||
"""
|
||||
Fetch workout/sport session history.
|
||||
|
||||
Args:
|
||||
days: Number of days of history to fetch
|
||||
|
||||
Returns:
|
||||
List of workout sessions
|
||||
"""
|
||||
from_date = datetime.date.today() - datetime.timedelta(days=days)
|
||||
|
||||
params = {
|
||||
"from_date": from_date.strftime("%Y-%m-%d"),
|
||||
"to_date": datetime.date.today().strftime("%Y-%m-%d"),
|
||||
"userid": self.user_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request("/v1/sport/run/history.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
logger.warning(f"Workout history request failed: {response}")
|
||||
return []
|
||||
|
||||
return response.get("data", {}).get("summary", [])
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Failed to fetch workout history: {e}")
|
||||
return []
|
||||
|
||||
def fetch_workout_detail(self, track_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch detailed workout data including GPS track.
|
||||
|
||||
Args:
|
||||
track_id: The workout track ID
|
||||
|
||||
Returns:
|
||||
Dict with workout details and GPS data
|
||||
"""
|
||||
params = {
|
||||
"trackid": track_id,
|
||||
"source": "run.mifit.huami.com",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request("/v1/sport/run/detail.json", params)
|
||||
|
||||
if response.get("code") != 1:
|
||||
return None
|
||||
|
||||
data = response.get("data", {})
|
||||
|
||||
# Parse GPS coordinates if present
|
||||
gps_str = data.get("longitude_latitude", "")
|
||||
if gps_str:
|
||||
data["gps_track"] = self._parse_gps_string(gps_str)
|
||||
|
||||
return data
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Failed to fetch workout detail {track_id}: {e}")
|
||||
return None
|
||||
|
||||
def _parse_gps_string(self, gps_str: str) -> List[Tuple[float, float]]:
|
||||
"""
|
||||
Parse Zepp's proprietary GPS format.
|
||||
Format: lat,lon;lat,lon;lat,lon...
|
||||
"""
|
||||
points = []
|
||||
try:
|
||||
for point_str in gps_str.split(";"):
|
||||
if "," in point_str:
|
||||
parts = point_str.split(",")
|
||||
if len(parts) >= 2:
|
||||
lat = float(parts[0])
|
||||
lon = float(parts[1])
|
||||
points.append((lat, lon))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse GPS string: {e}")
|
||||
return points
|
||||
|
||||
|
||||
# --- Convenience Functions ---
|
||||
|
||||
def create_zepp_client(email: str, password: str) -> ZeppApiClient:
|
||||
"""Create and authenticate a Zepp API client."""
|
||||
client = ZeppApiClient(email, password)
|
||||
client.authenticate()
|
||||
return client
|
||||
|
||||
|
||||
def sync_zepp_data(
|
||||
client: ZeppApiClient,
|
||||
days: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync all available data from Zepp.
|
||||
|
||||
Args:
|
||||
client: Authenticated ZeppApiClient
|
||||
days: Number of days to sync
|
||||
|
||||
Returns:
|
||||
Dict with synced data counts
|
||||
"""
|
||||
from_date = datetime.date.today() - datetime.timedelta(days=days)
|
||||
to_date = datetime.date.today()
|
||||
|
||||
results = {
|
||||
"daily_metrics": [],
|
||||
"workouts": [],
|
||||
"days_synced": 0,
|
||||
"workouts_synced": 0,
|
||||
}
|
||||
|
||||
# Fetch band data (daily metrics)
|
||||
try:
|
||||
band_data = client.fetch_band_data(from_date, to_date)
|
||||
results["daily_metrics"] = band_data.get("data", [])
|
||||
results["days_synced"] = len(results["daily_metrics"])
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Failed to fetch band data: {e}")
|
||||
|
||||
# Fetch workouts
|
||||
try:
|
||||
workouts = client.fetch_workout_history(days)
|
||||
results["workouts"] = workouts
|
||||
results["workouts_synced"] = len(workouts)
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Failed to fetch workouts: {e}")
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user