Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Zepp API Router - Endpoints for Zepp/Amazfit data synchronization.
|
||||
|
||||
Provides endpoints for connecting to Zepp account and syncing health data.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import DailyHealthMetrics, User, WorkoutSession
|
||||
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
|
||||
from backend.integrations.zepp_api import ZeppApiClient, ZeppApiError, sync_zepp_data
|
||||
from backend import auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/zepp", tags=["zepp"])
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
class ZeppConnectRequest(BaseModel):
|
||||
"""Request to connect a Zepp account."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class ZeppConnectResponse(BaseModel):
|
||||
"""Response after connecting Zepp account."""
|
||||
status: str
|
||||
user_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class ZeppSyncRequest(BaseModel):
|
||||
"""Request to sync data from Zepp."""
|
||||
days: int = 30
|
||||
|
||||
|
||||
class ZeppSyncResponse(BaseModel):
|
||||
"""Response after syncing Zepp data."""
|
||||
status: str
|
||||
days_synced: int
|
||||
workouts_synced: int
|
||||
message: str
|
||||
|
||||
|
||||
class ZeppStatusResponse(BaseModel):
|
||||
"""Response with Zepp connection status."""
|
||||
connected: bool
|
||||
zepp_user_id: Optional[str] = None
|
||||
last_sync: Optional[datetime] = None
|
||||
|
||||
|
||||
# --- In-memory token storage (per user) ---
|
||||
# In production, this should be stored in the database
|
||||
_zepp_clients: dict[int, ZeppApiClient] = {}
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/connect", response_model=ZeppConnectResponse)
|
||||
async def connect_zepp(
|
||||
request: ZeppConnectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Connect user's Zepp account.
|
||||
|
||||
Authenticates with Huami servers and stores the session for data syncing.
|
||||
"""
|
||||
try:
|
||||
client = ZeppApiClient(request.email, request.password)
|
||||
auth_result = client.authenticate()
|
||||
|
||||
# Store client for this user
|
||||
_zepp_clients[current_user.id] = client
|
||||
|
||||
logger.info(f"User {current_user.id} connected Zepp account: {auth_result['user_id']}")
|
||||
|
||||
return ZeppConnectResponse(
|
||||
status="success",
|
||||
user_id=auth_result["user_id"],
|
||||
message="Successfully connected to Zepp account"
|
||||
)
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.warning(f"Zepp connection failed for user {current_user.id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error connecting Zepp: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to connect to Zepp"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=ZeppSyncResponse)
|
||||
async def sync_zepp(
|
||||
request: ZeppSyncRequest = ZeppSyncRequest(),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
Sync health data from connected Zepp account.
|
||||
|
||||
Fetches daily metrics (steps, sleep, heart rate, SpO2) and workout sessions.
|
||||
"""
|
||||
# Check if user has connected their Zepp account
|
||||
client = _zepp_clients.get(current_user.id)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Zepp account not connected. Please connect first."
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch data from Zepp
|
||||
sync_result = sync_zepp_data(client, days=request.days)
|
||||
|
||||
# Save daily metrics to database
|
||||
metrics_saved = 0
|
||||
for entry in sync_result.get("daily_metrics", []):
|
||||
saved = _save_daily_metrics(db, current_user.id, entry)
|
||||
if saved:
|
||||
metrics_saved += 1
|
||||
|
||||
# Save workouts to database
|
||||
workouts_saved = 0
|
||||
for workout in sync_result.get("workouts", []):
|
||||
saved = _save_workout(db, current_user.id, workout)
|
||||
if saved:
|
||||
workouts_saved += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.id} synced {metrics_saved} days, {workouts_saved} workouts from Zepp"
|
||||
)
|
||||
|
||||
return ZeppSyncResponse(
|
||||
status="success",
|
||||
days_synced=metrics_saved,
|
||||
workouts_synced=workouts_saved,
|
||||
message=f"Synced {metrics_saved} days of data and {workouts_saved} workouts"
|
||||
)
|
||||
|
||||
except ZeppApiError as e:
|
||||
logger.error(f"Zepp sync failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Zepp API error: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Zepp sync: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to sync data from Zepp"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=ZeppStatusResponse)
|
||||
async def get_zepp_status(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Get the current Zepp connection status."""
|
||||
client = _zepp_clients.get(current_user.id)
|
||||
|
||||
if client and client.user_id:
|
||||
# Get last sync time from database
|
||||
last_metric = db.query(DailyHealthMetrics).filter_by(
|
||||
user_id=current_user.id
|
||||
).order_by(DailyHealthMetrics.date.desc()).first()
|
||||
|
||||
return ZeppStatusResponse(
|
||||
connected=True,
|
||||
zepp_user_id=client.user_id,
|
||||
last_sync=last_metric.date if last_metric else None
|
||||
)
|
||||
|
||||
return ZeppStatusResponse(connected=False)
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_zepp(
|
||||
current_user: User = Depends(auth.get_current_user)
|
||||
):
|
||||
"""Disconnect Zepp account."""
|
||||
if current_user.id in _zepp_clients:
|
||||
del _zepp_clients[current_user.id]
|
||||
logger.info(f"User {current_user.id} disconnected Zepp account")
|
||||
|
||||
return {"status": "success", "message": "Zepp account disconnected"}
|
||||
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def _save_daily_metrics(db: Session, user_id: int, entry: dict) -> bool:
|
||||
"""Save or update daily health metrics from Zepp data."""
|
||||
try:
|
||||
date_str = entry.get("date")
|
||||
if not date_str:
|
||||
return False
|
||||
|
||||
# Parse date
|
||||
if isinstance(date_str, str):
|
||||
d_date = datetime.strptime(date_str[:10], "%Y-%m-%d").date()
|
||||
else:
|
||||
d_date = date_str
|
||||
|
||||
# Find or create metric record
|
||||
metric = db.query(DailyHealthMetrics).filter_by(
|
||||
user_id=user_id, date=d_date
|
||||
).first()
|
||||
|
||||
if not metric:
|
||||
metric = DailyHealthMetrics(user_id=user_id, date=d_date)
|
||||
db.add(metric)
|
||||
|
||||
# Update activity metrics
|
||||
if entry.get("steps"):
|
||||
metric.step_count = entry["steps"]
|
||||
if entry.get("distance"):
|
||||
metric.distance_meters = entry["distance"]
|
||||
if entry.get("calories"):
|
||||
metric.calories_burned = entry["calories"]
|
||||
|
||||
# Update sleep metrics
|
||||
sleep = entry.get("sleep", {})
|
||||
if sleep:
|
||||
if sleep.get("total_minutes"):
|
||||
metric.sleep_duration_minutes = sleep["total_minutes"]
|
||||
if sleep.get("deep_sleep_minutes"):
|
||||
metric.deep_sleep_minutes = sleep["deep_sleep_minutes"]
|
||||
if sleep.get("light_sleep_minutes"):
|
||||
metric.light_sleep_minutes = sleep["light_sleep_minutes"]
|
||||
if sleep.get("rem_sleep_minutes"):
|
||||
metric.rem_sleep_minutes = sleep["rem_sleep_minutes"]
|
||||
if sleep.get("awake_minutes"):
|
||||
metric.awake_duration_minutes = sleep["awake_minutes"]
|
||||
|
||||
# Update heart rate metrics
|
||||
hr = entry.get("heart_rate", {})
|
||||
if hr:
|
||||
if hr.get("avg"):
|
||||
metric.avg_heart_rate = hr["avg"]
|
||||
if hr.get("min"):
|
||||
metric.min_heart_rate = hr["min"]
|
||||
if hr.get("max"):
|
||||
metric.max_heart_rate = hr["max"]
|
||||
if hr.get("resting"):
|
||||
metric.resting_heart_rate = hr["resting"]
|
||||
|
||||
# Update SpO2 metrics
|
||||
spo2 = entry.get("spo2", {})
|
||||
if spo2:
|
||||
if spo2.get("avg"):
|
||||
metric.avg_spo2 = spo2["avg"]
|
||||
if spo2.get("min"):
|
||||
metric.min_spo2 = spo2["min"]
|
||||
if spo2.get("max"):
|
||||
metric.max_spo2 = spo2["max"]
|
||||
|
||||
# Calculate simple PAI score
|
||||
pai = 0.0
|
||||
if metric.step_count:
|
||||
pai += metric.step_count / 1000
|
||||
if metric.calories_burned and metric.calories_burned > 0:
|
||||
pai += metric.calories_burned / 50
|
||||
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
|
||||
pai += (metric.avg_heart_rate - 100) / 10
|
||||
metric.pai_score = round(min(pai, 100), 1)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving daily metrics: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_workout(db: Session, user_id: int, workout: dict) -> bool:
|
||||
"""Save or update workout session from Zepp data."""
|
||||
try:
|
||||
# Extract workout data
|
||||
start_time_str = workout.get("startTime") or workout.get("start_time")
|
||||
if not start_time_str:
|
||||
return False
|
||||
|
||||
# Parse start time
|
||||
if isinstance(start_time_str, str):
|
||||
# Handle various formats
|
||||
start_time_str = start_time_str.split("+")[0].split("-0")[0]
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
start_time = datetime.strptime(start_time_str[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
else:
|
||||
start_time = start_time_str
|
||||
|
||||
# Check if workout already exists
|
||||
existing = db.query(WorkoutSession).filter_by(
|
||||
user_id=user_id, start_time=start_time
|
||||
).first()
|
||||
|
||||
# Get workout type
|
||||
type_id = int(workout.get("type", 0))
|
||||
activity_name = ZEPP_ACTIVITY_TYPES.get(type_id, "Unknown")
|
||||
|
||||
# Duration
|
||||
duration = int(workout.get("sportTime(s)", workout.get("duration", 0)))
|
||||
|
||||
# End time
|
||||
end_time = start_time + timedelta(seconds=duration)
|
||||
|
||||
# Distance and calories
|
||||
distance = float(workout.get("distance(m)", workout.get("distance", 0)))
|
||||
calories = float(workout.get("calories(kcal)", workout.get("calories", 0)))
|
||||
|
||||
# Pace metrics (convert from /meter to min/km)
|
||||
avg_pace = float(workout.get("avgPace(/meter)", workout.get("avg_pace", 0)))
|
||||
min_pace = float(workout.get("minPace(/meter)", workout.get("min_pace", 0)))
|
||||
max_pace = float(workout.get("maxPace(/meter)", workout.get("max_pace", 0)))
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.activity_type = activity_name
|
||||
existing.activity_type_id = type_id
|
||||
existing.duration_seconds = duration
|
||||
existing.distance_meters = distance
|
||||
existing.calories = calories
|
||||
existing.avg_pace = avg_pace if avg_pace > 0 else None
|
||||
existing.min_pace = min_pace if min_pace > 0 else None
|
||||
existing.max_pace = max_pace if max_pace > 0 else None
|
||||
else:
|
||||
# Create new
|
||||
workout_session = 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,
|
||||
distance_meters=distance,
|
||||
calories=calories,
|
||||
avg_pace=avg_pace if avg_pace > 0 else None,
|
||||
min_pace=min_pace if min_pace > 0 else None,
|
||||
max_pace=max_pace if max_pace > 0 else None,
|
||||
)
|
||||
db.add(workout_session)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving workout: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user