Files
HabitForge/backend/api/v1/health_connect.py
T
2026-07-30 23:25:20 -04:00

364 lines
14 KiB
Python

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