Initial commit: HabitForge - habit tracking application with FastAPI backend, React frontend, and Zepp integration

This commit is contained in:
2026-07-31 08:58:53 -04:00
commit c7fce339c2
124 changed files with 17558 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
"""Integrations module for external APIs."""
from backend.integrations.zepp_api import (
ZeppApiClient,
ZeppApiError,
create_zepp_client,
sync_zepp_data
)
__all__ = [
"ZeppApiClient",
"ZeppApiError",
"create_zepp_client",
"sync_zepp_data",
]
+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()
+472
View File
@@ -0,0 +1,472 @@
"""
Zepp/Huami API Client
Provides authentication and data fetching from the Zepp/Huami cloud API
for Amazfit and Mi Band devices.
Based on the Huami API technical documentation:
- Authentication: account.huami.com
- Data: api-mifit-de2.zepp.com (EU GDPR compliant)
"""
import base64
import datetime
import hashlib
import json
import logging
import uuid
from typing import Any, Dict, List, Optional, Tuple
import requests
from backend.core.config import settings
logger = logging.getLogger(__name__)
# --- Constants ---
AUTH_URL = "https://account.huami.com/v2/client/login"
API_BASE_EU = "https://api-mifit-de2.zepp.com"
API_BASE_GLOBAL = "https://api-mifit.huami.com"
# App identification (mimics official Zepp app)
APP_NAME = "com.huami.watch.hmwatchmanager"
CLIENT_ID = "HuaMi"
APP_VERSION = "6.9.3"
class ZeppApiError(Exception):
"""Custom exception for Zepp API errors."""
pass
class ZeppApiClient:
"""
Client for interacting with the Zepp/Huami API.
Handles authentication, token management, and data fetching.
"""
def __init__(self, email: str = None, password: str = None):
self.email = email
self.password = password
self.app_token: Optional[str] = None
self.login_token: Optional[str] = None
self.user_id: Optional[str] = None
self.token_expiry: Optional[datetime.datetime] = None
self.api_base = API_BASE_EU # Default to EU for GDPR
self._session = requests.Session()
self._session.headers.update({
"User-Agent": f"Zepp/{APP_VERSION} (Android)",
"Accept": "application/json",
})
def authenticate(self, email: str = None, password: str = None) -> Dict[str, Any]:
"""
Authenticate with the Huami/Zepp API.
Args:
email: Zepp account email
password: Zepp account password
Returns:
Dict containing user_id, app_token, and other session info
"""
email = email or self.email
password = password or self.password
if not email or not password:
raise ZeppApiError("Email and password are required")
# Generate unique device ID
device_id = uuid.uuid4().hex[:16].upper()
# Prepare authentication request
auth_data = {
"grant_type": "access_token",
"client_id": CLIENT_ID,
"app_name": APP_NAME,
"app_version": APP_VERSION,
"third_name": "huami",
"device_id": device_id,
"device_model": "android_phone",
"allow_registration": "false",
"dn": "api-user.huami.com,api-mifit-de2.zepp.com,app-analytics.huami.com",
"country_code": "DE",
"lang": "en",
"state": "REDIRECTION",
"redirect_uri": "https://s3-us-west-2.amazonws.com/hm-registration/successs498.html",
}
# Add credentials
auth_data["email"] = email
auth_data["password"] = password
headers = {
"Content-Type": "application/x-www-form-urlencoded",
}
try:
response = self._session.post(
AUTH_URL,
data=auth_data,
headers=headers,
timeout=30
)
response.raise_for_status()
result = response.json()
if "error_code" in result:
error_msg = result.get("message", "Authentication failed")
raise ZeppApiError(f"Auth error: {error_msg}")
# Extract tokens
token_info = result.get("token_info", {})
self.app_token = token_info.get("app_token")
self.login_token = token_info.get("login_token")
self.user_id = token_info.get("user_id")
if not self.app_token or not self.user_id:
raise ZeppApiError("Invalid authentication response: missing tokens")
# Store credentials for refresh
self.email = email
self.password = password
# Set token expiry (typically 30 days, be conservative with 7)
self.token_expiry = datetime.datetime.now() + datetime.timedelta(days=7)
logger.info(f"Successfully authenticated Zepp user: {self.user_id}")
return {
"user_id": self.user_id,
"app_token": self.app_token,
"login_token": self.login_token,
}
except requests.exceptions.RequestException as e:
logger.error(f"Zepp authentication request failed: {e}")
raise ZeppApiError(f"Network error during authentication: {e}")
def _ensure_authenticated(self):
"""Ensure we have valid tokens, re-authenticate if needed."""
if not self.app_token or not self.user_id:
if self.email and self.password:
self.authenticate()
else:
raise ZeppApiError("Not authenticated. Call authenticate() first.")
# Check token expiry
if self.token_expiry and datetime.datetime.now() > self.token_expiry:
logger.info("Token expired, re-authenticating...")
self.authenticate()
def _make_request(self, endpoint: str, params: Dict = None, method: str = "GET") -> Dict:
"""
Make an authenticated request to the Zepp API.
"""
self._ensure_authenticated()
url = f"{self.api_base}{endpoint}"
headers = {
"apptoken": self.app_token,
"Accept": "application/json",
}
try:
if method == "GET":
response = self._session.get(url, params=params, headers=headers, timeout=30)
else:
response = self._session.post(url, data=params, headers=headers, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Zepp API request failed: {e}")
raise ZeppApiError(f"API request failed: {e}")
def fetch_band_data(
self,
from_date: datetime.date,
to_date: datetime.date,
query_type: str = "summary"
) -> Dict[str, Any]:
"""
Fetch daily health data (steps, sleep, heart rate).
Args:
from_date: Start date
to_date: End date
query_type: "summary" for daily totals, "detail" for minute-by-minute
Returns:
Dict containing decoded health data
"""
params = {
"query_type": query_type,
"from_date": from_date.strftime("%Y-%m-%d"),
"to_date": to_date.strftime("%Y-%m-%d"),
"userid": self.user_id,
"device_type": "android_phone",
}
response = self._make_request("/v1/data/band_data.json", params)
if response.get("code") != 1:
raise ZeppApiError(f"band_data request failed: {response.get('message')}")
# Decode the Base64 summary data
data_list = response.get("data", [])
decoded_data = []
for entry in data_list:
decoded_entry = self._decode_band_data_entry(entry)
if decoded_entry:
decoded_data.append(decoded_entry)
return {"data": decoded_data, "raw_response": response}
def _decode_band_data_entry(self, entry: Dict) -> Optional[Dict]:
"""
Decode a single band_data entry with Base64 encoded fields.
"""
try:
date_str = entry.get("date_time", entry.get("date"))
if not date_str:
return None
result = {
"date": date_str,
"summary": {},
}
# Decode summary field (Base64 JSON)
summary_b64 = entry.get("summary")
if summary_b64:
try:
summary_json = base64.b64decode(summary_b64).decode("utf-8")
result["summary"] = json.loads(summary_json)
except Exception as e:
logger.warning(f"Failed to decode summary: {e}")
# Extract steps data
stp = result["summary"].get("stp", {})
result["steps"] = stp.get("ttl", 0)
result["distance"] = stp.get("dis", 0)
result["calories"] = stp.get("cal", 0)
# Extract sleep data
slp = result["summary"].get("slp", {})
if slp:
result["sleep"] = {
"start_time": slp.get("st"),
"end_time": slp.get("ed"),
"deep_sleep_minutes": slp.get("dp", 0),
"light_sleep_minutes": slp.get("lt", 0),
"rem_sleep_minutes": slp.get("rm", 0),
"awake_minutes": slp.get("wk", 0),
}
# Calculate total
result["sleep"]["total_minutes"] = (
result["sleep"]["deep_sleep_minutes"] +
result["sleep"]["light_sleep_minutes"] +
result["sleep"]["rem_sleep_minutes"]
)
# Decode heart rate blob if present
hr_blob = entry.get("data_hr")
if hr_blob:
hr_values = self._decode_heart_rate_blob(hr_blob)
if hr_values:
valid_hr = [v for v in hr_values if 30 < v < 220]
if valid_hr:
result["heart_rate"] = {
"avg": sum(valid_hr) // len(valid_hr),
"min": min(valid_hr),
"max": max(valid_hr),
"resting": min(valid_hr), # Approximation
}
# Decode SpO2 if present
spo2_blob = entry.get("data_spo2")
if spo2_blob:
spo2_values = self._decode_spo2_blob(spo2_blob)
if spo2_values:
valid_spo2 = [v for v in spo2_values if 70 < v <= 100]
if valid_spo2:
result["spo2"] = {
"avg": sum(valid_spo2) / len(valid_spo2),
"min": min(valid_spo2),
"max": max(valid_spo2),
}
return result
except Exception as e:
logger.error(f"Error decoding band_data entry: {e}")
return None
def _decode_heart_rate_blob(self, b64_data: str) -> List[int]:
"""
Decode Base64-encoded heart rate binary data.
Format: sequence of 1-byte or 2-byte integers.
"""
try:
raw = base64.b64decode(b64_data)
# Try single-byte format first (most common)
values = list(raw)
# Filter out invalid values (0, 255 are typically "no reading")
return [v for v in values if v > 0 and v < 255]
except Exception as e:
logger.warning(f"Failed to decode HR blob: {e}")
return []
def _decode_spo2_blob(self, b64_data: str) -> List[float]:
"""
Decode Base64-encoded SpO2 binary data.
"""
try:
raw = base64.b64decode(b64_data)
# SpO2 is typically stored as single bytes (percentage)
values = list(raw)
return [float(v) for v in values if v > 0]
except Exception as e:
logger.warning(f"Failed to decode SpO2 blob: {e}")
return []
def fetch_workout_history(self, days: int = 30) -> List[Dict]:
"""
Fetch workout/sport session history.
Args:
days: Number of days of history to fetch
Returns:
List of workout sessions
"""
from_date = datetime.date.today() - datetime.timedelta(days=days)
params = {
"from_date": from_date.strftime("%Y-%m-%d"),
"to_date": datetime.date.today().strftime("%Y-%m-%d"),
"userid": self.user_id,
}
try:
response = self._make_request("/v1/sport/run/history.json", params)
if response.get("code") != 1:
logger.warning(f"Workout history request failed: {response}")
return []
return response.get("data", {}).get("summary", [])
except ZeppApiError as e:
logger.warning(f"Failed to fetch workout history: {e}")
return []
def fetch_workout_detail(self, track_id: str) -> Optional[Dict]:
"""
Fetch detailed workout data including GPS track.
Args:
track_id: The workout track ID
Returns:
Dict with workout details and GPS data
"""
params = {
"trackid": track_id,
"source": "run.mifit.huami.com",
}
try:
response = self._make_request("/v1/sport/run/detail.json", params)
if response.get("code") != 1:
return None
data = response.get("data", {})
# Parse GPS coordinates if present
gps_str = data.get("longitude_latitude", "")
if gps_str:
data["gps_track"] = self._parse_gps_string(gps_str)
return data
except ZeppApiError as e:
logger.warning(f"Failed to fetch workout detail {track_id}: {e}")
return None
def _parse_gps_string(self, gps_str: str) -> List[Tuple[float, float]]:
"""
Parse Zepp's proprietary GPS format.
Format: lat,lon;lat,lon;lat,lon...
"""
points = []
try:
for point_str in gps_str.split(";"):
if "," in point_str:
parts = point_str.split(",")
if len(parts) >= 2:
lat = float(parts[0])
lon = float(parts[1])
points.append((lat, lon))
except Exception as e:
logger.warning(f"Failed to parse GPS string: {e}")
return points
# --- Convenience Functions ---
def create_zepp_client(email: str, password: str) -> ZeppApiClient:
"""Create and authenticate a Zepp API client."""
client = ZeppApiClient(email, password)
client.authenticate()
return client
def sync_zepp_data(
client: ZeppApiClient,
days: int = 30
) -> Dict[str, Any]:
"""
Sync all available data from Zepp.
Args:
client: Authenticated ZeppApiClient
days: Number of days to sync
Returns:
Dict with synced data counts
"""
from_date = datetime.date.today() - datetime.timedelta(days=days)
to_date = datetime.date.today()
results = {
"daily_metrics": [],
"workouts": [],
"days_synced": 0,
"workouts_synced": 0,
}
# Fetch band data (daily metrics)
try:
band_data = client.fetch_band_data(from_date, to_date)
results["daily_metrics"] = band_data.get("data", [])
results["days_synced"] = len(results["daily_metrics"])
except ZeppApiError as e:
logger.error(f"Failed to fetch band data: {e}")
# Fetch workouts
try:
workouts = client.fetch_workout_history(days)
results["workouts"] = workouts
results["workouts_synced"] = len(workouts)
except ZeppApiError as e:
logger.error(f"Failed to fetch workouts: {e}")
return results