""" Health Connect API - Endpoints for receiving data from Android companion app. These endpoints allow the Android companion app to push health data from Health Connect to the HabitForge backend. """ from fastapi import APIRouter, Depends, HTTPException, status, Header from sqlalchemy.orm import Session from typing import List, Optional from pydantic import BaseModel from datetime import datetime, date import hashlib import hmac import logging logger = logging.getLogger(__name__) from backend.database import get_db from backend.models.user import User from backend.models.health_connect import ( HealthConnectSyncLog, HealthConnectDailyMetrics, HealthConnectExerciseSession, HEALTH_CONNECT_EXERCISE_TYPES, ) from backend.models.daily_health_metrics import DailyHealthMetrics from backend.models.workout_session import WorkoutSession from backend import auth from backend.core.config import settings router = APIRouter(prefix="/api/health-connect", tags=["health-connect"]) # --- Request Models --- class DailyMetricsPayload(BaseModel): """Payload for daily health metrics from Health Connect.""" date: date step_count: int = 0 distance_meters: float = 0.0 calories_burned: float = 0.0 total_calories: float = 0.0 sleep_duration_minutes: int = 0 deep_sleep_minutes: int = 0 light_sleep_minutes: int = 0 rem_sleep_minutes: int = 0 awake_duration_minutes: int = 0 avg_heart_rate: Optional[int] = None min_heart_rate: Optional[int] = None max_heart_rate: Optional[int] = None resting_heart_rate: Optional[int] = None avg_spo2: Optional[float] = None min_spo2: Optional[float] = None max_spo2: Optional[float] = None weight: Optional[float] = None # Data source tracking data_source: Optional[str] = None # Package name (e.g., "com.huami.watch.hmwatchmanager") data_source_name: Optional[str] = None # Friendly name (e.g., "Zepp") class ExerciseSessionPayload(BaseModel): """Payload for exercise session from Health Connect.""" start_time: datetime end_time: datetime exercise_type: int # Health Connect ExerciseType enum value exercise_type_name: Optional[str] = None duration_seconds: int = 0 distance_meters: Optional[float] = None calories_burned: Optional[float] = None avg_heart_rate: Optional[int] = None max_heart_rate: Optional[int] = None min_heart_rate: Optional[int] = None steps: Optional[int] = None elevation_gained: Optional[float] = None notes: Optional[str] = None class HealthConnectSyncRequest(BaseModel): """Full sync request from Android companion app.""" device_id: str sync_timestamp: str = "" # Added for Android compatibility daily_metrics: List[DailyMetricsPayload] = [] exercise_sessions: List[ExerciseSessionPayload] = [] class SyncResponse(BaseModel): """Response for sync operation.""" status: str sync_id: int metrics_synced: int # Renamed from metrics_processed to match Android exercises_synced: int # Renamed from sessions_processed to match Android message: str # --- Endpoints --- @router.post("/sync", response_model=SyncResponse, status_code=status.HTTP_200_OK) async def receive_health_connect_data( request: HealthConnectSyncRequest, db: Session = Depends(get_db), current_user: User = Depends(auth.get_current_user) ): """ Receive health data pushed from Android companion app. This endpoint accepts data from Health Connect and stores it for later retrieval by the HealthConnectProvider. """ logger.info(f"=== Health Connect Sync Request from user {current_user.username} ===") logger.info(f"Device ID: {request.device_id}") logger.info(f"Timestamp: {request.sync_timestamp}") logger.info(f"Daily metrics count: {len(request.daily_metrics)}") logger.info(f"Exercise sessions count: {len(request.exercise_sessions)}") # Log sample data for debugging if request.daily_metrics: sample = request.daily_metrics[0] logger.info(f"Sample metric - Date: {sample.date}, Steps: {sample.step_count}, Calories: {sample.calories_burned}") if request.exercise_sessions: sample = request.exercise_sessions[0] logger.info(f"Sample exercise - Type: {sample.exercise_type_name}, Duration: {sample.duration_seconds}s") # Create sync log entry sync_log = HealthConnectSyncLog( user_id=current_user.id, device_id=request.device_id, records_pushed=len(request.daily_metrics) + len(request.exercise_sessions), status="processing" ) db.add(sync_log) db.flush() # Get the sync_log.id metrics_synced = 0 exercises_synced = 0 try: # Process daily metrics for metrics in request.daily_metrics: # Upsert: update if exists, insert if not existing = db.query(HealthConnectDailyMetrics).filter( HealthConnectDailyMetrics.user_id == current_user.id, HealthConnectDailyMetrics.date == metrics.date ).first() if existing: # Update existing record for field, value in metrics.model_dump().items(): if value is not None: setattr(existing, field, value) existing.last_synced = datetime.utcnow() existing.sync_id = sync_log.id logger.info(f"Updated existing metric for date {metrics.date}") else: # Create new record new_metrics = HealthConnectDailyMetrics( user_id=current_user.id, sync_id=sync_log.id, **metrics.model_dump() ) db.add(new_metrics) logger.info(f"Created new metric for date {metrics.date}") metrics_synced += 1 # --- AUTO-SYNC to Main DailyHealthMetrics Table --- try: main_existing = db.query(DailyHealthMetrics).filter( DailyHealthMetrics.user_id == current_user.id, DailyHealthMetrics.date == metrics.date ).first() # Filter fields that exist in DailyHealthMetrics metric_data = metrics.model_dump() # Manual Mapping / Cleanup # DailyMetricsPayload has snake_case keys which match DailyHealthMetrics mostly # Exceptions: # - DailyHealthMetrics has no 'data_source_name' # - DailyHealthMetrics has 'data_source' (string) update_data = {} for key, value in metric_data.items(): if value is not None and hasattr(DailyHealthMetrics, key): update_data[key] = value # Explicitly set data source update_data["data_source"] = "health_connect" if main_existing: # Update if value is not None (and maybe preferential logic?) # For now, we trust the push as latest source of truth for key, value in update_data.items(): setattr(main_existing, key, value) logger.info(f"Auto-synced main health metric for {metrics.date}") else: new_main = DailyHealthMetrics( user_id=current_user.id, **update_data ) db.add(new_main) logger.info(f"Created main health metric for {metrics.date}") except Exception as e: logger.error(f"Failed to auto-sync daily metric to main table: {e}") # Don't fail the request, just log # Process exercise sessions for session in request.exercise_sessions: # Dedup by start_time existing = db.query(HealthConnectExerciseSession).filter( HealthConnectExerciseSession.user_id == current_user.id, HealthConnectExerciseSession.start_time == session.start_time ).first() if not existing: # Get exercise type name if not provided exercise_name = session.exercise_type_name or HEALTH_CONNECT_EXERCISE_TYPES.get( session.exercise_type, f"Exercise {session.exercise_type}" ) new_session = HealthConnectExerciseSession( user_id=current_user.id, sync_id=sync_log.id, exercise_type_name=exercise_name, **session.model_dump(exclude={"exercise_type_name"}) ) db.add(new_session) exercises_synced += 1 logger.info(f"Created new exercise session: {exercise_name}") # --- AUTO-SYNC to Main WorkoutSession Table --- try: # Check existence in main table (simple check by start time) main_workout_exists = db.query(WorkoutSession).filter( WorkoutSession.user_id == current_user.id, WorkoutSession.start_time == session.start_time ).first() if not main_workout_exists: # key mapping sess_data = session.model_dump(exclude={"exercise_type_name"}) # WorkoutSession fields: # start_time, end_time, activity_type (str), activity_type_id (int), # duration_seconds, distance_meters, calories (not calories_burned), # avg_hr, max_hr, min_hr, notes new_workout = WorkoutSession( user_id=current_user.id, start_time=session.start_time, end_time=session.end_time, activity_type=exercise_name, activity_type_id=session.exercise_type, duration_seconds=session.duration_seconds, distance_meters=session.distance_meters or 0, calories=session.calories_burned or 0, avg_hr=session.avg_heart_rate, max_hr=session.max_heart_rate, min_hr=session.min_heart_rate, notes=session.notes, data_source="health_connect" ) db.add(new_workout) logger.info(f"Auto-synced workout session: {exercise_name}") except Exception as e: logger.error(f"Failed to auto-sync workout to main table: {e}") else: logger.info(f"Skipped duplicate exercise session at {session.start_time}") # Update sync log sync_log.status = "success" sync_log.records_pushed = metrics_synced + exercises_synced db.commit() logger.info(f"=== Sync Complete: {metrics_synced} metrics, {exercises_synced} exercises ===") return SyncResponse( status="success", sync_id=sync_log.id, metrics_synced=metrics_synced, exercises_synced=exercises_synced, message=f"Successfully synced {metrics_synced} daily records and {exercises_synced} exercise sessions" ) except Exception as e: logger.error(f"Sync failed: {str(e)}", exc_info=True) sync_log.status = "error" sync_log.error_message = str(e) db.commit() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Sync failed: {str(e)}" ) @router.get("/status", status_code=status.HTTP_200_OK) async def get_sync_status( db: Session = Depends(get_db), current_user: User = Depends(auth.get_current_user) ): """Get the status of Health Connect sync for current user.""" # Get latest sync latest_sync = db.query(HealthConnectSyncLog).filter( HealthConnectSyncLog.user_id == current_user.id ).order_by(HealthConnectSyncLog.sync_timestamp.desc()).first() # Count records metrics_count = db.query(HealthConnectDailyMetrics).filter( HealthConnectDailyMetrics.user_id == current_user.id ).count() sessions_count = db.query(HealthConnectExerciseSession).filter( HealthConnectExerciseSession.user_id == current_user.id ).count() return { "has_data": metrics_count > 0 or sessions_count > 0, "metrics_count": metrics_count, "sessions_count": sessions_count, "last_sync": latest_sync.sync_timestamp if latest_sync else None, "last_sync_status": latest_sync.status if latest_sync else None, "last_device_id": latest_sync.device_id if latest_sync else None } @router.get("/metrics", status_code=status.HTTP_200_OK) async def get_health_connect_metrics( days: int = 30, db: Session = Depends(get_db), current_user: User = Depends(auth.get_current_user) ): """Get Health Connect daily metrics for current user.""" from datetime import timedelta start_date = date.today() - timedelta(days=days) metrics = db.query(HealthConnectDailyMetrics).filter( HealthConnectDailyMetrics.user_id == current_user.id, HealthConnectDailyMetrics.date >= start_date ).order_by(HealthConnectDailyMetrics.date.desc()).all() return [ { "date": m.date.isoformat(), "step_count": m.step_count, "distance_meters": m.distance_meters, "calories_burned": m.calories_burned, "sleep_duration_minutes": m.sleep_duration_minutes, "deep_sleep_minutes": m.deep_sleep_minutes, "light_sleep_minutes": m.light_sleep_minutes, "rem_sleep_minutes": m.rem_sleep_minutes, "avg_heart_rate": m.avg_heart_rate, "avg_spo2": m.avg_spo2, "weight": m.weight, "data_source": m.data_source, "data_source_name": m.data_source_name } for m in metrics ]