Skill: lovefromio/lovefromio-garmin-connect (version 1.0.2)

--- name: garmin-connect description: "Garmin Connect integration for Clawdbot: sync fitness data (steps, HR, calories, workouts, sleep) every 5 minutes using OAuth." --- # Garmin Connect Skill Sync all your Garmin fitness data to Clawdbot: - 🚶 **Daily Activity**: Steps, heart rate, calories, active minutes, distance - 😴 **Sleep**: Duration, quality, deep/REM/light sleep breakdown - 🏋️ **Workouts**: Recent activities with distance, duration, calories, heart rate - ⏱️ **Real-time sync**: Every 5 minutes via cron ## Quick Start ### 1. Install Dependencies ```bash pip install -r requirements.txt ``` ### 2. OAuth Authentication (One-time) ```bash python3 scripts/garmin-auth.py [REDACTED_SECRET] your-password ``` This saves your OAuth session to `~/.garth/session.json` — fully local and secure. ### 3. Test Sync ```bash python3 scripts/garmin-sync.py ``` You should see JSON output with today's stats. ### 4. Set Up 5-Minute Cron Add to your crontab: ```bash */5 * * * * /home/user/garmin-connect-clawdbot/scripts/garmin-cron.sh ``` Or manually: ```bash */5 * * * * python3 /home/user/garmin-connect-clawdbot/scripts/garmin-sync.py ~/.clawdbot/.garmin-cache.json ``` ### 5. Use in Clawdbot Import and use in your scripts: ```python from scripts.garmin_formatter import format_all, get_as_dict # Get all formatted data print(format_all()) # Or get raw dict data = get_as_dict() print(f"Steps today: {data['summary']['steps']}") ``` ## Features ✅ OAuth-based (secure, no password storage) ✅ All metrics: activity, sleep, workouts ✅ Local caching (fast access) ✅ Cron-friendly (5-minute intervals) ✅ Easy Clawdbot integration ✅ Multi-user support ## Data Captured ### Daily Activity (`summary`) - `steps`: Daily step count - `heart_rate_resting`: Resting heart rate (bpm) - `calories`: Total calories burned - `active_minutes`: Intensity minutes - `distance_km`: Distance traveled ### Sleep (`sleep`) - `duration_hours`: Total sleep time - `duration_minutes`: Sleep in minutes - `quality_percent`: Sleep quality score (0-100) - `deep_sleep_hours`: Deep sleep duration - `rem_sleep_hours`: REM sleep duration - `light_sleep_hours`: Light sleep duration - `awake_minutes`: Time awake during sleep ### Workouts (`workouts`) For each recent workout: - `type`: Activity type (Running, Cycling, etc.) - `name`: Activity name - `distance_km`: Distance traveled - `duration_minutes`: Duration of activity - `calories`: Calories burned - `heart_rate_avg`: Average heart rate - `heart_rate_max`: Max heart rate ## Cache Location By default, data is cached at: `~/.clawdbot/.garmin-cache.json` Customize with: ```bash python3 scripts/garmin-sync.py /custom/path/cache.json ``` ## Files | File | Purpose | |------|---------| | `garmin-auth.py` | OAuth setup (run once) | | `garmin-sync.py` | Main sync logic (run every 5 min) | | `garmin-formatter.py` | Format data for display | | `garmin-cron.sh` | Cron wrapper script | | `requirements.txt` | Python dependencies | ## Troubleshooting ### OAuth authentication fails - Check email/password - Disable 2FA on Garmin account (or use app password) - Garmin servers might be rate-limiting — wait 5 minutes ### No data appears 1. Sync your Garmin device with the Garmin Connect app 2. Wait 2-3 minutes for data to sync 3. Check that data appears in Garmin Connect web/app 4. Then run `garmin-sync.py` again ### Permission denied on cron ```bash chmod +x scripts/garmin-cron.sh chmod +x scripts/garmin-sync.py chmod +x scripts/garmin-auth.py ``` ### Cache file not found Run `garmin-sync.py` at least once to create cache: ```bash python3 scripts/garmin-sync.py ``` ## Usage Examples ```python from scripts.garmin_formatter import format_all, get_as_dict # Get formatted output print(format_all()) # Get raw data data = get_as_dict() if data: print(f"Sleep: {data['sleep']['duration_hours']}h") print(f"Steps: {data['summary']['steps']:,}") ``` ## License MIT — Use, fork, modify freely. --- Made for [Clawdbot](https://clawd.bot) | Available on [ClawdHub](https://clawdhub.com)

path: README.md
content: # Garmin Connect Integration for Clawdbot

Sync your Garmin fitness data (steps, HR, calories, workouts, sleep) automatically to Clawdbot every 5 minutes.

## Quick Start

### 1. Install Dependencies

```bash
pip install -r requirements.txt
```

### 2. Authenticate with OAuth

Run the authentication script:

```bash
python3 scripts/garmin-auth.py [REDACTED_SECRET] your-password
```

This saves your OAuth session to `~/.garth/session.json` (local, secure).

⚠️ **Keep this file safe** — it contains your Garmin OAuth token.

### 3. Test

```bash
python3 scripts/garmin-sync.py
```

You should see JSON output with your current Garmin data.

### 4. Set Up Cron (5-minute sync)

Add to your crontab:

```bash
*/5 * * * * python3 /path/to/scripts/garmin-sync.py
```

### 5. Use in Your Scripts

Import Garmin data in any Clawdbot script:

```python
from garmin_connect_clawdbot.scripts.garmin_formatter import format_all, get_as_dict

# Get all formatted data
all_data = format_all()
print(all_data)

# Or get raw dictionary
data = get_as_dict()
steps = data['summary']['steps']
sleep_hours = data['sleep']['duration_hours']
```

## Features

- ✅ OAuth-based authentication (secure)
- ✅ Real-time sync every 5 minutes
- ✅ Sleep quality tracking (duration, deep/REM/light sleep)
- ✅ Daily activity metrics (steps, HR, calories, distance)
- ✅ Workout tracking (all activity types)
- ✅ Body battery monitoring
- ✅ Local caching (JSON)
- ✅ Easy Clawdbot integration

## Scripts

| Script | Purpose |
|--------|---------|
| `garmin-auth.py` | OAuth authentication (run once) |
| `garmin-sync.py` | Sync all data from Garmin |
| `garmin-formatter.py` | Format data for display |
| `garmin-cron.sh` | Wrapper for cron jobs |

## Data Caching

Data is cached locally in JSON format for quick access without constant API calls.

### Data Structure

The cached data contains:

- **summary**: Daily activity (steps, heart rate, calories, active minutes, distance)
- **sleep**: Sleep metrics (duration, quality, deep/REM/light sleep breakdown)
- **workouts**: Recent activities (type, distance, duration, calories, heart rate)

## Usage Examples

### Format All Data

```python
from scripts.garmin_formatter import format_all

output = format_all()  # Returns formatted string
print(output)
```

### Access Raw Data

```python
from scripts.garmin_formatter import get_as_dict

data = get_as_dict()
if data:
    print(f"Sleep: {data['sleep']['duration_hours']}h")
    print(f"Steps: {data['summary']['steps']:,}")
    print(f"Workouts: {len(data['workouts'])} activities")
```

### Format Specific Metrics

```python
from scripts.garmin_formatter import format_daily_summary, format_sleep, format_workouts

# Use individually
print(format_daily_summary())
print(format_sleep())
print(format_workouts())
```

## Troubleshooting

### Authentication Failed

- Check email/password
- Ensure 2FA is **OFF** on Garmin (or use app-specific password)
- Garmin servers might rate-limit — wait 5 minutes

### No Sleep Data

- Sync your Garmin device with the Garmin Connect app
- Sleep must be tracked while wearing device
- Data available ~1 hour after waking

### Missing Workouts

- Sync device → Garmin Connect app
- Confirm workout saved in Garmin app
- Check `garmin-sync.py` logs

### Data not syncing

Run the sync script manually:

```bash
python3 scripts/garmin-sync.py
```

Check the output for errors.

## License

MIT — Use freely, fork, modify as needed.

---

Made for [Clawdbot](https://clawd.bot) | Available on [ClawdHub](https://clawdhub.com)


path: _meta.json
content: ownerId: kn7c0sy4fbf4n92n3nm242axc1800bgk
slug: lovefromio-garmin-connect
version: 1.0.1
publishedAt: 1769535574161
forkOf: rayleigh3105/garmin-connect@1.0.0

path: requirements.txt
content: garminconnect>=0.2.38
requests>=2.28.0
python-dateutil>=2.8.2


path: scripts/garmin-auth-oauth.py
content: #!/usr/bin/env python3
"""
Garmin Connect OAuth Authentication (NEW)
Garmin switched to OAuth cookie-based auth
"""

import os
import json
from pathlib import Path

try:
    from garth import Client
except ImportError:
    print("❌ garth not installed. Run: pip install garth")
    exit(1)

def get_oauth_client():
    """Get authenticated Garmin client with OAuth"""
    
    # OAuth cache file
    garth_cache_dir = Path.home() / ".garth"
    garth_cache_dir.mkdir(exist_ok=True)
    
    client = Client()
    
    # Try to load existing token
    try:
        client.load("/home/mamotec/.garth/session.json")
        print("✅ Loaded existing Garmin OAuth session")
        return client
    except:
        pass
    
    # Need to authenticate - this requires browser login
    print("⚠️  First-time setup: Need to authenticate via browser")
    print("\nGarmin switched to OAuth. Follow these steps:")
    print("1. Run: python3 garmin-auth-oauth.py")
    print("2. Open: https://sso.garmin.com/sso/signin")
    print("3. Sign in with your Garmin account")
    print("4. The script will save your session for future use")
    
    return None

def save_session(client):
    """Save OAuth session for future use"""
    client.dump("/home/mamotec/.garth/session.json")
    print("✅ Session saved to ~/.garth/session.json")

if __name__ == "__main__":
    print("Garmin OAuth Setup")
    print("=" * 50)
    
    # Try to load existing session
    try:
        client = Client()
        client.load("/home/mamotec/.garth/session.json")
        print("✅ OAuth session already configured")
        print("Try: python3 garmin-sync.py")
    except:
        print("❌ No OAuth session found")
        print("\nManuelle Authentifizierung nötig:")
        print("1. Geh zu: https://sso.garmin.com/sso/signin")
        print("2. Melde dich an ([REDACTED_SECRET])")
        print("3. Cookies werden automatisch gespeichert")
        print("\nOder nutze garth-cli:")
        print("pip install garth-cli")
        print("garth auth [REDACTED_SECRET]")


path: scripts/garmin-auth.py
content: #!/usr/bin/env python3
"""
Garmin OAuth Authentication Setup
One-time setup: saves OAuth session for future use
"""

import os
from pathlib import Path

try:
    from garth import Client
except ImportError:
    print("❌ garth not installed. Run: pip install garth")
    exit(1)

def setup_oauth(email, password):
    """Authenticate with Garmin and save session"""
    
    garth_dir = Path.home() / ".garth"
    session_file = garth_dir / "session.json"
    
    print(f"🔐 Authenticating with Garmin ({email})...")
    
    try:
        client = Client()
        client.login(email, password)
        
        # Save session
        garth_dir.mkdir(exist_ok=True)
        client.dump(str(session_file))
        
        print(f"✅ OAuth session saved to {session_file}")
        print("✅ You can now use garmin-sync.py")
        
        return True
        
    except Exception as e:
        print(f"❌ Authentication failed: {e}")
        print("\nCommon issues:")
        print("- Wrong email/password")
        print("- 2FA enabled (disable or use app-specific password)")
        print("- Garmin servers temporary unavailable")
        return False

if __name__ == "__main__":
    import sys
    
    if len(sys.argv) < 3:
        print("Usage: python3 garmin-auth.py <email> <password>")
        print("Example: python3 garmin-auth.py [REDACTED_SECRET] MyPassword123")
        sys.exit(1)
    
    email = sys.argv[1]
    [REDACTED_SECRET]
    
    success = setup_oauth(email, password)
    sys.exit(0 if success else 1)


path: scripts/garmin-cron.sh
content: #!/bin/bash
# Cron wrapper for 5-minute Garmin sync checks
# Add to crontab: */5 * * * * /home/mamotec/clawd/garmin-connect-clawdbot/scripts/garmin-cron.sh

cd /home/mamotec/clawd/garmin-connect-clawdbot

# Run sync (with timeout to prevent hanging)
timeout 30 python3 scripts/garmin-sync.py > /tmp/garmin-sync.log 2>&1

# Check for significant changes and alert if needed
# (Would send to Telegram via Clawdbot message tool)

exit 0


path: scripts/garmin-formatter.py
content: #!/usr/bin/env python3
"""
Format Garmin data for display
Provides formatted output for integration into Clawdbot
"""

import json
import os
from pathlib import Path

def load_cache():
    """Load latest Garmin data from cache"""
    cache_file = Path.home() / ".clawdbot" / ".garmin-cache.json"
    
    if not cache_file.exists():
        return None
    
    try:
        with open(cache_file, 'r') as f:
            return json.load(f)
    except:
        return None

def format_daily_summary():
    """Format daily activity summary"""
    
    data = load_cache()
    if not data:
        return "📊 No Garmin data available yet. Run sync first."
    
    summary = data.get('summary', {})
    
    output = "📊 **Daily Activity**\n"
    output += f"🚶 Steps: {summary.get('steps', 0):,}\n"
    output += f"❤️ Heart Rate (resting): {summary.get('heart_rate_resting', 0)} bpm\n"
    output += f"🔥 Calories: {summary.get('calories', 0):,}\n"
    output += f"⏱️ Active Minutes: {summary.get('active_minutes', 0)} min\n"
    output += f"📏 Distance: {summary.get('distance_km', 0)} km"
    
    return output

def format_sleep():
    """Format sleep data"""
    
    data = load_cache()
    if not data:
        return "😴 No sleep data available"
    
    sleep = data.get('sleep', {})
    
    quality = sleep.get('quality_percent', 0)
    quality_emoji = "😴" if quality >= 80 else "😐" if quality >= 60 else "😩"
    
    output = f"{quality_emoji} **Sleep**\n"
    output += f"Duration: {sleep.get('duration_hours', 0)}h {int(sleep.get('duration_minutes', 0) % 60)}m\n"
    output += f"Quality: {quality}%\n"
    output += f"Deep Sleep: {sleep.get('deep_sleep_hours', 0)}h\n"
    output += f"REM Sleep: {sleep.get('rem_sleep_hours', 0)}h\n"
    output += f"Light Sleep: {sleep.get('light_sleep_hours', 0)}h\n"
    output += f"Awake: {int(sleep.get('awake_minutes', 0))} min"
    
    return output

def format_workouts():
    """Format recent workouts"""
    
    data = load_cache()
    if not data:
        return "🏋️ No workout data available"
    
    workouts = data.get('workouts', [])
    
    if not workouts:
        return "🏋️ No recent workouts"
    
    output = "🏋️ **Recent Workouts**\n"
    
    for workout in workouts[:5]:  # Show last 5
        output += f"\n• {workout.get('type', 'Workout')}: {workout.get('name', 'Unnamed')}\n"
        output += f"  Distance: {workout.get('distance_km', 0)} km\n"
        output += f"  Duration: {int(workout.get('duration_minutes', 0))} min\n"
        output += f"  Calories: {workout.get('calories', 0)}\n"
        output += f"  HR: {workout.get('heart_rate_avg', 0)} avg / {workout.get('heart_rate_max', 0)} max"
    
    return output

def format_all():
    """Format all data at once"""
    
    output = ""
    output += format_daily_summary() + "\n\n"
    output += format_sleep() + "\n\n"
    output += format_workouts()
    
    return output

def get_as_dict():
    """Get all data as dict (for Clawdbot integration)"""
    return load_cache()

if __name__ == "__main__":
    print(format_all())


path: scripts/garmin-sync-30days.py
content: #!/usr/bin/env python3
"""
Garmin Connect 30-Day Health Data Sync
Fetches 30 days of complete health data and generates interactive dashboard
"""

import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path

try:
    from garth import Client
    from garminconnect import Garmin
except ImportError as e:
    print(f"❌ Dependencies missing: {e}")
    print("Run: pip install garth garminconnect")
    sys.exit(1)

def load_garth_session():
    """Load saved Garmin OAuth session"""
    session_file = Path.home() / ".garth" / "session.json"

    if not session_file.exists():
        print(f"❌ No OAuth session found at {session_file}")
        print("\nRun: python3 garmin-auth.py <email> <password>")
        return None

    try:
        client = Client()
        client.load(str(session_file))
        return client
    except Exception as e:
        print(f"❌ Failed to load session: {e}")
        return None

def get_daily_summary(garth_client, date_str):
    """Get daily summary with retry logic for 429 errors"""
    import time

    max_retries = 3
    retry_delays = [60, 120, 240]  # Exponential backoff

    for attempt in range(max_retries):
        try:
            gc = Garmin()
            gc.garth = garth_client
            summary = gc.get_user_summary(date_str)

            return {
                'steps': summary.get('totalSteps', 0),
                'heart_rate_resting': summary.get('restingHeartRate', 0),
                'calories': summary.get('totalKilocalories', 0),
                'active_minutes': summary.get('totalIntensityMinutes', 0),
                'distance_km': round(summary.get('totalDistance', 0) / 1000, 2),
            }
        except Exception as e:
            if "429" in str(e) and attempt < max_retries - 1:
                wait = retry_delays[attempt]
                print(f"⚠️  Rate limited (429). Waiting {wait}s before retry...")
                time.sleep(wait)
                continue
            elif "403" in str(e):
                # Try alternate endpoint
                try:
                    # Use daily summary endpoint directly
                    gc = Garmin()
                    gc.garth = garth_client
                    # Try get daily stats via different method
                    stats = gc.get_stats(date_str)
                    return {
                        'steps': stats.get('dailySteps', 0),
                        'heart_rate_resting': stats.get('restingHeartRate', 0),
                        'calories': stats.get('totalKilocalories', 0),
                        'active_minutes': stats.get('totalIntensityMinutes', 0),
                        'distance_km': 0,
                    }
                except:
                    print(f"⚠️  Daily summary error for {date_str}: {e}")
                    return None
            else:
                print(f"⚠️  Daily summary error for {date_str}: {e}")
                return None

    return None

def get_sleep_data(garth_client, date_str):
    """Get sleep data for a specific date"""
    try:
        gc = Garmin()
        gc.garth = garth_client
        sleep = gc.get_sleep_data(date_str)

        if sleep and 'dailySleepDTO' in sleep:
            s = sleep['dailySleepDTO']
            duration_sec = s.get('sleepTimeSeconds', 0)
            return {
                'date': date_str,
                'duration_hours': round(duration_sec / 3600, 1),
                'duration_minutes': round(duration_sec / 60, 0),
                'quality_percent': s.get('sleepQualityPercentage', 0),
                'deep_sleep_hours': round(s.get('deepSleepSeconds', 0) / 3600, 1),
                'rem_sleep_hours': round(s.get('remSleepSeconds', 0) / 3600, 1),
                'light_sleep_hours': round(s.get('lightSleepSeconds', 0) / 3600, 1),
                'awake_minutes': round(s.get('awakeTimeSeconds', 0) / 60, 0),
            }
    except Exception as e:
        print(f"⚠️  Sleep data error for {date_str}: {e}")

    return {
        'date': date_str,
        'duration_hours': 0,
        'quality_percent': 0,
        'deep_sleep_hours': 0,
        'rem_sleep_hours': 0,
        'light_sleep_hours': 0,
        'awake_minutes': 0,
    }

def get_heart_rate(garth_client, date_str):
    """Get heart rate data"""
    try:
        gc = Garmin()
        gc.garth = garth_client
        hr_data = gc.get_heart_rate(date_str)

        if hr_data:
            return {
                'resting': hr_data.get('restingHeartRate', 0),
                'max': hr_data.get('maxHeartRate', 0),
                'avg': hr_data.get('averageHeartRate', 0),
            }
    except Exception as e:
        print(f"⚠️  Heart rate error for {date_str}: {e}")

    return {'resting': 0, 'max': 0, 'avg': 0}

def get_workouts(garth_client, days=30):
    """Get recent workouts from the last N days"""
    workouts = []

    try:
        gc = Garmin()
        gc.garth = garth_client

        # Get activities from last 30 days
        start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
        activities = gc.get_activities(start_date, datetime.now().strftime("%Y-%m-%d"))

        for activity in activities:
            workout = {
                'date': activity.get('startTimeLocal', ''),
                'type': activity.get('activityType', {}).get('typeKey', 'Unknown'),
                'name': activity.get('activityName', 'Unnamed'),
                'distance_km': round(activity.get('distance', 0) / 1000, 2),
                'duration_minutes': round(activity.get('duration', 0) / 60, 0),
                'calories': activity.get('calories', 0),
                'heart_rate_avg': activity.get('avgHeartRate', 0),
                'heart_rate_max': activity.get('maxHeartRate', 0),
            }
            workouts.append(workout)

    except Exception as e:
        print(f"⚠️  Workouts error: {e}")

    return workouts

def sync_30_days():
    """Sync 30 days of Garmin health data"""
    garth_client = load_garth_session()
    if not garth_client:
        return None

    today = datetime.now()
    thirty_days_ago = today - timedelta(days=30)

    print(f"📊 Fetching 30 days of health data ({thirty_days_ago.strftime('%Y-%m-%d')} to {today.strftime('%Y-%m-%d')})...")

    daily_data = []
    current = thirty_days_ago

    while current <= today:
        date_str = current.strftime("%Y-%m-%d")
        print(f"  Fetching {date_str}...", end="", flush=True)

        summary = get_daily_summary(garth_client, date_str)
        sleep = get_sleep_data(garth_client, date_str)
        hr = get_heart_rate(garth_client, date_str)

        day_data = {
            'date': date_str,
            'summary': summary or {},
            'sleep': sleep,
            'heart_rate': hr,
        }
        daily_data.append(day_data)

        print(" ✓")
        current += timedelta(days=1)

    # Get workouts (last 30 days)
    print("\n🏃 Fetching workouts...")
    workouts = get_workouts(garth_client, 30)

    # Compile all data
    all_data = {
        'timestamp': datetime.now().isoformat(),
        'period': {
            'start': thirty_days_ago.strftime("%Y-%m-%d"),
            'end': today.strftime("%Y-%m-%d"),
            'days': 30,
        },
        'daily': daily_data,
        'workouts': workouts,
        'summary_stats': calculate_summary_stats(daily_data, workouts),
    }

    return all_data

def calculate_summary_stats(daily_data, workouts):
    """Calculate aggregated statistics for the 30-day period"""
    total_steps = sum(d.get('summary', {}).get('steps', 0) for d in daily_data if d.get('summary'))
    avg_steps = total_steps / len(daily_data) if daily_data else 0

    total_calories = sum(d.get('summary', {}).get('calories', 0) for d in daily_data if d.get('summary'))
    total_active_minutes = sum(d.get('summary', {}).get('active_minutes', 0) for d in daily_data if d.get('summary'))

    avg_sleep = sum(d.get('sleep', {}).get('duration_hours', 0) for d in daily_data) / len(daily_data) if daily_data else 0
    avg_sleep_quality = sum(d.get('sleep', {}).get('quality_percent', 0) for d in daily_data) / len(daily_data) if daily_data else 0

    total_workouts = len(workouts)
    total_workout_minutes = sum(w.get('duration_minutes', 0) for w in workouts)
    total_workout_calories = sum(w.get('calories', 0) for w in workouts)

    return {
        'total_steps': total_steps,
        'avg_daily_steps': round(avg_steps, 0),
        'total_calories': total_calories,
        'total_active_minutes': total_active_minutes,
        'avg_sleep_hours': round(avg_sleep, 1),
        'avg_sleep_quality': round(avg_sleep_quality, 1),
        'total_workouts': total_workouts,
        'total_workout_minutes': round(total_workout_minutes, 0),
        'total_workout_calories': total_workout_calories,
    }

def generate_dashboard_html(data, output_path):
    """Generate an interactive HTML health dashboard"""

    html_template = """<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Garmin Health Dashboard - Last 30 Days</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        .metric-card { transition: transform 0.2s; }
        .metric-card:hover { transform: translateY(-4px); }
    </style>
</head>
<body class="bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 min-h-screen text-white p-6">

    <div class="max-w-7xl mx-auto">

        <!-- Header -->
        <div class="text-center mb-8">
            <h1 class="text-4xl font-bold mb-2">🏃 Garmin Health Dashboard</h1>
            <p class="text-blue-300">30-Day Summary | {start} → {end}</p>
            <p class="text-sm text-gray-400 mt-2">Generated: {timestamp}</p>
        </div>

        <!-- Key Metrics -->
        <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">

            <div class="metric-card bg-gradient-to-br from-blue-600 to-blue-800 p-6 rounded-xl shadow-lg">
                <div class="text-blue-200 text-sm">Total Steps</div>
                <div class="text-3xl font-bold">{total_steps:,}</div>
                <div class="text-blue-200 text-sm">Daily avg: {avg_steps:,}</div>
            </div>

            <div class="metric-card bg-gradient-to-br from-green-600 to-green-800 p-6 rounded-xl shadow-lg">
                <div class="text-green-200 text-sm">Total Calories</div>
                <div class="text-3xl font-bold">{total_calories:,}</div>
                <div class="text-green-200 text-sm">Active: {active_minutes} min</div>
            </div>

            <div class="metric-card bg-gradient-to-br from-purple-600 to-purple-800 p-6 rounded-xl shadow-lg">
                <div class="text-purple-200 text-sm">Avg Sleep</div>
                <div class="text-3xl font-bold">{avg_sleep}h</div>
                <div class="text-purple-200 text-sm">Quality: {avg_quality}%</div>
            </div>

            <div class="metric-card bg-gradient-to-br from-orange-600 to-orange-800 p-6 rounded-xl shadow-lg">
                <div class="text-orange-200 text-sm">Workouts</div>
                <div class="text-3xl font-bold">{total_workouts}</div>
                <div class="text-orange-200 text-sm">{total_workout_minutes} min • {workout_calories} cal</div>
            </div>

        </div>

        <!-- Charts Row -->
        <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">

            <!-- Steps Chart -->
            <div class="bg-slate-800/50 backdrop-blur p-6 rounded-xl border border-slate-700">
                <h2 class="text-xl font-bold mb-4">📈 Daily Steps</h2>
                <canvas id="stepsChart" height="200"></canvas>
            </div>

            <!-- Sleep Chart -->
            <div class="bg-slate-800/50 backdrop-blur p-6 rounded-xl border border-slate-700">
                <h2 class="text-xl font-bold mb-4">😴 Sleep Duration</h2>
                <canvas id="sleepChart" height="200"></canvas>
            </div>

        </div>

        <!-- Second Row Charts -->
        <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">

            <!-- Heart Rate -->
            <div class="bg-slate-800/50 backdrop-blur p-6 rounded-xl border border-slate-700">
                <h2 class="text-xl font-bold mb-4">❤️ Resting Heart Rate</h2>
                <canvas id="hrChart" height="200"></canvas>
            </div>

            <!-- Activity Types -->
            <div class="bg-slate-800/50 backdrop-blur p-6 rounded-xl border border-slate-700">
                <h2 class="text-xl font-bold mb-4">🏋️ Workout Distribution</h2>
                <canvas id="workoutChart" height="200"></canvas>
            </div>

        </div>

        <!-- Workout List -->
        <div class="bg-slate-800/50 backdrop-blur p-6 rounded-xl border border-slate-700 mb-8">
            <h2 class="text-xl font-bold mb-4">📋 Recent Workouts (Last 30 Days)</h2>
            <div class="overflow-x-auto">
                <table class="w-full text-left">
                    <thead class="border-b border-slate-600">
                        <tr>
                            <th class="py-3 px-4">Date</th>
                            <th class="py-3 px-4">Type</th>
                            <th class="py-3 px-4">Name</th>
                            <th class="py-3 px-4">Duration</th>
                            <th class="py-3 px-4">Distance</th>
                            <th class="py-3 px-4">Calories</th>
                            <th class="py-3 px-4">HR Avg/Max</th>
                        </tr>
                    </thead>
                    <tbody>
                        {workout_rows}
                    </tbody>
                </table>
            </div>
        </div>

        <!-- Raw Data Section -->
        <details class="bg-slate-800/30 rounded-xl border border-slate-700 mb-8">
            <summary class="p-4 cursor-pointer hover:bg-slate-800/50">📦 Raw JSON Data (Click to expand)</summary>
            <pre class="p-4 text-xs overflow-x-auto text-gray-300">{json_data}</pre>
        </details>

    </div>

    <script>
        // Chart.js configurations
        const dates = {dates};
        const stepsData = {steps_data};
        const sleepData = {sleep_data};
        const hrData = {hr_data};
        const workoutTypes = {workout_types};
        const workoutCounts = {workout_counts};

        // Steps Chart
        new Chart(document.getElementById('stepsChart'), {{
            type: 'bar',
            data: {{
                labels: dates,
                datasets: [{{
                    label: 'Steps',
                    data: stepsData,
                    backgroundColor: 'rgba(59, 130, 246, 0.7)',
                    borderColor: 'rgba(59, 130, 246, 1)',
                    borderWidth: 1
                }}]
            }},
            options: {{
                responsive: true,
                maintainAspectRatio: false,
                plugins: {{ legend: {{ display: false }} }},
                scales: {{
                    y: {{ beginAtZero: true, grid: {{ color: 'rgba(255,255,255,0.1)' }} }},
                    x: {{ grid: {{ display: false }} }}
                }}
            }}
        }});

        // Sleep Chart
        new Chart(document.getElementById('sleepChart'), {{
            type: 'line',
            data: {{
                labels: dates,
                datasets: [{{
                    label: 'Sleep (hours)',
                    data: sleepData,
                    borderColor: 'rgba(168, 85, 247, 1)',
                    backgroundColor: 'rgba(168, 85, 247, 0.1)',
                    fill: true,
                    tension: 0.4
                }}]
            }},
            options: {{
                responsive: true,
                maintainAspectRatio: false,
                plugins: {{ legend: {{ display: false }} }},
                scales: {{
                    y: {{ beginAtZero: true, grid: {{ color: 'rgba(255,255,255,0.1)' }} }},
                    x: {{ grid: {{ display: false }} }}
                }}
            }}
        }});

        // Heart Rate Chart
        new Chart(document.getElementById('hrChart'), {{
            type: 'line',
            data: {{
                labels: dates,
                datasets: [{{
                    label: 'Resting HR',
                    data: hrData,
                    borderColor: 'rgba(239, 68, 68, 1)',
                    backgroundColor: 'rgba(239, 68, 68, 0.1)',
                    fill: true,
                    tension: 0.4
                }}]
            }},
            options: {{
                responsive: true,
                maintainAspectRatio: false,
                plugins: {{ legend: {{ display: false }} }},
                scales: {{
                    y: {{ beginAtZero: false, grid: {{ color: 'rgba(255,255,255,0.1)' }} }},
                    x: {{ grid: {{ display: false }} }}
                }}
            }}
        }});

        // Workout Types Chart
        new Chart(document.getElementById('workoutChart'), {{
            type: 'doughnut',
            data: {{
                labels: workoutTypes,
                datasets: [{{
                    data: workoutCounts,
                    backgroundColor: [
                        'rgba(59, 130, 246, 0.8)',
                        'rgba(16, 185, 129, 0.8)',
                        'rgba(245, 158, 11, 0.8)',
                        'rgba(239, 68, 68, 0.8)',
                        'rgba(168, 85, 247, 0.8)',
                        'rgba(236, 72, 153, 0.8)',
                    ]
                }}]
            }},
            options: {{
                responsive: true,
                maintainAspectRatio: false,
                plugins: {{
                    legend: {{ position: 'bottom' }}
                }}
            }}
        }});
    </script>

</body>
</html>"""

    # Prepare data for template
    dates = [d['date'] for d in data['daily']]
    steps_data = [d.get('summary', {}).get('steps', 0) for d in data['daily']]
    sleep_data = [d.get('sleep', {}).get('duration_hours', 0) for d in data['daily']]
    hr_data = [d.get('heart_rate', {}).get('resting', 0) for d in data['daily']]

    # Workout types distribution
    workout_type_counts = {}
    for w in workouts:
        t = w.get('type', 'Unknown')
        workout_type_counts[t] = workout_type_counts.get(t, 0) + 1

    workout_types = list(workout_type_counts.keys())
    workout_counts = list(workout_type_counts.values())

    # Format workout rows
    workout_rows = ""
    for w in sorted(workouts, key=lambda x: x.get('date', ''), reverse=True)[:30]:
        hr_avg = w.get('heart_rate_avg', 0)
        hr_max = w.get('heart_rate_max', 0)
        workout_rows += f"""
        <tr class="border-b border-slate-700 hover:bg-slate-700/30">
            <td class="py-3 px-4">{w.get('date', 'N/A')[:10]}</td>
            <td class="py-3 px-4 capitalize">{w.get('type', 'Unknown')}</td>
            <td class="py-3 px-4">{w.get('name', 'Unnamed')}</td>
            <td class="py-3 px-4">{w.get('duration_minutes', 0)} min</td>
            <td class="py-3 px-4">{w.get('distance_km', 0)} km</td>
            <td class="py-3 px-4">{w.get('calories', 0)} cal</td>
            <td class="py-3 px-4">{hr_avg}/{hr_max} bpm</td>
        </tr>"""

    # Render template
    html = html_template.format(
        start=data['period']['start'],
        end=data['period']['end'],
        timestamp=data['timestamp'],
        total_steps=data['summary_stats']['total_steps'],
        avg_steps=int(data['summary_stats']['avg_daily_steps']),
        total_calories=data['summary_stats']['total_calories'],
        active_minutes=data['summary_stats']['total_active_minutes'],
        avg_sleep=data['summary_stats']['avg_sleep_hours'],
        avg_quality=data['summary_stats']['avg_sleep_quality'],
        total_workouts=data['summary_stats']['total_workouts'],
        total_workout_minutes=data['summary_stats']['total_workout_minutes'],
        workout_calories=data['summary_stats']['total_workout_calories'],
        dates=json.dumps(dates),
        steps_data=json.dumps(steps_data),
        sleep_data=json.dumps(sleep_data),
        hr_data=json.dumps(hr_data),
        workout_types=json.dumps(workout_types),
        workout_counts=json.dumps(workout_counts),
        workout_rows=workout_rows,
        json_data=json.dumps(data, indent=2)
    )

    # Write HTML file
    with open(output_path, 'w') as f:
        f.write(html)

    print(f"✅ Dashboard generated: {output_path}")

if __name__ == "__main__":
    print("🚀 Starting 30-Day Garmin Health Sync")
    print("=" * 60)

    # Check dependencies first
    try:
        import garth
        import garminconnect
    except ImportError as e:
        print(f"❌ Missing dependency: {e}")
        print("\nInstall with: pip install garth garminconnect")
        sys.exit(1)

    # Sync data
    data = sync_30_days()
    if not data:
        print("❌ Failed to sync data")
        sys.exit(1)

    print("\n✅ 30-day sync complete!")
    print(f"   📊 Days fetched: {len(data['daily'])}")
    print(f"   🏃 Workouts: {len(data['workouts'])}")
    print(f"   📈 Total steps: {data['summary_stats']['total_steps']:,}")

    # Save raw data
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    data_file = f"/tmp/garmin_30days_{timestamp}.json"
    with open(data_file, 'w') as f:
        json.dump(data, f, indent=2)
    print(f"   💾 Raw data saved: {data_file}")

    # Generate dashboard
    dashboard_file = f"/tmp/garmin_dashboard_{timestamp}.html"
    generate_dashboard_html(data, dashboard_file)
    print(f"   📊 Dashboard generated: {dashboard_file}")

    # Also save latest reference
    latest_json = "/tmp/garmin_latest.json"
    latest_html = "/tmp/garmin_latest.html"
    with open(latest_json, 'w') as f:
        json.dump(data, f, indent=2)
    with open(latest_html, 'w') as f:
        with open(dashboard_file, 'r') as src:
            f.write(src.read())

    print(f"   🔗 Latest links updated: {latest_json}, {latest_html}")

    print("\n✅ All tasks completed successfully!")


path: scripts/garmin-sync-oauth.py
content: #!/usr/bin/env python3
"""
Garmin Connect Data Sync (OAuth version)
NEW: Garmin now uses OAuth instead of username/password
"""

import json
import os
from datetime import datetime
import sys

try:
    from garth import Client
except ImportError:
    print("❌ garth not installed. Run: pip install garth garminconnect")
    sys.exit(1)

def get_garth_client():
    """Get authenticated Garmin client using Garth (OAuth)"""
    
    garth_cache_dir = os.path.expanduser("~/.garth")
    cache_file = os.path.join(garth_cache_dir, "session.json")
    
    client = Client()
    
    # Try to load existing session
    if os.path.exists(cache_file):
        try:
            client.load(cache_file)
            return client
        except Exception as e:
            print(f"⚠️ Session expired, need to re-authenticate: {e}")
    
    print("❌ No OAuth session found")
    print("\nGarmin now requires OAuth authentication (browser-based)")
    print("\nSetup instructions:")
    print("1. Install garth CLI: pip install garth-cli")
    print("2. Run: garth auth [REDACTED_SECRET]")
    print("3. Follow the browser prompt to sign in")
    print("4. Come back here and run: python3 garmin-sync-oauth.py")
    
    sys.exit(1)

def sync_all_data():
    """Sync all Garmin data using OAuth"""
    client = get_garth_client()
    if not client:
        return None
    
    # Load config for cache location
    config_path = os.path.expanduser('~/.clawdbot/garmin-config.json')
    cache_file = '/tmp/garmin-cache.json'
    
    if os.path.exists(config_path):
        with open(config_path, 'r') as f:
            config = json.load(f)
            cache_file = config.get('cache_file', cache_file)
    
    today = datetime.now().date()
    data = {
        'timestamp': datetime.now().isoformat(),
        'date': str(today),
    }
    
    try:
        # Get user summary
        summary = client.get_user_summary(today.isoformat())
        
        data['summary'] = {
            'steps': summary.get('totalSteps', 0),
            'heart_rate': summary.get('heartRateData', {}).get('restingHeartRate', 0),
            'calories': summary.get('totalKilocalories', 0),
            'active_minutes': summary.get('totalIntensityMinutes', 0),
            'distance_km': round(summary.get('totalDistance', 0) / 1000, 2),
        }
        
        # Get sleep data
        try:
            sleep_data = client.get_sleep_data(today.isoformat())
            if sleep_data:
                data['sleep'] = {
                    'duration_hours': round(sleep_data.get('duration', 0) / 3600, 1),
                    'quality': sleep_data.get('qualityScore', 0),
                    'deep_sleep': round(sleep_data.get('deepSleepSeconds', 0) / 3600, 1),
                    'rem_sleep': round(sleep_data.get('remSleepSeconds', 0) / 3600, 1),
                    'light_sleep': round(sleep_data.get('lightSleepSeconds', 0) / 3600, 1),
                }
            else:
                data['sleep'] = {'duration_hours': 0, 'quality': 0}
        except:
            data['sleep'] = {'duration_hours': 0, 'quality': 0}
        
        # Get heart rate
        try:
            hr = summary.get('heartRateData', {})
            data['heart_rate_details'] = {
                'resting': hr.get('restingHeartRate', 0),
                'max': hr.get('maxHeartRate', 0),
            }
        except:
            data['heart_rate_details'] = {}
        
        # Get activities/workouts
        try:
            activities = client.get_activities(0, 1)
            data['workouts'] = []
            if activities:
                for a in activities[:5]:
                    data['workouts'].append({
                        'type': a.get('activityType', 'Unknown'),
                        'distance_km': round(a.get('distance', 0) / 1000, 2),
                        'duration_min': round(a.get('duration', 0) / 60, 1),
                        'calories': a.get('calories', 0),
                    })
        except:
            data['workouts'] = []
        
        # Save cache
        os.makedirs(os.path.dirname(cache_file), exist_ok=True)
        with open(cache_file, 'w') as f:
            json.dump(data, f, indent=2)
        
        print(json.dumps(data, indent=2))
        return data
        
    except Exception as e:
        print(f"❌ Sync error: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    sync_all_data()


path: scripts/garmin-sync.py
content: #!/usr/bin/env python3
"""
Garmin Connect Data Sync
Syncs all fitness data using saved OAuth session
"""

import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path

try:
    from garth import Client
    from garminconnect import Garmin
except ImportError:
    print("❌ Dependencies not installed. Run: pip install -r requirements.txt")
    sys.exit(1)

def load_garth_session():
    """Load saved Garmin OAuth session"""
    session_file = Path.home() / ".garth" / "session.json"
    
    if not session_file.exists():
        print(f"❌ No OAuth session found at {session_file}")
        print("\nRun: python3 garmin-auth.py <email> <password>")
        return None
    
    try:
        client = Client()
        client.load(str(session_file))
        return client
    except Exception as e:
        print(f"❌ Failed to load session: {e}")
        return None

def get_daily_summary(garth_client, date_str):
    """Get daily summary: steps, HR, calories, active minutes"""
    
    data = {
        'steps': 0,
        'heart_rate_resting': 0,
        'calories': 0,
        'active_minutes': 0,
        'distance_km': 0,
    }
    
    try:
        # Use Garminconnect with garth session
        gc = Garmin()
        gc.garth = garth_client
        
        summary = gc.get_user_summary(date_str)
        
        data['steps'] = summary.get('totalSteps', 0)
        data['heart_rate_resting'] = summary.get('restingHeartRate', 0)
        data['calories'] = summary.get('totalKilocalories', 0)
        data['active_minutes'] = summary.get('totalIntensityMinutes', 0)
        data['distance_km'] = round(summary.get('totalDistance', 0) / 1000, 2)
        
    except Exception as e:
        print(f"⚠️  Daily summary error: {e}", file=sys.stderr)
    
    return data

def get_sleep_data(garth_client, date_str):
    """Get sleep data: duration, quality, deep/REM sleep"""
    
    data = {
        'duration_hours': 0,
        'duration_minutes': 0,
        'quality_percent': 0,
        'deep_sleep_hours': 0,
        'rem_sleep_hours': 0,
        'light_sleep_hours': 0,
        'awake_minutes': 0,
    }
    
    try:
        gc = Garmin()
        gc.garth = garth_client
        
        sleep = gc.get_sleep_data(date_str)
        
        if sleep and 'dailySleepDTO' in sleep:
            s = sleep['dailySleepDTO']
            
            duration_sec = s.get('sleepTimeSeconds', 0)
            data['duration_hours'] = round(duration_sec / 3600, 1)
            data['duration_minutes'] = round(duration_sec / 60, 0)
            data['quality_percent'] = s.get('sleepQualityPercentage', 0)
            data['deep_sleep_hours'] = round(s.get('deepSleepSeconds', 0) / 3600, 1)
            data['rem_sleep_hours'] = round(s.get('remSleepSeconds', 0) / 3600, 1)
            data['light_sleep_hours'] = round(s.get('lightSleepSeconds', 0) / 3600, 1)
            data['awake_minutes'] = round(s.get('awakeTimeSeconds', 0) / 60, 0)
        
    except Exception as e:
        print(f"⚠️  Sleep data error: {e}", file=sys.stderr)
    
    return data

def get_workouts(garth_client):
    """Get recent workouts"""
    
    workouts = []
    
    try:
        gc = Garmin()
        gc.garth = garth_client
        
        activities = gc.get_activities(0, 20)  # Last 20 workouts
        
        for activity in activities[:10]:  # Return last 10
            workout = {
                'type': activity.get('activityType', 'Unknown'),
                'name': activity.get('activityName', 'Unnamed'),
                'distance_km': round(activity.get('distance', 0) / 1000, 2),
                'duration_minutes': round(activity.get('duration', 0) / 60, 0),
                'calories': activity.get('calories', 0),
                'heart_rate_avg': activity.get('avgHeartRate', 0),
                'heart_rate_max': activity.get('maxHeartRate', 0),
                'timestamp': activity.get('startTimeInSeconds', 0),
            }
            workouts.append(workout)
    
    except Exception as e:
        print(f"⚠️  Workouts error: {e}", file=sys.stderr)
    
    return workouts

def sync_all(output_file=None):
    """Sync all Garmin data"""
    
    garth_client = load_garth_session()
    if not garth_client:
        return None
    
    today = datetime.now().strftime("%Y-%m-%d")
    
    # Collect all data
    all_data = {
        'timestamp': datetime.now().isoformat(),
        'date': today,
        'summary': get_daily_summary(garth_client, today),
        'sleep': get_sleep_data(garth_client, today),
        'workouts': get_workouts(garth_client),
    }
    
    # Save to file if specified
    if output_file:
        os.makedirs(os.path.dirname(output_file), exist_ok=True)
        with open(output_file, 'w') as f:
            json.dump(all_data, f, indent=2)
    
    # Print JSON to stdout
    print(json.dumps(all_data, indent=2))
    
    return all_data

if __name__ == "__main__":
    
    # Default cache file
    cache_file = os.path.expanduser('~/.clawdbot/.garmin-cache.json')
    
    # Use custom path if provided
    if len(sys.argv) > 1:
        cache_file = sys.argv[1]
    
    sync_all(cache_file)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)