""" 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