368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""
|
|
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
|