364 lines
14 KiB
Python
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
|
|
]
|