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