Add initial project
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
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
|
||||
]
|
||||
Reference in New Issue
Block a user