Files

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