Add initial project

This commit is contained in:
2026-07-30 23:25:20 -04:00
commit cff6aa8289
164 changed files with 26672 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
"""Integrations module for external APIs."""
from backend.integrations.health_provider import (
HealthDataProvider,
DataSource,
HealthProviderFactory
)
# Import providers to register them with the factory
# from backend.integrations import google_fit_provider
from backend.integrations import health_connect_provider
__all__ = [
"HealthDataProvider",
"DataSource",
"HealthProviderFactory",
]
@@ -0,0 +1,162 @@
"""
Health Connect Provider - Implementation for Health Connect API.
This module provides the Health Connect implementation that reads data
pushed from the Android companion app via the /api/health-connect/sync endpoint.
"""
import datetime
from typing import Dict, Any, List, Optional
from backend.integrations.health_provider import (
HealthDataProvider,
DataSource,
HealthProviderFactory
)
class HealthConnectProvider(HealthDataProvider):
"""
Health Connect implementation of HealthDataProvider.
This provider reads data that has been pushed from an Android companion app
via the /api/health-connect/sync endpoint and stored in the database.
"""
@property
def source(self) -> DataSource:
return DataSource.HEALTH_CONNECT
def get_auth_url(self) -> str:
"""
Health Connect doesn't use OAuth - permissions are granted on-device.
Returns a URL to download the Android companion app.
"""
# TODO: Replace with actual Play Store URL when app is published
return "https://play.google.com/store/apps/details?id=com.habitforge.companion"
async def fetch_daily_metrics(
self,
access_token: Optional[str], # Not used for Health Connect, but required by interface
days: int = 7,
timezone: str = "UTC",
user_id: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Fetch daily metrics from Health Connect data store.
Reads data that was pushed from the Android companion app.
"""
if not user_id:
print("[HealthConnect] ERROR: user_id is required")
return []
print(f"[HealthConnect] Fetching daily metrics for user {user_id}, last {days} days")
from backend.database import SessionLocal
from backend.models.health_connect import HealthConnectDailyMetrics
from datetime import timedelta, date as date_type
db = SessionLocal()
try:
start_date = date_type.today() - timedelta(days=days)
metrics = db.query(HealthConnectDailyMetrics).filter(
HealthConnectDailyMetrics.user_id == user_id,
HealthConnectDailyMetrics.date >= start_date
).order_by(HealthConnectDailyMetrics.date.desc()).all()
if not metrics:
print(f"[HealthConnect] No data found. Ensure Android app has synced data.")
return []
print(f"[HealthConnect] Found {len(metrics)} days of data")
# Convert to standard format
return [
{
"date": m.date,
"step_count": m.step_count or 0,
"calories_burned": m.calories_burned or 0.0,
"distance_meters": m.distance_meters or 0.0,
"sleep_duration_minutes": m.sleep_duration_minutes or 0,
"deep_sleep_minutes": m.deep_sleep_minutes or 0,
"light_sleep_minutes": m.light_sleep_minutes or 0,
"rem_sleep_minutes": m.rem_sleep_minutes or 0,
"awake_duration_minutes": m.awake_duration_minutes or 0,
"avg_heart_rate": m.avg_heart_rate or 0,
"min_heart_rate": m.min_heart_rate or 0,
"max_heart_rate": m.max_heart_rate or 0,
"avg_spo2": m.avg_spo2 or 0.0,
"pai_score": 0.0, # Calculate if needed
"weight": m.weight,
"resting_heart_rate": m.resting_heart_rate,
"hrv": None
}
for m in metrics
]
finally:
db.close()
async def fetch_workouts(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch exercise sessions from Health Connect data store.
"""
# Note: We need user_id but the interface doesn't pass it for workouts
# This is a limitation we'll need to address in the interface
print(f"[HealthConnect] fetch_workouts called")
from backend.database import SessionLocal
from backend.models.health_connect import HealthConnectExerciseSession
from datetime import timedelta
db = SessionLocal()
try:
start_date = datetime.datetime.now() - timedelta(days=days)
# Query all sessions (we can't filter by user without user_id)
# In practice, this would be called with user context
sessions = db.query(HealthConnectExerciseSession).filter(
HealthConnectExerciseSession.start_time >= start_date
).order_by(HealthConnectExerciseSession.start_time.desc()).all()
return [
{
"start_time": s.start_time,
"end_time": s.end_time,
"activity_type": s.exercise_type_name,
"activity_type_id": s.exercise_type,
"duration_seconds": s.duration_seconds,
"distance_meters": s.distance_meters or 0.0,
"calories": s.calories_burned or 0.0,
"avg_hr": s.avg_heart_rate,
"max_hr": s.max_heart_rate,
"min_hr": s.min_heart_rate
}
for s in sessions
]
finally:
db.close()
async def fetch_sleep_sessions(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch sleep sessions from Health Connect data store.
Note: Health Connect stores sleep as part of daily metrics.
This returns an empty list as sleep is handled in fetch_daily_metrics.
"""
print(f"[HealthConnect] fetch_sleep_sessions called")
# Sleep data is included in daily metrics for Health Connect
return []
# Register the provider with the factory
HealthProviderFactory.register("health_connect", HealthConnectProvider)
+176
View File
@@ -0,0 +1,176 @@
"""
Health Data Provider - Abstract interface for health data sources.
This module provides an abstraction layer that allows switching between
different health data backends (Google Fit, Health Connect, etc.) without
changing the API layer.
"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional
from datetime import datetime
from enum import Enum
class DataSource(str, Enum):
"""Enum for tracking data provenance."""
HEALTH_CONNECT = "health_connect"
MANUAL = "manual"
class HealthDataProvider(ABC):
"""
Abstract base class for health data providers.
Implementations must provide methods to fetch daily metrics,
workout sessions, and sleep data from their respective sources.
"""
@property
@abstractmethod
def source(self) -> DataSource:
"""Return the data source identifier."""
pass
@abstractmethod
async def fetch_daily_metrics(
self,
access_token: Optional[str],
days: int = 7,
timezone: str = "UTC",
user_id: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Fetch aggregated daily health metrics.
Args:
access_token: OAuth token or API key
days: Number of days to fetch
timezone: User's timezone for day boundaries
user_id: Optional user ID for logging
Returns:
List of daily metric dictionaries with keys:
- date: date object
- step_count: int
- calories_burned: float
- distance_meters: float
- sleep_duration_minutes: int
- deep_sleep_minutes: int
- light_sleep_minutes: int
- rem_sleep_minutes: int
- awake_duration_minutes: int
- avg_heart_rate: int
- min_heart_rate: int
- max_heart_rate: int
- avg_spo2: float
- pai_score: float
"""
pass
@abstractmethod
async def fetch_workouts(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch workout sessions.
Args:
access_token: OAuth token or API key
days: Number of days to fetch
Returns:
List of workout dictionaries with keys:
- start_time: datetime
- end_time: datetime
- activity_type: str (human readable)
- activity_type_id: int (source-specific ID)
- duration_seconds: int
- distance_meters: float
- calories: float
- avg_hr: int
- max_hr: int
- min_hr: int
"""
pass
@abstractmethod
async def fetch_sleep_sessions(
self,
access_token: Optional[str],
days: int = 30
) -> List[Dict[str, Any]]:
"""
Fetch sleep sessions for total duration validation.
Args:
access_token: OAuth token or API key
days: Number of days to fetch
Returns:
List of sleep session dictionaries with keys:
- date: date object (wake-up date)
- duration_minutes: int
- start_time: datetime
- end_time: datetime
- type: str
"""
pass
@abstractmethod
def get_auth_url(self) -> str:
"""
Get the OAuth authorization URL for this provider.
Returns:
Authorization URL string
"""
pass
class HealthProviderFactory:
"""
Factory for creating health data provider instances.
Uses the HEALTH_PROVIDER environment variable to determine
which provider to instantiate.
"""
_providers: Dict[str, type] = {}
@classmethod
def register(cls, name: str, provider_class: type):
"""Register a provider class with a name."""
cls._providers[name] = provider_class
@classmethod
def get_provider(cls, name: Optional[str] = None) -> HealthDataProvider:
"""
Get a provider instance by name.
Args:
name: Provider name. If None, uses HEALTH_PROVIDER env var.
Returns:
HealthDataProvider instance
Raises:
ValueError: If provider name is not registered
"""
from backend.core.config import settings
provider_name = name or getattr(settings, 'HEALTH_PROVIDER', 'health_connect')
if provider_name not in cls._providers:
available = list(cls._providers.keys())
raise ValueError(
f"Unknown provider '{provider_name}'. Available: {available}"
)
return cls._providers[provider_name]()
@classmethod
def list_providers(cls) -> List[str]:
"""List all registered provider names."""
return list(cls._providers.keys())
+434
View File
@@ -0,0 +1,434 @@
"""
Zepp Data Import Module
Imports health data from Zepp/Mi Band/Amazfit export files into HabitForge database.
Supports: Activity, Sleep, Heart Rate, Body measurements, and Sport/Workouts.
"""
import csv
import glob
import os
import datetime
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
from backend.models import DailyHealthMetrics, User, WorkoutSession
from backend.models.workout_session import ZEPP_ACTIVITY_TYPES
from backend.database import SessionLocal, engine, Base
# --- Configuration ---
EXPORT_DIR = "export-zepp"
USER_ID = 1 # Default user ID since we are single user for now or running locally
def parse_date(date_str):
"""Parse YYYY-MM-DD date string."""
if not date_str:
return None
try:
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return None
def parse_datetime(dt_str):
"""Parse datetime string with timezone (e.g., 2025-12-28 18:25:54+0000)."""
if not dt_str:
return None
try:
# Remove timezone for simplicity
clean_str = dt_str.split('+')[0].split('-0')[0]
return datetime.datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def get_latest_export_dir(base_dir):
"""Find the numbered directory inside export-zepp."""
subdirs = glob.glob(os.path.join(base_dir, "*"))
if not subdirs:
return None
# Return the most recent one (sorted by name, which includes timestamp)
return sorted(subdirs)[-1]
def import_activity(session, export_path):
"""Import daily activity summary (steps, distance, calories)."""
activity_dir = os.path.join(export_path, "ACTIVITY")
csv_files = glob.glob(os.path.join(activity_dir, "*.csv"))
if not csv_files:
print("No Activity CSV found.")
return
count = 0
for csv_file in csv_files:
print(f"Processing Activity: {csv_file}")
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
d_date = parse_date(row.get('date'))
if not d_date:
continue
steps = int(row.get('steps', 0))
distance = float(row.get('distance', 0))
calories = float(row.get('calories', 0))
# Upsert
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
# Overwrite with Zepp data as it's authoritative
if steps > 0:
metric.step_count = steps
if distance > 0:
metric.distance_meters = distance
if calories > 0:
metric.calories_burned = calories
count += 1
session.commit()
print(f" Imported {count} activity records.")
def import_sleep(session, export_path):
"""Import sleep data with phases (deep, light, REM, awake)."""
sleep_dir = os.path.join(export_path, "SLEEP")
csv_files = glob.glob(os.path.join(sleep_dir, "*.csv"))
count = 0
for csv_file in csv_files:
print(f"Processing Sleep: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
d_date = parse_date(row.get('date'))
if not d_date:
continue
try:
deep = int(row.get('deepSleepTime', 0))
light = int(row.get('shallowSleepTime', 0))
wake = int(row.get('wakeTime', 0))
rem = int(row.get('REMTime', 0))
except ValueError:
continue
total_sleep = deep + light + rem
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
if total_sleep > 0:
metric.sleep_duration_minutes = total_sleep
metric.deep_sleep_minutes = deep
metric.light_sleep_minutes = light
metric.rem_sleep_minutes = rem
metric.awake_duration_minutes = wake
count += 1
session.commit()
print(f" Imported {count} sleep records.")
def import_heart_rate(session, export_path):
"""Import manual heart rate readings."""
hr_dir = os.path.join(export_path, "HEARTRATE")
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
daily_hr = {} # date -> [hr_values]
for csv_file in csv_files:
print(f"Processing Heart Rate: {csv_file}")
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
ts_str = row.get('time')
hr_val = int(row.get('heartRate', 0))
if not ts_str or hr_val <= 0:
continue
try:
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
d_date = dt.date()
if d_date not in daily_hr:
daily_hr[d_date] = []
daily_hr[d_date].append(hr_val)
except Exception:
pass
# Aggregate
for d_date, values in daily_hr.items():
if not values:
continue
avg_hr = sum(values) // len(values)
min_hr = min(values)
max_hr = max(values)
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.avg_heart_rate = avg_hr
metric.min_heart_rate = min_hr
metric.max_heart_rate = max_hr
session.commit()
print(f" Imported heart rate data for {len(daily_hr)} days.")
def import_heart_rate_auto(session, export_path):
"""Import automatic heart rate monitoring data."""
hr_dir = os.path.join(export_path, "HEARTRATE_AUTO")
csv_files = glob.glob(os.path.join(hr_dir, "*.csv"))
if not csv_files:
print("No HEARTRATE_AUTO CSV found.")
return
daily_hr = {}
for csv_file in csv_files:
print(f"Processing Heart Rate Auto: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
date_str = row.get('date')
hr_val = int(row.get('heartRate', 0))
if not date_str or hr_val <= 0:
continue
try:
d_date = parse_date(date_str)
if not d_date:
continue
if d_date not in daily_hr:
daily_hr[d_date] = []
daily_hr[d_date].append(hr_val)
except Exception:
pass
# Aggregate and Save
for d_date, values in daily_hr.items():
if not values:
continue
avg_hr = sum(values) // len(values)
min_hr = min(values)
max_hr = max(values)
rhr = min_hr # Resting HR approximation
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.avg_heart_rate = avg_hr
metric.min_heart_rate = min_hr
metric.max_heart_rate = max_hr
metric.resting_heart_rate = rhr
session.commit()
print(f" Imported auto heart rate data for {len(daily_hr)} days.")
def import_body(session, export_path):
"""Import body measurements (weight, etc.)."""
body_dir = os.path.join(export_path, "BODY")
csv_files = glob.glob(os.path.join(body_dir, "*.csv"))
count = 0
for csv_file in csv_files:
print(f"Processing Body: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
ts_str = row.get('time')
w_str = row.get('weight', '0')
weight = float(w_str)
if not ts_str or weight <= 0:
continue
try:
dt = datetime.datetime.strptime(ts_str.split('+')[0], "%Y-%m-%d %H:%M:%S")
d_date = dt.date()
metric = session.query(DailyHealthMetrics).filter_by(
user_id=USER_ID, date=d_date
).first()
if not metric:
metric = DailyHealthMetrics(user_id=USER_ID, date=d_date)
session.add(metric)
metric.weight = weight
count += 1
except Exception:
pass
session.commit()
print(f" Imported {count} body measurements.")
def import_sport(session, export_path):
"""Import sport/workout sessions."""
sport_dir = os.path.join(export_path, "SPORT")
csv_files = glob.glob(os.path.join(sport_dir, "*.csv"))
if not csv_files:
print("No SPORT CSV found.")
return
count = 0
for csv_file in csv_files:
print(f"Processing Sport: {csv_file}")
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
# Columns: type, startTime, sportTime(s), maxPace(/meter), minPace(/meter),
# distance(m), avgPace(/meter), calories(kcal)
for row in reader:
try:
type_id = int(row.get('type', 0))
start_time_str = row.get('startTime')
duration_seconds = int(row.get('sportTime(s)', 0))
distance = float(row.get('distance(m)', 0))
calories = float(row.get('calories(kcal)', 0))
max_pace = float(row.get('maxPace(/meter)', 0))
min_pace = float(row.get('minPace(/meter)', 0))
avg_pace = float(row.get('avgPace(/meter)', 0))
start_time = parse_datetime(start_time_str)
if not start_time:
continue
end_time = start_time + datetime.timedelta(seconds=duration_seconds)
activity_name = WorkoutSession.get_activity_name(type_id)
# Check if workout already exists (by start time and user)
existing = session.query(WorkoutSession).filter_by(
user_id=USER_ID, start_time=start_time
).first()
if existing:
# Update existing
existing.activity_type = activity_name
existing.activity_type_id = type_id
existing.duration_seconds = duration_seconds
existing.distance_meters = distance
existing.calories = calories
existing.avg_pace = avg_pace if avg_pace > 0 else None
existing.max_pace = max_pace if max_pace > 0 else None
existing.min_pace = min_pace if min_pace > 0 else None
else:
# Create new
workout = 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_seconds,
distance_meters=distance,
calories=calories,
avg_pace=avg_pace if avg_pace > 0 else None,
max_pace=max_pace if max_pace > 0 else None,
min_pace=min_pace if min_pace > 0 else None
)
session.add(workout)
count += 1
except Exception as e:
print(f" Error processing workout: {e}")
continue
session.commit()
print(f" Imported {count} workout sessions.")
def calculate_pai_scores(session):
"""Calculate PAI scores for all days based on activity and heart rate data."""
print("Calculating PAI scores...")
metrics = session.query(DailyHealthMetrics).filter_by(user_id=USER_ID).all()
for metric in metrics:
# Simple PAI calculation based on activity
# Real PAI requires continuous HR data, this is an approximation
pai = 0.0
# Base PAI from steps (rough approximation)
if metric.step_count:
pai += metric.step_count / 1000 # 1 PAI per 1000 steps
# Bonus from calories burned (beyond baseline)
if metric.calories_burned and metric.calories_burned > 0:
pai += metric.calories_burned / 50 # 1 PAI per 50 cal
# Bonus from elevated heart rate activities
if metric.avg_heart_rate and metric.avg_heart_rate > 100:
intensity_bonus = (metric.avg_heart_rate - 100) / 10
pai += intensity_bonus
metric.pai_score = round(min(pai, 100), 1) # Cap at 100
session.commit()
print(f" Updated PAI scores for {len(metrics)} days.")
def main():
"""Main import function."""
print("=" * 60)
print("Starting Zepp Data Import...")
print("=" * 60)
session = SessionLocal()
try:
base_export = get_latest_export_dir(EXPORT_DIR)
if not base_export:
print(f"No export directory found in {EXPORT_DIR}")
return
print(f"Using export directory: {base_export}\n")
# Import all data types
import_activity(session, base_export)
import_sleep(session, base_export)
import_heart_rate(session, base_export)
import_heart_rate_auto(session, base_export)
import_body(session, base_export)
import_sport(session, base_export)
# Calculate derived metrics
calculate_pai_scores(session)
print("\n" + "=" * 60)
print("Import completed successfully!")
print("=" * 60)
except Exception as e:
print(f"\nAn error occurred: {e}")
import traceback
traceback.print_exc()
finally:
session.close()
if __name__ == "__main__":
main()