Skill: lovefromio/lovefromio-garmin-health-analysis (version 1.2.4)

--- name: garmin-health-analysis description: Talk to your Garmin data naturally - "what was my fastest speed snowboarding?", "how did I sleep last night?", "what was my heart rate at 3pm?". Access 20+ metrics (sleep stages, Body Battery, HRV, VO2 max, training readiness, body composition, SPO2), download FIT/GPX files for route analysis, query elevation/pace at any point, and generate interactive health dashboards. From casual "show me this week's workouts" to deep "analyze my recovery vs training load". version: 1.2.2 author: EversonL & Claude homepage: https://github.com/eversonl/ClawdBot-garmin-health-analysis metadata: {"clawdbot":{"emoji":"⌚","requires":{"env":["GARMIN_EMAIL","GARMIN_PASSWORD"]},"install":[{"id":"garminconnect","kind":"python","package":"garminconnect","label":"Install garminconnect (pip)"},{"id":"fitparse","kind":"python","package":"fitparse","label":"Install fitparse (pip)"},{"id":"gpxpy","kind":"python","package":"gpxpy","label":"Install gpxpy (pip)"}]}} --- # Garmin Health Analysis Query health metrics from Garmin Connect and generate interactive HTML charts. ## Two Installation Paths This skill supports **two different setups**: 1. **Clawdbot Skill** (this guide) - Use with Clawdbot for automation and proactive health monitoring 2. **MCP Server** ([see MCP setup guide](references/mcp_setup.md)) - Use with standard Claude Desktop as an MCP server Choose the path that matches your use case. You can also use both simultaneously! --- ## Clawdbot Skill Setup (first time only) ### 1. Install Dependencies ```bash pip3 install garminconnect ``` ### 2. Configure Credentials You have three options to provide your Garmin Connect credentials: #### Option A: Clawdbot Config (Recommended - UI configurable) Add credentials to `~/.clawdbot/clawdbot.json`: ```json { "skills": { "entries": { "garmin-health-analysis": { "enabled": true, "env": { "GARMIN_EMAIL": "[REDACTED_SECRET]", "GARMIN_PASSWORD": "your-password" } } } } } ``` **Tip**: You can also set these through the Clawdbot UI in the Skills settings panel. #### Option B: Local Config File Create a config file in the skill directory: ```bash cd ~/.clawdbot/skills/garmin-health-analysis # or: cd <workspace>/skills/garmin-health-analysis cp config.example.json config.json # Edit config.json and add your email and password ``` **config.json:** ```json { "email": "[REDACTED_SECRET]", "password": "your-password" } ``` **Note**: `config.json` is gitignored to keep your credentials secure. #### Option C: Command Line Pass credentials directly when authenticating: ```bash python3 scripts/garmin_auth.py login \ --email [REDACTED_SECRET] \ --password YOUR_PASSWORD ``` ### 3. Authenticate Login to Garmin Connect and save session tokens: ```bash python3 scripts/garmin_auth.py login ``` This uses credentials from (in priority order): 1. Command line arguments (`--email`, `--password`) 2. Local config file (`config.json`) 3. Environment variables (`GARMIN_EMAIL`, `GARMIN_PASSWORD`) 4. Clawdbot config (`skills.entries.garmin-health-analysis.env`) Session tokens are stored in `~/.clawdbot/garmin-tokens.json` and auto-refresh. Check authentication status: ```bash python3 scripts/garmin_auth.py status ``` ## Fetching Data Use `scripts/garmin_data.py` to get JSON data: ```bash # Sleep (last 7 days default) python3 scripts/garmin_data.py sleep --days 14 # Body Battery (Garmin's recovery metric) python3 scripts/garmin_data.py body_battery --days 30 # HRV data python3 scripts/garmin_data.py hrv --days 30 # Heart rate (resting, max, min) python3 scripts/garmin_data.py heart_rate --days 7 # Activities/workouts python3 scripts/garmin_data.py activities --days 30 # Stress levels python3 scripts/garmin_data.py stress --days 7 # Combined summary with averages python3 scripts/garmin_data.py summary --days 7 # Custom date range python3 scripts/garmin_data.py sleep --start 2026-01-01 --end 2026-01-15 # User profile python3 scripts/garmin_data.py profile ``` Output is JSON to stdout. Parse it to answer user questions. ## Generating Charts Use `scripts/garmin_chart.py` for interactive HTML visualizations: ```bash # Sleep analysis (hours + scores) python3 scripts/garmin_chart.py sleep --days 30 # Body Battery recovery chart (color-coded) python3 scripts/garmin_chart.py body_battery --days 30 # HRV & resting heart rate trends python3 scripts/garmin_chart.py hrv --days 90 # Activities summary (by type, calories) python3 scripts/garmin_chart.py activities --days 30 # Full dashboard (all 4 charts) python3 scripts/garmin_chart.py dashboard --days 30 # Save to specific file python3 scripts/garmin_chart.py dashboard --days 90 --output ~/Desktop/garmin-health.html ``` Charts open automatically in the default browser. They use Chart.js with a modern gradient design, stat cards, and interactive tooltips. ## Answering Questions | User asks | Action | |-----------|--------| | "How did I sleep last night?" | `garmin_data.py summary --days 1`, report sleep hours + score | | "How's my recovery this week?" | `garmin_data.py body_battery --days 7`, report average + trend | | "Show me my health for the last month" | `garmin_chart.py dashboard --days 30` | | "Is my HRV improving?" | `garmin_data.py hrv --days 30`, analyze trend | | "What workouts did I do this week?" | `garmin_data.py activities --days 7`, list activities with details | | "How's my resting heart rate?" | `garmin_data.py heart_rate --days 7`, report average + trend | ## Key Metrics ### Body Battery (0-100) Garmin's proprietary recovery metric based on HRV, stress, sleep, and activity: - **High (75-100)**: Fully recharged, ready for high intensity - **Medium (50-74)**: Moderate energy, good for regular activity - **Low (25-49)**: Limited energy, recovery needed - **Very Low (0-24)**: Depleted, prioritize rest ### Sleep Scores (0-100) Overall sleep quality based on duration, stages, and disturbances: - **Excellent (90-100)**: Optimal restorative sleep - **Good (80-89)**: Quality sleep with minor issues - **Fair (60-79)**: Adequate but could improve - **Poor (0-59)**: Significant sleep deficiencies ### HRV (Heart Rate Variability) Measured in milliseconds, higher is generally better: - Indicates nervous system balance and recovery capacity - Track **trends** over time (increasing = improving recovery) - Affected by sleep, stress, training load, illness - Normal range varies by individual (20-200+ ms) ### Resting Heart Rate (bpm) Lower generally indicates better cardiovascular fitness: - **Athletes**: 40-60 bpm - **Fit adults**: 60-70 bpm - **Average adults**: 70-80 bpm - Sudden increases may indicate stress, illness, or overtraining ### Stress Levels Based on HRV analysis throughout the day: - **Low stress**: Rest and recovery periods - **Medium stress**: Normal daily activities - **High stress**: Physical activity or mental pressure ## Health Analysis When users ask for insights or want to understand their trends, use `references/health_analysis.md` for: - Science-backed interpretation of all metrics - Normal ranges by age and fitness level - Pattern detection (weekly trends, recovery cycles, training load balance) - Actionable recommendations based on data - Warning signs that suggest rest or medical consultation ### Analysis workflow 1. Fetch data: `python3 scripts/garmin_data.py summary --days N` 2. Read `references/health_analysis.md` for interpretation framework 3. Apply the analysis framework: Status → Trends → Patterns → Insights → Recommendations 4. Always include disclaimer that this is informational, not medical advice ## Troubleshooting ### Authentication Issues - **"Invalid credentials"**: Double-check email/password, try logging into Garmin Connect web - **"Tokens expired"**: Run login again: `python3 scripts/garmin_auth.py login ...` - **"Too many requests"**: Garmin rate-limits; wait a few minutes and try again ### Missing Data - Some metrics require specific Garmin devices (Body Battery needs HRV-capable devices) - Historical data may have gaps if device wasn't worn - New accounts may have limited history ### Library Issues - If `garminconnect` import fails: `pip3 install --upgrade garminconnect` - Garmin occasionally changes their API; update the library if requests fail ## Privacy Note - Credentials are stored locally in `~/.clawdbot/garmin-tokens.json` - Session tokens refresh automatically - No data is sent anywhere except to Garmin's official servers - You can revoke access anytime by deleting the tokens file ## Comparison: Garmin vs Whoop | Feature | Garmin | Whoop | |---------|--------|-------| | **Recovery metric** | Body Battery (0-100) | Recovery Score (0-100%) | | **HRV tracking** | Yes (nightly average) | Yes (detailed) | | **Sleep stages** | Light, Deep, REM, Awake | Light, SWS, REM, Awake | | **Activity tracking** | Built-in GPS, many sport modes | Strain score (0-21) | | **Stress** | All-day stress levels | Not directly tracked | | **API** | Unofficial (garminconnect) | Official OAuth | | **Device types** | Watches, fitness trackers | Wearable band only | ## References - `references/api.md` — Garmin Connect API details (unofficial) - `references/health_analysis.md` — Science-backed health data interpretation - [garminconnect library](https://github.com/cyberjunky/python-garminconnect) — Python API wrapper - [Garmin Connect](https://connect.garmin.com) — Official web interface ## Version Info - **Created**: 2026-01-25 - **Author**: EversonL & Claude - **Version**: 1.2.0 - **Dependencies**: garminconnect, fitparse, gpxpy (Python libraries) - **License**: MIT

path: CHANGELOG.md
content: # Changelog

## v1.2.2 (2026-01-26)

### 🧹 Repository Cleanup & Focus

**Removed old MCP server files** - Now using dedicated MCP server repo for Claude Desktop users:
- Removed `CLAUDE_DESKTOP.md` (old MCP setup docs)
- Removed `mcp_server.py` (old Python MCP server - 750+ lines)
- Removed `requirements.txt` (old MCP dependencies)

**Updated documentation for clarity:**
- README.md now clearly identifies this as the Clawdbot skill
- Added prominent callout directing Claude Desktop users to the dedicated MCP server repo
- Simplified `references/mcp_setup.md` to redirect to https://github.com/eversonl/garmin-health-mcp-server
- Updated file tree to reflect current structure

**New dedicated MCP server** for Claude Desktop users:
- Purpose-built Node.js MCP server at https://github.com/eversonl/garmin-health-mcp-server
- Uses modern @modelcontextprotocol/sdk
- Easy `npm install` + `npm run auth` setup
- Comprehensive installation guide and troubleshooting

**Both can coexist** - Shared authentication allows users to install both the Clawdbot skill and MCP server simultaneously.

**This repo is now focused exclusively on the Clawdbot skill** for automated health monitoring, scheduled reports, and proactive check-ins.

## v1.2.0 (2026-01-26)

### 🚀 Major Feature: MCP Server for Claude Desktop & Code

**NEW: Works with Claude Desktop, Claude Code, and any MCP client!**

- Added `mcp_server.py` - full MCP (Model Context Protocol) server implementation
- Exposes all Garmin tools to Claude Desktop, Claude Code, and other MCP-compatible clients
- New installation guide: `CLAUDE_DESKTOP.md` with step-by-step setup
- 14 MCP tools available:
  - Health metrics (sleep, Body Battery, HRV, activities, heart rate)
  - Time-based queries (HR/stress/BB at specific times)
  - Extended metrics (training readiness, body composition, SPO2)
  - Activity file downloads and analysis
- Updated README with Claude Desktop quick start
- Added `mcp` to requirements.txt

**Now you can use this skill with:**
- ✅ Clawdbot (original)
- ✅ Claude Desktop (new!)
- ✅ Claude Code / VS Code extension (new!)
- ✅ Any MCP-compatible client (new!)

## v1.1.5 (2026-01-25)

### Metadata
- Expanded ClawdHub description with conversational examples and "talk to your data" messaging
- Showcases real use cases: "what was my fastest speed?", activity analysis, recovery tracking

## v1.1.3 (2026-01-25)

### Documentation
- Updated Version Info section in SKILL.md with correct author and all dependencies

## v1.1.2 (2026-01-25)

### Metadata
- Updated description to highlight v1.1+ features (time queries, 20+ metrics, FIT/GPX analysis)
- Changed author to "EversonL & Claude"

## v1.1.1 (2026-01-25)

### Documentation
- Updated metadata with new dependencies (fitparse, gpxpy)

## v1.1.0 (2026-01-25)

### 🚀 Major Features

**Time-Based Queries**
- New `garmin_query.py` script for time-based questions
- Ask "what was my heart rate at 3pm?" and get instant answers
- Supports heart rate, stress, Body Battery, steps
- Flexible time parsing (12/24 hour formats)

**Extended Metrics** (`garmin_data_extended.py`)
- Training readiness & training status
- Body composition (weight, body fat %, muscle mass, BMI)
- Weight tracking over time
- SPO2 (blood oxygen saturation)
- Detailed respiration data
- Intraday steps, floors climbed
- Intensity minutes (vigorous/moderate activity)
- Hydration tracking
- Time-series stress data
- Max metrics (VO2 max, fitness age, endurance/hill scores)
- Intraday heart rate (all samples)

**Activity File Analysis** (`garmin_activity_files.py`)
- Download FIT/GPX/TCX files from activities
- Parse FIT files for GPS, elevation, HR, cadence, power
- Parse GPX files for route visualization
- Query data at specific distances ("what was my elevation at mile 2?")
- Query data at specific times during activities
- Comprehensive activity analysis & statistics
- Support for advanced use cases (route mapping, pace analysis, elevation profiles)

**Documentation**
- New `references/extended_capabilities.md` with comprehensive usage examples
- Updated dependencies (fitparse, gpxpy)

## v1.0.1 (2026-01-25)

### Bug Fixes
- Fixed sleep data extraction - properly parse nested `dailySleepDTO` object from Garmin API
- Sleep time and scores now display correctly in all charts and dashboards

## v1.0.0 (2026-01-25)

### Initial Release

**Features:**
- Fetch health data from Garmin Connect (sleep, Body Battery, HRV, heart rate, activities, stress)
- Generate interactive HTML charts with Chart.js
- Science-backed health analysis framework
- Support for multiple credential configuration methods
- Automatic token refresh

**Data Available:**
- Sleep: duration, stages (deep/light/REM), scores, HRV during sleep
- Body Battery: Garmin's recovery metric (0-100)
- HRV: nightly heart rate variability with baseline tracking
- Heart Rate: resting, max, min throughout the day
- Activities: workouts with calories, duration, heart rate, GPS data
- Stress: all-day stress levels based on HRV analysis

**Charts:**
- Sleep analysis (hours + scores)
- Body Battery recovery (color-coded by level)
- HRV & Resting Heart Rate trends
- Activities summary (by type with calories)
- Full dashboard combining all metrics

**Configuration:**
- UI-configurable via Clawdbot config (`skills.entries.garmin-health-analysis.env`)
- Local config.json support
- Command-line arguments
- Environment variables

**Requirements:**
- Python 3.7+
- garminconnect library (installed via pip)
- Garmin Connect account with wearable device

**Security:**
- Session tokens stored locally in `~/.clawdbot/garmin/`
- Tokens auto-refresh
- No data sent anywhere except Garmin's official servers


path: README.md
content: # Garmin Health Analysis - Clawdbot Skill

> **Talk to your Garmin data naturally** - "what was my fastest speed snowboarding?", "how did I sleep last night?", "what was my heart rate at 3pm?"

Access 20+ metrics from your Garmin device: sleep stages, Body Battery, HRV, VO2 max, training readiness, body composition, SPO2, and more. Download FIT/GPX files, query elevation/pace at any point, and generate interactive health dashboards.

## 🔵 Looking for Claude Desktop? 

**This is the Clawdbot skill repo.** For standard Claude Desktop, use the dedicated MCP server:

👉 **[garmin-health-mcp-server](https://github.com/eversonl/garmin-health-mcp-server)** - Node.js MCP server for Claude Desktop

---

## 🚀 Clawdbot Installation

**Best for**: Automated health monitoring, scheduled reports, proactive check-ins

```bash
# Install via clawdhub
clawdhub install garmin-health-analysis

# Or manually
cd ~/.clawdbot/skills
git clone https://github.com/eversonl/ClawdBot-garmin-health-analysis.git garmin-health-analysis

# Install dependencies
pip3 install garminconnect fitparse gpxpy

# Configure credentials and authenticate
python3 scripts/garmin_auth.py login
```

**[📖 Full Setup Guide](SKILL.md)**

## ⚡ Features

- **Natural language queries**: "How's my recovery this week?" → instant Body Battery analysis
- **Sleep analysis**: Hours, stages (light/deep/REM), quality scores, trends
- **Recovery tracking**: Body Battery, HRV, training readiness, stress levels
- **Workout data**: Activities by type, calories, duration, pace, elevation
- **Health metrics**: Resting heart rate, VO2 max, body composition, SPO2
- **Activity files**: Download FIT/GPX for detailed route and performance analysis
- **Interactive charts**: Beautiful HTML dashboards with Chart.js visualizations
- **Science-backed insights**: Interpret trends with expert analysis framework

## 📊 Example Queries

**Clawdbot or Claude Desktop:**

> "How did I sleep last night?"
> 
> "Show me my health dashboard for the last month"
> 
> "Is my HRV improving?"
> 
> "What was my fastest speed during yesterday's bike ride?"
> 
> "How's my recovery vs. training load balance?"
> 
> "Download the GPX file for my Sunday run"

## 🛠️ Key Metrics

| Metric | Range | What It Means |
|--------|-------|---------------|
| **Body Battery** | 0-100 | Garmin's recovery score (higher = more energy) |
| **Sleep Score** | 0-100 | Overall sleep quality (90+ = excellent) |
| **HRV** | 20-200+ ms | Heart rate variability (higher = better recovery) |
| **Resting HR** | 40-80 bpm | Lower is generally better (athletes: 40-60) |
| **Stress** | Low/Med/High | Based on HRV throughout the day |

## 📦 What's Included

```
garmin-health-analysis/
├── SKILL.md                       # Clawdbot setup & usage
├── README.md                      # This file
├── install.sh                     # Automated installation script
├── scripts/
│   ├── garmin_auth.py            # Authentication helper
│   ├── garmin_data.py            # Fetch health metrics (JSON)
│   ├── garmin_chart.py           # Generate HTML charts
│   ├── garmin_data_extended.py   # Extended metrics (VO2, readiness, etc.)
│   ├── garmin_activity_files.py  # Download FIT/GPX files
│   └── garmin_query.py           # Time-based queries
├── references/
│   ├── health_analysis.md        # Metric interpretation guide
│   ├── api.md                    # Garmin Connect API docs
│   └── extended_capabilities.md  # Advanced features
└── config.example.json           # Credentials template
```

## 🔒 Privacy & Security

- Credentials stored locally (never sent to third parties)
- Session tokens auto-refresh (no repeated logins)
- Connects only to Garmin's official API
- No cloud storage or external data sharing
- Open source - audit the code yourself

## 📚 Documentation

- **[SKILL.md](SKILL.md)** - Complete Clawdbot setup, commands, troubleshooting
- **[references/health_analysis.md](references/health_analysis.md)** - Science-backed metric interpretation
- **[references/api.md](references/api.md)** - Garmin Connect API details
- **[references/extended_capabilities.md](references/extended_capabilities.md)** - Advanced features

### Looking for Claude Desktop?
See **[garmin-health-mcp-server](https://github.com/eversonl/garmin-health-mcp-server)** for the dedicated MCP server (you can use both!)

## 🐛 Troubleshooting

**Authentication issues?**
- Run `python3 scripts/garmin_auth.py login` to refresh tokens
- Check credentials in config.json or environment variables
- Try logging into Garmin Connect web to verify account

**Missing data?**
- Some metrics require specific devices (Body Battery needs HRV-capable watches)
- Check device was worn during the time period
- New accounts may have limited history

**Rate limits?**
- Garmin limits API requests - wait a few minutes and try again
- Batch queries when possible (use `summary` instead of individual calls)

## 🙏 Credits

- **Author**: EversonL & Claude
- **Version**: 1.2.0
- **License**: MIT
- **Dependencies**: [python-garminconnect](https://github.com/cyberjunky/python-garminconnect), fitparse, gpxpy

## 🔗 Links

- **Clawdbot**: [clawdbot.com](https://clawdbot.com)
- **ClawdHub**: [clawdhub.com](https://clawdhub.com)
- **Garmin Connect**: [connect.garmin.com](https://connect.garmin.com)

---

**Questions?** Open an issue on GitHub or ask in the Clawdbot Discord!


path: _meta.json
content: ownerId: kn79g0y3dssw91dcngn485stzd7zxmhs
slug: lovefromio-garmin-health-analysis
version: 1.2.2
publishedAt: 1769387065409

path: config.example.json
content: email: [REDACTED_SECRET]
password: your-password

path: config.json
content: email: [REDACTED_SECRET]
password: XSxs366496

path: install.sh
content: #!/usr/bin/env bash
# Garmin Health Analysis - Clawdbot Skill Installation
# Run this after cloning the skill to your Clawdbot skills directory

set -e  # Exit on error

echo "🏃 Installing Garmin Health Analysis Skill..."
echo

# Check Python
if ! command -v python3 &> /dev/null; then
    echo "❌ Error: Python 3 is required but not found"
    exit 1
fi

echo "✓ Python 3 found: $(python3 --version)"

# Install Python dependencies
echo
echo "📦 Installing Python dependencies..."

if pip3 install --user garminconnect fitparse gpxpy 2>/dev/null; then
    echo "✓ Dependencies installed (--user)"
elif pip3 install --break-system-packages garminconnect fitparse gpxpy 2>/dev/null; then
    echo "✓ Dependencies installed (--break-system-packages)"
elif pip3 install garminconnect fitparse gpxpy 2>/dev/null; then
    echo "✓ Dependencies installed (system-wide)"
else
    echo "❌ Failed to install Python dependencies"
    echo "   Try manually: pip3 install --user garminconnect fitparse gpxpy"
    exit 1
fi

# Create config from example if it doesn't exist
if [ ! -f "config.json" ] && [ -f "config.example.json" ]; then
    echo
    echo "📝 Creating config.json from example..."
    cp config.example.json config.json
    echo "✓ config.json created (edit with your credentials)"
fi

# Success
echo
echo "✅ Installation complete!"
echo
echo "Next steps:"
echo "  1. Add your Garmin credentials:"
echo "     - Edit config.json, or"
echo "     - Set GARMIN_EMAIL and GARMIN_PASSWORD env vars, or"
echo "     - Add to ~/.clawdbot/clawdbot.json skills config"
echo
echo "  2. Authenticate:"
echo "     python3 scripts/garmin_auth.py login"
echo
echo "  3. Test:"
echo "     python3 scripts/garmin_data.py summary --days 7"
echo
echo "📖 Read SKILL.md for full documentation"


path: references/api.md
content: # Garmin Connect API Reference (Unofficial)

This documents the unofficial Garmin Connect API accessed via the `garminconnect` Python library.

## Important Notes

- **Unofficial API**: Garmin does not provide a public API for personal use. This library reverse-engineers their web interface.
- **May break**: Garmin can change their API anytime, which may break the library
- **Rate limits**: Garmin implements rate limiting; excessive requests may temporarily block your account
- **Official alternative**: Garmin Health API exists for enterprise partnerships only

## Authentication

### Library: `garminconnect`
```bash
pip3 install garminconnect
```

### Authentication Flow
1. Login with email/password
2. Library handles OAuth token exchange
3. Tokens stored for session persistence
4. Tokens auto-refresh when expired

### Session Management
```python
from garminconnect import Garmin

# Initial login
client = Garmin(email, password)
client.login()

# Save tokens for reuse
oauth1 = client.garth.oauth1_token
oauth2 = client.garth.oauth2_token

# Restore session
client = Garmin()
client.garth.oauth1_token = oauth1
client.garth.oauth2_token = oauth2
```

## Core Endpoints

### User Profile
```python
# Full name
client.get_full_name()

# User summary (daily stats)
client.get_user_summary("2026-01-25")
```

### Sleep Data
```python
# Daily sleep details
client.get_sleep_data("2026-01-25")
```

**Response structure:**
```json
{
  "sleepTimeSeconds": 28800,      // Total sleep duration
  "deepSleepSeconds": 7200,       // Deep sleep
  "lightSleepSeconds": 14400,     // Light sleep
  "remSleepSeconds": 7200,        // REM sleep
  "awakeSleepSeconds": 1800,      // Awake time
  "sleepScores": {
    "overall": {"value": 85},     // Overall sleep score (0-100)
    "duration": {...},
    "quality": {...}
  },
  "restlessMoments": 12,          // Number of restless periods
  "avgSleepHeartRate": 52,        // Average HR during sleep
  "avgSleepHRV": 45,              // Average HRV during sleep
  "avgSleepRespiration": 14       // Respiration rate (breaths/min)
}
```

### HRV Data
```python
# Daily HRV summary
client.get_hrv_data("2026-01-25")
```

**Response structure:**
```json
{
  "hrvSummary": {
    "lastNightAvg": 45,             // Last night's average HRV (ms)
    "lastNight5MinHigh": 68,        // 5-min high
    "lastNight5MinLow": 28,         // 5-min low
    "weeklyAvg": 42,                // 7-day rolling average
    "baselineBalancedLow": 38,      // Personal baseline low
    "baselineBalancedHigh": 48,     // Personal baseline high
    "status": "BALANCED"            // Status: BALANCED, UNBALANCED, POOR, LOW
  }
}
```

### Body Battery
```python
# Daily Body Battery readings (time series)
client.get_body_battery("2026-01-25")
```

**Response structure:**
```json
[
  {
    "timestamp": 1737849600000,     // Unix timestamp (ms)
    "value": 85,                    // Body Battery level (0-100)
    "charged": 45,                  // Amount charged overnight
    "drained": 15                   // Amount drained from previous
  },
  // ... more readings throughout the day
]
```

### Heart Rate
```python
# Daily heart rate summary
client.get_heart_rates("2026-01-25")
```

**Response structure:**
```json
{
  "restingHeartRate": 52,           // Resting HR (bpm)
  "maxHeartRate": 165,              // Max HR of the day
  "minHeartRate": 48                // Min HR of the day
}
```

### Stress Levels
```python
# Daily stress data
client.get_stress_data("2026-01-25")
```

**Response structure:**
```json
{
  "avgStressLevel": 35,             // Average all-day stress (0-100)
  "maxStressLevel": 78,             // Peak stress
  "restStressLevel": 15,            // Stress during rest
  "activityStressLevel": 65,        // Stress during activity
  "lowStressDuration": 14400,       // Seconds in low stress (0-25)
  "mediumStressDuration": 28800,    // Seconds in medium stress (26-50)
  "highStressDuration": 7200        // Seconds in high stress (51-100)
}
```

### Activities
```python
# Activities in date range
client.get_activities_by_date("2026-01-01", "2026-01-31", activitytype="")
```

**Response structure:**
```json
[
  {
    "activityId": 123456789,
    "activityType": {
      "typeKey": "running",
      "typeId": 1
    },
    "activityName": "Morning Run",
    "startTimeLocal": "2026-01-25 07:30:00",
    "duration": 3600,               // Duration in seconds
    "distance": 10000,              // Distance in meters
    "calories": 650,                // Calories burned
    "averageHR": 152,               // Average heart rate
    "maxHR": 178,                   // Max heart rate
    "elevationGain": 120,           // Elevation gain (meters)
    "averageSpeed": 2.78,           // Speed (m/s)
    "averageRunningCadence": 165    // Cadence (steps/min)
  }
]
```

### Steps & Daily Totals
```python
# Daily stats summary
client.get_user_summary("2026-01-25")
```

**Response structure:**
```json
{
  "totalSteps": 12543,
  "totalKilocalories": 2456,
  "activeKilocalories": 856,
  "bmrKilocalories": 1600,
  "intensityMinutesGoal": 150,
  "vigorousIntensityMinutes": 35,
  "moderateIntensityMinutes": 85
}
```

## Data Availability

### Required Device Features

| Metric | Required Hardware |
|--------|------------------|
| **Sleep stages** | Newer Garmin watches (2018+) |
| **Body Battery** | Firstbeat-enabled devices with HRV |
| **HRV** | Devices with optical HR sensor |
| **Stress** | Devices with all-day HR monitoring |
| **VO2 Max** | GPS + HR-enabled activities |
| **Respiration** | Newer devices (Fenix 6+, Venu 2+, etc.) |

### Historical Data
- Most metrics: Available for full account history
- HRV: May be limited on older devices
- Body Battery: Requires compatible device

## Rate Limits

Garmin enforces rate limits on their API:

**Observed limits:**
- ~50-100 requests per 10 minutes (varies)
- Excessive requests trigger temporary IP/account blocks
- Blocks typically last 15-60 minutes

**Best practices:**
- Cache data locally when possible
- Don't poll excessively (once per hour max for updates)
- Use date range queries instead of day-by-day loops when possible
- Add delays between bulk requests (1-2 seconds)

## Error Handling

### Common Errors

**Authentication Failed**
```
GarminConnectAuthenticationError
```
- Invalid credentials
- Account locked
- Two-factor authentication enabled (not supported)

**Connection Error**
```
GarminConnectConnectionError
```
- Network issues
- Garmin servers down
- Rate limit hit

**No Data**
- Returns `None` or empty list
- Device not worn
- Metric not supported on user's device
- Date has no data

## Library Documentation

**GitHub**: https://github.com/cyberjunky/python-garminconnect

**PyPI**: https://pypi.org/project/garminconnect/

## Alternative: Official Garmin Health API

For enterprise/commercial use:
- **Garmin Health API**: Requires partnership agreement
- **Use case**: App integration, health platforms
- **Not for**: Personal hobby projects

More info: https://www.garmin.com/en-US/health-enterprise/

## Comparison to Whoop API

| Feature | Garmin (unofficial) | Whoop (official) |
|---------|-------------------|------------------|
| **Authentication** | Email/password | OAuth 2.0 |
| **Stability** | May break anytime | Stable, versioned |
| **Rate limits** | Undocumented (~50/10min) | Documented (200/hr) |
| **Data access** | Full history | Full history |
| **Support** | Community library | Official support |
| **Terms of Service** | Gray area | Officially supported |

## Terms of Service Considerations

**Important**: Using the unofficial API may violate Garmin's Terms of Service. Use at your own risk:

- For **personal use** only
- Don't build commercial products on this
- Don't scrape excessively
- Consider official Garmin Health API for production use

## Tips for Reliability

1. **Cache aggressively**: Store data locally, only fetch new dates
2. **Error recovery**: Retry with exponential backoff on failures
3. **Monitor library updates**: Watch GitHub for breaking changes
4. **Have a fallback**: Manual CSV export from Garmin Connect web if API breaks
5. **Test before relying**: Verify data accuracy against Garmin Connect web UI

## Useful Resources

- [Garmin Connect Web](https://connect.garmin.com): Official interface
- [python-garminconnect GitHub](https://github.com/cyberjunky/python-garminconnect): Library source
- [Garmin Forums](https://forums.garmin.com): Community support
- [r/Garmin](https://reddit.com/r/Garmin): Reddit community


path: references/extended_capabilities.md
content: # Extended Garmin Capabilities

This skill now supports **comprehensive health tracking, time-based queries, and activity file analysis**.

## 🎯 Time-Based Queries

Ask questions like:
- "What was my heart rate at 3pm yesterday?"
- "What was my stress level at 10:30 this morning?"
- "What was my Body Battery at noon?"

### Usage

```bash
# Heart rate at specific time
python3 scripts/garmin_query.py heart_rate "3:00 PM" --date 2026-01-24

# Stress level
python3 scripts/garmin_query.py stress "14:30"

# Body Battery
python3 scripts/garmin_query.py body_battery "10:00 AM" --date 2026-01-23

# Steps at time
python3 scripts/garmin_query.py steps "17:00"
```

**Time formats supported:**
- `3:00 PM`, `3 PM` (12-hour)
- `15:00`, `15:30:45` (24-hour)
- `2026-01-24 15:30` (full datetime)

## 📊 Extended Metrics

### Training & Performance

```bash
# Training readiness (daily readiness score)
python3 scripts/garmin_data_extended.py training_readiness

# Training status (load, VO2 max trends)
python3 scripts/garmin_data_extended.py training_status

# Endurance score
python3 scripts/garmin_data_extended.py endurance_score

# Hill score
python3 scripts/garmin_data_extended.py hill_score

# Max metrics (VO2 max, etc.)
python3 scripts/garmin_data_extended.py max_metrics

# Fitness age
python3 scripts/garmin_data_extended.py fitness_age
```

### Body Composition & Health

```bash
# Body composition (weight, body fat %, muscle mass, BMI)
python3 scripts/garmin_data_extended.py body_composition --date 2026-01-24

# Weight history
python3 scripts/garmin_data_extended.py weigh_ins --start 2026-01-01 --end 2026-01-24

# Blood oxygen (SPO2)
python3 scripts/garmin_data_extended.py spo2 --date 2026-01-24

# Respiration (breathing rate throughout day)
python3 scripts/garmin_data_extended.py respiration
```

### Activity Metrics

```bash
# Detailed steps (time-series)
python3 scripts/garmin_data_extended.py steps --date 2026-01-24

# Floors climbed
python3 scripts/garmin_data_extended.py floors

# Intensity minutes (vigorous/moderate)
python3 scripts/garmin_data_extended.py intensity_minutes

# Hydration/water intake
python3 scripts/garmin_data_extended.py hydration

# Detailed stress (time-series throughout day)
python3 scripts/garmin_data_extended.py stress_detailed

# Intraday heart rate (all HR samples)
python3 scripts/garmin_data_extended.py hr_intraday
```

## 🗺️ Activity File Analysis (FIT/GPX)

Download and analyze activity files to answer questions like:
- "What was my elevation at mile 2?"
- "What was my pace when my heart rate was above 160?"
- "Show me my route on a map"

### Download Activity Files

```bash
# Download FIT file
python3 scripts/garmin_activity_files.py download --activity-id 12345678 --format fit

# Download GPX file (for GPS visualization)
python3 scripts/garmin_activity_files.py download --activity-id 12345678 --format gpx

# Download TCX file
python3 scripts/garmin_activity_files.py download --activity-id 12345678 --format tcx
```

### Parse Activity Files

```bash
# Parse FIT file (detailed metrics)
python3 scripts/garmin_activity_files.py parse --file /tmp/activity_12345678.fit

# Parse GPX file (GPS track)
python3 scripts/garmin_activity_files.py parse --file /tmp/activity_12345678.gpx
```

**FIT files contain:**
- GPS coordinates (lat/lon)
- Elevation/altitude
- Heart rate
- Cadence (steps/min for running, rpm for cycling)
- Power (watts, for cycling)
- Speed & pace
- Temperature
- Lap splits

### Query Activity Data

```bash
# What was my heart rate/elevation at 1500 meters into the run?
python3 scripts/garmin_activity_files.py query --file /tmp/activity_12345678.fit --distance 1500

# What was my data at a specific time during the activity?
python3 scripts/garmin_activity_files.py query --file /tmp/activity_12345678.fit --time "2026-01-24T10:15:30"
```

### Analyze Activity

```bash
# Get comprehensive statistics
python3 scripts/garmin_activity_files.py analyze --file /tmp/activity_12345678.fit
```

**Returns:**
- Average/max/min heart rate
- Elevation gain/max/min
- Average/max speed
- Average cadence
- Average/max power (cycling)
- Total distance
- Duration

## 🔍 Use Cases

### Health Monitoring
- "How has my VO2 max changed over the past month?"
- "What's my fitness age compared to my actual age?"
- "Show me my training load trend"
- "What was my SPO2 during sleep last night?"

### Activity Analysis
- "Map my running route from yesterday"
- "What was my heart rate at the steepest hill?"
- "Show me my pace per mile with elevation profile"
- "How does my cadence correlate with my heart rate?"

### Recovery Tracking
- "What's my training readiness today?"
- "When did my Body Battery start draining yesterday?"
- "How many intensity minutes did I get this week?"

### Time-Series Analysis
- "Graph my heart rate from 8am to 6pm"
- "Show me stress levels throughout the workday"
- "When was my Body Battery fully charged?"

## 📦 Dependencies

```bash
pip3 install garminconnect fitparse gpxpy
```

- **garminconnect**: Garmin Connect API wrapper
- **fitparse**: Parse FIT files (Garmin's binary format)
- **gpxpy**: Parse GPX files (GPS track format)

## 🛠️ Advanced Tips

### Get Activity ID

The activity ID is visible in the Garmin Connect URL:
```
https://connect.garmin.com/modern/activity/12345678
                                        ^^^^^^^^
```

Or find it programmatically:
```bash
python3 scripts/garmin_data.py activities --days 7
# Look for "activity_id" in each activity
```

### Batch Processing

```bash
# Get recent activity IDs
activities=$(python3 scripts/garmin_data.py activities --days 7 | jq -r '.activities[].activityId')

# Download all FIT files
for id in $activities; do
  python3 scripts/garmin_activity_files.py download --activity-id $id --format fit
done
```

### Visualization Ideas

1. **Route Maps**: Use GPX files with Leaflet.js or Google Maps
2. **Elevation Profiles**: Plot elevation vs distance
3. **Heart Rate Zones**: Color-code route by HR zones
4. **Pace Heatmap**: Show where you were fastest/slowest
5. **Power/Cadence Correlation**: Cycling efficiency analysis

## 🚀 Future Enhancements

Potential additions:
- [ ] Automated route visualization
- [ ] Training plan tracking
- [ ] Workout recommendations
- [ ] Gear tracking & wear analysis
- [ ] Weather correlation with performance
- [ ] Social challenges & badges
- [ ] Menstrual cycle tracking
- [ ] Pregnancy tracking
- [ ] Race prediction accuracy tracking


path: references/health_analysis.md
content: # Health Data Analysis Guide - Garmin Edition

Science-backed framework for interpreting Garmin health metrics. Use this when the user asks about their health, trends, or wants insights from their data.

## HRV (Heart Rate Variability) - Nightly Average

### What it means
HRV measures the variation in time between heartbeats. It reflects autonomic nervous system (ANS) balance — specifically parasympathetic (rest-and-digest) activity. Higher HRV = more adaptable, resilient nervous system.

### Normal ranges (wrist-based wearable)
| Age | Low | Normal | High |
|-----|-----|--------|------|
| 20-29 | <25ms | 25-105ms | >105ms |
| 30-39 | <20ms | 20-80ms | >80ms |
| 40-49 | <15ms | 15-60ms | >60ms |
| 50-59 | <12ms | 12-45ms | >45ms |
| 60+ | <10ms | 10-35ms | >35ms |

*Note: Wrist-based HRV tends to read lower than chest strap. Individual baseline matters more than population norms.*

### Interpretation
- **Trend matters more than absolute value** — compare to personal weekly/monthly average
- **Declining HRV trend** (>10% below baseline for 3+ days): suggests accumulated stress, poor recovery, illness onset, or overtraining
- **Rising HRV trend**: improved fitness, better recovery, reduced stress
- **Acute drop**: poor sleep, alcohol, illness, intense training, emotional stress
- **Very low HRV** (<15ms consistently): consider medical consultation — linked to cardiovascular risk, chronic stress

### Garmin-specific notes
- Garmin calculates HRV during sleep (nightly average)
- Shows 7-day rolling average and baseline ranges (balanced low/high)
- **Status indicator**: Balanced, Unbalanced, Poor, Low
- Look for trends over weeks, not single days

### Key research findings
- Low HRV is associated with increased cardiovascular mortality (Harvard Health, PMC5624990)
- HRV reflects both physical AND psychological stress load
- 7-day rolling average provides more meaningful context than daily values

---

## Body Battery (0-100)

### What it means
Garmin's proprietary recovery metric based on:
- Heart rate variability
- Stress levels
- Sleep quality
- Activity intensity

Body Battery "charges" during rest/sleep and "drains" during activity/stress.

### Zones
| Level | Range | Meaning | Recommendation |
|-------|-------|---------|---------------|
| High | 75-100 | Fully charged, optimal energy | Ready for high-intensity training |
| Medium | 50-74 | Moderate energy | Good for regular workouts |
| Low | 25-49 | Limited energy reserves | Light activity, recovery focus |
| Very Low | 0-24 | Depleted | Prioritize rest and recovery |

### Patterns to watch
- **Peak daily value** (highest point after sleep): indicates overnight recovery quality
- **Charging rate** (how much you gain during sleep): poor charging = poor sleep/recovery
- **Draining rate** (how fast it depletes): rapid drain = high stress/activity
- **Consistently <50 peak**: chronic under-recovery — review sleep, stress, training load
- **Not fully charging** (peak <75 for 3+ days): accumulated fatigue

### Optimal pattern
- Charge to 75-100 overnight
- Maintain 50+ through most of the day
- Drop to 25-50 by evening
- Recharge during sleep

---

## Resting Heart Rate (RHR)

### Normal ranges
| Fitness level | RHR (bpm) |
|--------------|-----------|
| Athlete | 40-55 |
| Active adult | 55-65 |
| Average adult | 60-80 |
| Sedentary | 70-90 |
| Concerning | >90 |

### Interpretation
- **Trend matters**: RHR rising 3-5+ bpm above personal baseline for several days suggests accumulated fatigue, stress, illness, or dehydration
- **Acute spike**: alcohol, poor sleep, illness onset, overtraining
- **Decreasing RHR over weeks/months**: improving cardiovascular fitness
- **High RHR + Low HRV**: strong signal of poor recovery or health concern

### Garmin-specific
- Measured during sleep periods
- Tracks 7-day average and long-term trends
- Sudden increases often visible before illness symptoms

---

## Sleep Analysis

### Optimal distribution (for 7-8h total sleep)
| Stage | % of total | Ideal duration (8h) | Function |
|-------|-----------|---------------------|----------|
| Deep | 15-25% | 1.2-2.0h | Physical restoration, growth hormone, immune function, memory consolidation |
| REM | 20-25% | 1.6-2.0h | Emotional processing, learning, creativity, memory |
| Light | 50-60% | 4.0-4.8h | Transition stage, some memory processing |
| Awake | <10% | <0.8h | Normal brief awakenings |

### Sleep Score (0-100)
Garmin's composite score based on:
- Total sleep duration
- Sleep stage distribution
- Movement/restlessness
- Respiration quality
- Heart rate stability

| Score | Quality | Interpretation |
|-------|---------|---------------|
| 90-100 | Excellent | Optimal restorative sleep |
| 80-89 | Good | Quality sleep with minor issues |
| 60-79 | Fair | Adequate but could improve |
| 0-59 | Poor | Significant sleep deficiencies |

### Key thresholds
- **Total sleep needed** (adults): 7-9 hours (CDC recommendation)
- **Deep sleep <1h**: concerning — reduced physical recovery, weakened immunity
- **REM sleep <1.2h**: may affect emotional regulation, learning
- **Restless periods >15**: possible environment issues, sleep apnea, stress
- **Avg Sleep Score <70**: chronic sleep issues — review habits, environment, schedule

### Factors that reduce deep sleep
- Alcohol, caffeine (within 6h), late heavy meals, screen time, irregular schedule, aging

### Factors that increase deep sleep
- Exercise (not too close to bedtime), cool room temperature (60-67°F), consistent schedule, stress management

---

## Stress Levels

### What it means
Garmin calculates stress from HRV analysis throughout the day. Lower HRV = higher stress.

### Ranges
| Level | Score | Interpretation |
|-------|-------|---------------|
| Rest | 0-25 | Relaxed, recovering |
| Low | 26-50 | Calm, light activity |
| Medium | 51-75 | Moderate stress, normal activity |
| High | 76-100 | High stress, intense activity, or emotional pressure |

### Interpretation
- **All-day stress average**: Should be <50 for most days
- **High stress duration**: >4h per day may indicate chronic stress
- **Rest periods**: Should have some rest periods (0-25) throughout day
- **No rest periods**: concerning — may need stress management techniques
- **Stress spikes without activity**: emotional/mental stress

### Healthy pattern
- Morning rise (waking up)
- Activity-related spikes
- Several rest periods
- Evening decline
- Low stress during sleep

---

## Respiration Rate

### Normal ranges (breaths per minute during sleep)
| Range | Interpretation |
|-------|---------------|
| 12-16 | Normal, healthy |
| 10-12 | Normal for athletes |
| <10 or >20 | May indicate health concern |

### What to watch
- **Sudden increase**: illness, allergies, sleep apnea, stress
- **Consistently elevated**: chronic respiratory issues, poor fitness
- **High variability**: possible sleep-disordered breathing

---

## VO2 Max

### What it means
Maximum oxygen uptake during intense exercise (ml/kg/min). Gold standard for cardiorespiratory fitness.

### Ranges by age and gender (general population)
#### Men
| Age | Poor | Fair | Good | Excellent | Superior |
|-----|------|------|------|-----------|----------|
| 20-29 | <40 | 40-43 | 44-51 | 52-56 | >56 |
| 30-39 | <38 | 38-41 | 42-49 | 50-54 | >54 |
| 40-49 | <35 | 35-38 | 39-45 | 46-52 | >52 |
| 50-59 | <32 | 32-35 | 36-43 | 44-48 | >48 |

#### Women
| Age | Poor | Fair | Good | Excellent | Superior |
|-----|------|------|------|-----------|----------|
| 20-29 | <32 | 32-36 | 37-41 | 42-46 | >46 |
| 30-39 | <30 | 30-33 | 34-39 | 40-44 | >44 |
| 40-49 | <27 | 27-31 | 32-36 | 37-41 | >41 |
| 50-59 | <25 | 25-28 | 29-33 | 34-38 | >38 |

### Garmin-specific
- Estimated from running/walking activities with GPS + heart rate
- Updates after qualifying outdoor activities
- More accurate with chest strap heart rate
- Tracks trends over time

---

## Activity Intensity Minutes

### Weekly targets (WHO/AHA guidelines)
- **Moderate intensity**: 150 minutes/week
- **Vigorous intensity**: 75 minutes/week
- OR combination (2 min moderate = 1 min vigorous)

### Interpretation
- **Meeting target**: associated with reduced disease risk
- **Exceeding target**: additional health benefits up to ~300 min/week
- **Below target**: increased health risks

---

## Analysis Framework

When analyzing user data, follow this structure:

### 1. Quick Status (current state)
- Latest Body Battery level + peak value
- Last night's sleep score + hours
- Today's stress average
- Current resting heart rate
- Latest HRV reading + status

### 2. Trend Analysis (7-30 day window)
- HRV trend (rising/stable/declining vs baseline)
- Resting heart rate trend
- Sleep score average
- Body Battery charging pattern
- Activity intensity vs. recovery balance

### 3. Pattern Detection
- Day-of-week patterns (weekend effects, Monday dips)
- Sleep consistency
- Stress/recovery correlation
- Activity load vs. Body Battery recovery
- RHR elevation before illness

### 4. Actionable Insights (science-backed)
Based on data, suggest specific improvements:

**Sleep optimization:**
- Consistent bedtime/wake time (±30 min)
- 7-9 hours nightly
- Cool room (60-67°F)
- Dark, quiet environment
- No alcohol within 3h, no caffeine within 6h

**Recovery enhancement:**
- Match training intensity to Body Battery
- Build in rest days when BB not recovering above 75
- Stress management techniques when all-day stress >50
- Hydration monitoring

**Training periodization:**
- Push intensity on high Body Battery days (75+)
- Active recovery on medium days (50-74)
- Rest on low days (<50)
- Monitor HRV for overtraining signs

### 5. Flags / Alerts
- **Consistently low HRV** (<15ms) → suggest medical consultation
- **Elevated respiration** (>20 bpm during sleep) → possible breathing issue
- **RHR elevated 5+ bpm for 3+ days** → illness/overtraining watch
- **Sleep <6h average for 5+ days** → serious sleep debt, health risk
- **Body Battery not charging >75 for 5+ days** → chronic under-recovery
- **No rest stress periods** → chronic stress concern

---

## Prompt Template for Analysis

When asked "how am I doing?" or similar, fetch summary data and analyze:

```
Based on [USER]'s Garmin data for the last [N] days:

METRICS:
- Avg Body Battery Peak: X/100 (charging: good/poor)
- Avg HRV: Xms (vs baseline, trend: rising/falling/stable)
- HRV Status: Balanced/Unbalanced/Poor/Low
- Avg Resting HR: Xbpm (vs baseline: Xbpm)
- Avg Sleep: Xh, score: X/100
- Deep sleep avg: Xh (X% of total)
- REM avg: Xh (X% of total)
- Avg Stress: X (high stress duration: Xh/day)
- Activities: X workouts, X total calories

ANALYSIS:
[Apply the framework above — status, trends, patterns, insights, flags]
```

---

## Garmin vs Whoop: Key Differences

| Metric | Garmin | Whoop |
|--------|--------|-------|
| **Recovery** | Body Battery (0-100) | Recovery Score (0-100%) |
| **Strain** | Activity calories/intensity | Strain (0-21 scale) |
| **Stress** | All-day HRV-based stress | Not directly tracked |
| **Sleep scoring** | Composite score (0-100) | Performance % vs need |
| **Activities** | Extensive GPS/sport modes | Strain-focused tracking |
| **Primary use case** | Multi-sport fitness watch | Recovery-focused wearable |

---

## Important Disclaimers
- This is NOT medical advice — always recommend consulting a doctor for health concerns
- Wearable data has limitations in accuracy
- Individual baselines matter more than population norms
- One bad day doesn't indicate a problem — look for patterns over 3+ days
- Context matters: travel, altitude, menstrual cycle, medication, illness can all affect metrics
- Garmin estimates are based on algorithms and sensor data — not laboratory measurements


path: references/mcp_setup.md
content: # MCP Server for Standard Claude Desktop

> **This is a Clawdbot skill.** If you want to use Garmin data with standard Claude Desktop, use the dedicated MCP server instead.

## 📦 Dedicated MCP Server Repository

We've created a separate, purpose-built MCP server for Claude Desktop users:

### **[garmin-health-mcp-server](https://github.com/eversonl/garmin-health-mcp-server)**

This is a standalone Node.js-based MCP server optimized for Claude Desktop, with:
- ✅ Easy `npm install` setup
- ✅ Built-in authentication helper
- ✅ Test suite for verification
- ✅ Comprehensive documentation
- ✅ Full troubleshooting guide

## Quick Start (MCP Server)

```bash
# Clone the MCP server (separate repo)
git clone https://github.com/eversonl/garmin-health-mcp-server.git
cd garmin-health-mcp-server

# Install and setup
npm install
pip3 install garminconnect fitparse gpxpy
cp .env.example .env
# Edit .env with your credentials

# Authenticate
npm run auth

# Configure Claude Desktop (see full guide)
# Add to claude_desktop_config.json
```

**[📖 Full Installation Guide →](https://github.com/eversonl/garmin-health-mcp-server#readme)**

---

## This Skill (Clawdbot)

If you're using **Clawdbot**, you're in the right place! This skill provides:
- Automated health monitoring
- Scheduled reports and summaries  
- Proactive workout notifications
- Integration with other Clawdbot skills

**[📖 Clawdbot Skill Setup →](../SKILL.md)**

---

## Using Both?

You can use both the Clawdbot skill and the MCP server simultaneously! They share authentication tokens, so you only need to log in once.

**Recommended setup:**
- **Clawdbot**: Morning health summaries, weekly reports, automated tracking
- **Claude Desktop**: Quick ad-hoc queries during the day

Authentication tokens are shared at `~/.clawdbot/garmin-tokens.json` (or `~/.garmin-tokens.json`).


path: scripts/debug_api.py
content: #!/usr/bin/env python3
import json
from datetime import datetime
from pathlib import Path
import sys

sys.path.insert(0, str(Path(__file__).parent))

from garmin_auth import get_client

client = get_client()
if not client:
    print("NOT_AUTHED")
    sys.exit(1)

# 先尝试获取设备
try:
    devices = client.get_devices()
    print("DEVICES:", json.dumps(devices, default=str)[:500])
except Exception as e:
    print("DEVICES_ERROR:", str(e))

# 测试睡眠数据
try:
    sleep = client.get_sleep_data("2026-04-04")
    if sleep:
        dto = sleep.get("dailySleepDTO", {})
        print("SLEEP:", json.dumps(dto, default=str)[:300])
    else:
        print("SLEEP: None")
except Exception as e:
    print("SLEEP_ERROR:", str(e))

# 测试心率
try:
    hr = client.get_heart_rates("2026-04-04")
    print("HR:", json.dumps(hr, default=str)[:300])
except Exception as e:
    print("HR_ERROR:", str(e))


path: scripts/garmin_activity_files.py
content: #!/usr/bin/env python3
"""
Download and analyze Garmin activity FIT/GPX files.
Extract GPS, elevation, pace, heart rate, power, cadence, etc.
"""

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

sys.path.insert(0, str(Path(__file__).parent))
from garmin_auth import get_client

# Check for optional dependencies
try:
    import fitparse
    HAS_FITPARSE = True
except ImportError:
    HAS_FITPARSE = False

try:
    import gpxpy
    import gpxpy.gpx
    HAS_GPXPY = True
except ImportError:
    HAS_GPXPY = False


def download_activity_file(client, activity_id, file_format="fit", output_dir="/tmp"):
    """Download activity FIT or GPX file."""
    try:
        output_path = f"{output_dir}/activity_{activity_id}.{file_format.lower()}"
        
        if file_format.lower() == "fit":
            data = client.download_activity(activity_id, dl_fmt=client.ActivityDownloadFormat.ORIGINAL)
        elif file_format.lower() == "gpx":
            data = client.download_activity(activity_id, dl_fmt=client.ActivityDownloadFormat.GPX)
        elif file_format.lower() == "tcx":
            data = client.download_activity(activity_id, dl_fmt=client.ActivityDownloadFormat.TCX)
        else:
            return {"error": f"Unsupported format: {file_format}"}
        
        with open(output_path, 'wb') as f:
            f.write(data)
        
        return {"file": output_path, "activity_id": activity_id, "format": file_format}
    
    except Exception as e:
        return {"error": str(e), "activity_id": activity_id}


def parse_fit_file(file_path):
    """Parse FIT file and extract all data points."""
    if not HAS_FITPARSE:
        return {"error": "fitparse library not installed. Run: pip install fitparse"}
    
    try:
        fitfile = fitparse.FitFile(file_path)
        
        # Extract different record types
        records = []
        laps = []
        sessions = []
        
        for record in fitfile.get_messages('record'):
            data_point = {}
            for field in record:
                if field.value is not None:
                    data_point[field.name] = field.value
            if data_point:
                records.append(data_point)
        
        for record in fitfile.get_messages('lap'):
            lap_data = {}
            for field in record:
                if field.value is not None:
                    lap_data[field.name] = field.value
            if lap_data:
                laps.append(lap_data)
        
        for record in fitfile.get_messages('session'):
            session_data = {}
            for field in record:
                if field.value is not None:
                    session_data[field.name] = field.value
            if session_data:
                sessions.append(session_data)
        
        return {
            "records": records,
            "laps": laps,
            "sessions": sessions,
            "total_records": len(records)
        }
    
    except Exception as e:
        return {"error": str(e)}


def parse_gpx_file(file_path):
    """Parse GPX file and extract track points."""
    if not HAS_GPXPY:
        return {"error": "gpxpy library not installed. Run: pip install gpxpy"}
    
    try:
        with open(file_path, 'r') as f:
            gpx = gpxpy.parse(f)
        
        points = []
        for track in gpx.tracks:
            for segment in track.segments:
                for point in segment.points:
                    points.append({
                        "latitude": point.latitude,
                        "longitude": point.longitude,
                        "elevation": point.elevation,
                        "time": point.time.isoformat() if point.time else None,
                        "speed": point.speed,
                        "hr": point.extensions.get("hr") if point.extensions else None
                    })
        
        return {
            "points": points,
            "total_points": len(points),
            "bounds": {
                "min_lat": gpx.get_bounds().min_latitude,
                "max_lat": gpx.get_bounds().max_latitude,
                "min_lon": gpx.get_bounds().min_longitude,
                "max_lon": gpx.get_bounds().max_longitude
            } if gpx.get_bounds() else None
        }
    
    except Exception as e:
        return {"error": str(e)}


def query_data_at_distance(data, distance_meters):
    """Find data point closest to a specific distance."""
    if "records" in data:
        records = data["records"]
    elif "points" in data:
        records = data["points"]
    else:
        return {"error": "No data records found"}
    
    # Find closest by distance
    closest = None
    min_diff = float('inf')
    
    for record in records:
        if "distance" in record:
            diff = abs(record["distance"] - distance_meters)
            if diff < min_diff:
                min_diff = diff
                closest = record
    
    return closest


def query_data_at_time(data, target_time):
    """Find data point at a specific time."""
    if "records" in data:
        records = data["records"]
    elif "points" in data:
        records = data["points"]
    else:
        return {"error": "No data records found"}
    
    # Parse target time
    if isinstance(target_time, str):
        try:
            target_dt = datetime.fromisoformat(target_time.replace("Z", "+00:00"))
        except:
            return {"error": f"Invalid time format: {target_time}"}
    else:
        target_dt = target_time
    
    target_ts = target_dt.timestamp()
    
    closest = None
    min_diff = float('inf')
    
    for record in records:
        if "timestamp" in record:
            if isinstance(record["timestamp"], datetime):
                rec_ts = record["timestamp"].timestamp()
            else:
                continue
            
            diff = abs(rec_ts - target_ts)
            if diff < min_diff:
                min_diff = diff
                closest = record
    
    return closest


def analyze_activity(data):
    """Analyze activity data and provide insights."""
    if "error" in data:
        return data
    
    records = data.get("records", [])
    if not records:
        return {"error": "No data records to analyze"}
    
    # Calculate statistics
    hr_values = [r.get("heart_rate") for r in records if r.get("heart_rate")]
    elevation_values = [r.get("altitude") or r.get("elevation") for r in records if r.get("altitude") or r.get("elevation")]
    speed_values = [r.get("speed") for r in records if r.get("speed")]
    cadence_values = [r.get("cadence") for r in records if r.get("cadence")]
    power_values = [r.get("power") for r in records if r.get("power")]
    
    analysis = {
        "total_points": len(records),
        "duration_seconds": None,
        "distance_meters": None,
        "heart_rate": {
            "avg": sum(hr_values) / len(hr_values) if hr_values else None,
            "max": max(hr_values) if hr_values else None,
            "min": min(hr_values) if hr_values else None
        },
        "elevation": {
            "max": max(elevation_values) if elevation_values else None,
            "min": min(elevation_values) if elevation_values else None,
            "gain": None  # Would need to calculate from sequential points
        },
        "speed": {
            "avg": sum(speed_values) / len(speed_values) if speed_values else None,
            "max": max(speed_values) if speed_values else None
        },
        "cadence": {
            "avg": sum(cadence_values) / len(cadence_values) if cadence_values else None
        } if cadence_values else None,
        "power": {
            "avg": sum(power_values) / len(power_values) if power_values else None,
            "max": max(power_values) if power_values else None
        } if power_values else None
    }
    
    # Get duration and distance from first/last records
    if records:
        if "timestamp" in records[0] and "timestamp" in records[-1]:
            if isinstance(records[0]["timestamp"], datetime):
                duration = (records[-1]["timestamp"] - records[0]["timestamp"]).total_seconds()
                analysis["duration_seconds"] = duration
        
        if "distance" in records[-1]:
            analysis["distance_meters"] = records[-1]["distance"]
    
    return analysis


def main():
    import argparse
    
    parser = argparse.ArgumentParser(description="Analyze Garmin activity files")
    parser.add_argument("action", choices=["download", "parse", "query", "analyze"],
                       help="Action to perform")
    parser.add_argument("--activity-id", type=int, help="Activity ID")
    parser.add_argument("--format", choices=["fit", "gpx", "tcx"], default="fit",
                       help="File format for download")
    parser.add_argument("--file", help="Path to local FIT/GPX file")
    parser.add_argument("--distance", type=float, help="Query data at distance (meters)")
    parser.add_argument("--time", help="Query data at time (ISO format)")
    parser.add_argument("--output-dir", default="/tmp", help="Output directory")
    
    args = parser.parse_args()
    
    if args.action == "download":
        if not args.activity_id:
            print('{"error": "activity-id required for download"}')
            sys.exit(1)
        
        client = get_client()
        if not client:
            print('{"error": "Not authenticated"}')
            sys.exit(1)
        
        result = download_activity_file(client, args.activity_id, args.format, args.output_dir)
        print(json.dumps(result, indent=2))
    
    elif args.action == "parse":
        if not args.file:
            print('{"error": "file path required for parse"}')
            sys.exit(1)
        
        if args.file.endswith('.fit'):
            result = parse_fit_file(args.file)
        elif args.file.endswith('.gpx'):
            result = parse_gpx_file(args.file)
        else:
            result = {"error": "Unsupported file type. Use .fit or .gpx"}
        
        print(json.dumps(result, indent=2, default=str))
    
    elif args.action == "query":
        if not args.file:
            print('{"error": "file path required for query"}')
            sys.exit(1)
        
        # First parse the file
        if args.file.endswith('.fit'):
            data = parse_fit_file(args.file)
        elif args.file.endswith('.gpx'):
            data = parse_gpx_file(args.file)
        else:
            print('{"error": "Unsupported file type"}')
            sys.exit(1)
        
        if "error" in data:
            print(json.dumps(data, indent=2))
            sys.exit(1)
        
        # Query
        if args.distance is not None:
            result = query_data_at_distance(data, args.distance)
        elif args.time:
            result = query_data_at_time(data, args.time)
        else:
            result = {"error": "Specify --distance or --time for query"}
        
        print(json.dumps(result, indent=2, default=str))
    
    elif args.action == "analyze":
        if not args.file:
            print('{"error": "file path required for analyze"}')
            sys.exit(1)
        
        # Parse and analyze
        if args.file.endswith('.fit'):
            data = parse_fit_file(args.file)
        elif args.file.endswith('.gpx'):
            data = parse_gpx_file(args.file)
        else:
            print('{"error": "Unsupported file type"}')
            sys.exit(1)
        
        result = analyze_activity(data)
        print(json.dumps(result, indent=2, default=str))


if __name__ == "__main__":
    main()


path: scripts/garmin_auth.py
content: #!/usr/bin/env python3
"""
Garmin Connect authentication helper.
Handles login and stores session tokens.
"""

import json
import os
import sys
from pathlib import Path
from datetime import datetime
import argparse

try:
    from garminconnect import Garmin, GarminConnectAuthenticationError, GarminConnectConnectionError
except ImportError:
    print("❌ garminconnect library not installed", file=sys.stderr)
    print("Install with: pip3 install garminconnect", file=sys.stderr)
    sys.exit(1)

TOKEN_DIR = Path.home() / ".clawdbot" / "garmin"
CONFIG_FILE = Path(__file__).parent.parent / "config.json"


def load_config():
    """Load credentials from config file."""
    if not CONFIG_FILE.exists():
        return None
    try:
        with open(CONFIG_FILE) as f:
            return json.load(f)
    except Exception as e:
        print(f"⚠️  Failed to load config: {e}", file=sys.stderr)
        return None


def login(email, password):
    """Perform login and save tokens using garminconnect's tokenstore."""
    try:
        print(f"🔐 Logging in as {email}...", file=sys.stderr)
        
        # Create token directory
        TOKEN_DIR.mkdir(parents=True, exist_ok=True)
        tokenstore = str(TOKEN_DIR)
        
        # Create client and login (don't pass tokenstore on first login)
        client = Garmin(email, password)
        client.login()  # Initial login without tokenstore
        
        # Save tokens to tokenstore
        client.garth.dump(tokenstore)
        print(f"✅ Tokens saved to {tokenstore}", file=sys.stderr)
        
        # Test the connection
        try:
            profile = client.get_user_summary(datetime.now().strftime("%Y-%m-%d"))
            print(f"✅ Login successful! User: {profile.get('displayName', 'Unknown')}", file=sys.stderr)
        except Exception as e:
            print(f"✅ Login successful! (Unable to fetch profile: {e})", file=sys.stderr)
        
        # Make tokenstore directory secure
        TOKEN_DIR.chmod(0o700)
        
        return True
        
    except GarminConnectAuthenticationError as e:
        print(f"❌ Authentication failed: {e}", file=sys.stderr)
        print("Check your email/password and try again.", file=sys.stderr)
        return False
    except Exception as e:
        print(f"❌ Login error: {e}", file=sys.stderr)
        return False


def get_client():
    """Get authenticated Garmin client, using saved tokens if available."""
    tokenstore = str(TOKEN_DIR)
    
    if not TOKEN_DIR.exists():
        return None
    
    try:
        # Try to use saved tokens
        client = Garmin()
        client.login(tokenstore=tokenstore)
        
        # Test if tokens still work
        client.get_user_summary(datetime.now().strftime("%Y-%m-%d"))
        return client
        
    except Exception as e:
        print(f"⚠️  Saved tokens expired or invalid: {e}", file=sys.stderr)
        return None


def check_status():
    """Check if we have valid authentication."""
    tokenstore = str(TOKEN_DIR)
    
    if not TOKEN_DIR.exists():
        print("❌ Not authenticated", file=sys.stderr)
        print("Run: python3 scripts/garmin_auth.py login", file=sys.stderr)
        return False
    
    print(f"✅ Token store found at {tokenstore}", file=sys.stderr)
    
    # Test if they work
    client = get_client()
    if client:
        try:
            profile = client.get_user_summary(datetime.now().strftime("%Y-%m-%d"))
            print(f"✅ Authentication valid! User: {profile.get('displayName', 'Unknown')}", file=sys.stderr)
            return True
        except Exception as e:
            print(f"⚠️  Tokens may be expired: {e}", file=sys.stderr)
            return False
    
    print("❌ Authentication invalid. Please login again.", file=sys.stderr)
    return False


def main():
    parser = argparse.ArgumentParser(description="Garmin Connect authentication")
    subparsers = parser.add_subparsers(dest="command", help="Command")
    
    # Login command
    login_parser = subparsers.add_parser("login", help="Login to Garmin Connect")
    login_parser.add_argument("--email", help="Garmin account email (or set via env/config)")
    login_parser.add_argument("--password", help="Garmin account password (or set via env/config)")
    
    # Status command
    subparsers.add_parser("status", help="Check authentication status")
    
    args = parser.parse_args()
    
    if args.command == "login":
        email = args.email
        [REDACTED_SECRET]
        
        # Priority: CLI args > config.json > environment variables
        if not email or not [REDACTED_SECRET] = load_config()
            if config:
                email = email or config.get("email")
                [REDACTED_SECRET] or config.get("password")
        
        if not email or not password:
            email = email or os.getenv("GARMIN_EMAIL")
            [REDACTED_SECRET] or os.getenv("GARMIN_PASSWORD")
        
        if not email or not [REDACTED_SECRET]"❌ Email and password required", file=sys.stderr)
            print("Set via:", file=sys.stderr)
            print("  1. CLI: --email and --password", file=sys.stderr)
            print("  2. Config: create config.json from config.example.json", file=sys.stderr)
            print("  3. Env vars: GARMIN_EMAIL and GARMIN_PASSWORD", file=sys.stderr)
            print("  4. Clawdbot config: skills.entries.garmin-health-analysis.env", file=sys.stderr)
            sys.exit(1)
        
        success = login(email, password)
        sys.exit(0 if success else 1)
    
    elif args.command == "status":
        success = check_status()
        sys.exit(0 if success else 1)
    
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/garmin_chart.py
content: #!/usr/bin/env python3
"""
Generate interactive HTML charts from Garmin health data.
Uses Chart.js for visualizations.
"""

import json
import sys
import argparse
import webbrowser
from pathlib import Path
from datetime import datetime

# Import auth and data helpers
sys.path.insert(0, str(Path(__file__).parent))
from garmin_auth import get_client
from garmin_data import fetch_sleep, fetch_hrv, fetch_body_battery, fetch_heart_rate, fetch_activities, fetch_stress


def generate_html(charts_data, title="Garmin Health Dashboard"):
    """Generate HTML with Chart.js visualizations."""
    
    html = f"""<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{title}</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
    <style>
        * {{
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }}
        body {{
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
            color: #fff;
            padding: 20px;
            min-height: 100vh;
        }}
        .container {{
            max-width: 1400px;
            margin: 0 auto;
        }}
        h1 {{
            text-align: center;
            margin-bottom: 30px;
            font-size: 2.5rem;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
        }}
        .stats-grid {{
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 20px;
            margin-bottom: 40px;
        }}
        .stat-card {{
            background: rgba(255, 255, 255, 0.1);
            backdrop-filter: blur(10px);
            border-radius: 12px;
            padding: 20px;
            text-align: center;
            border: 1px solid rgba(255, 255, 255, 0.2);
        }}
        .stat-value {{
            font-size: 2.5rem;
            font-weight: bold;
            margin: 10px 0;
        }}
        .stat-label {{
            font-size: 0.9rem;
            opacity: 0.8;
            text-transform: uppercase;
            letter-spacing: 1px;
        }}
        .charts-grid {{
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
            gap: 30px;
        }}
        .chart-container {{
            background: rgba(255, 255, 255, 0.05);
            backdrop-filter: blur(10px);
            border-radius: 16px;
            padding: 30px;
            border: 1px solid rgba(255, 255, 255, 0.1);
        }}
        .chart-title {{
            font-size: 1.3rem;
            margin-bottom: 20px;
            text-align: center;
            font-weight: 600;
        }}
        canvas {{
            max-height: 400px;
        }}
        .footer {{
            text-align: center;
            margin-top: 40px;
            opacity: 0.6;
            font-size: 0.9rem;
        }}
    </style>
</head>
<body>
    <div class="container">
        <h1>{title}</h1>
        
        <div class="stats-grid" id="stats"></div>
        
        <div class="charts-grid" id="charts"></div>
        
        <div class="footer">
            Generated on {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
        </div>
    </div>
    
    <script>
        Chart.defaults.color = '#fff';
        Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
        
        const chartsData = {json.dumps(charts_data)};
        
        // Render stats cards
        function renderStats(stats) {{
            const container = document.getElementById('stats');
            for (const [label, value] of Object.entries(stats)) {{
                const card = document.createElement('div');
                card.className = 'stat-card';
                card.innerHTML = `
                    <div class="stat-label">${{label}}</div>
                    <div class="stat-value">${{value}}</div>
                `;
                container.appendChild(card);
            }}
        }}
        
        // Render chart
        function renderChart(config) {{
            const container = document.createElement('div');
            container.className = 'chart-container';
            
            const title = document.createElement('div');
            title.className = 'chart-title';
            title.textContent = config.title;
            container.appendChild(title);
            
            const canvas = document.createElement('canvas');
            container.appendChild(canvas);
            
            document.getElementById('charts').appendChild(container);
            
            new Chart(canvas, config.chart);
        }}
        
        // Render all data
        if (chartsData.stats) {{
            renderStats(chartsData.stats);
        }}
        
        if (chartsData.charts) {{
            chartsData.charts.forEach(renderChart);
        }}
    </script>
</body>
</html>"""
    
    return html


def create_sleep_chart(sleep_data):
    """Create sleep analysis chart."""
    dates = [s["date"] for s in sleep_data if s.get("sleep_time_seconds")]
    hours = [s["sleep_time_seconds"] / 3600 for s in sleep_data if s.get("sleep_time_seconds")]
    scores = [s.get("sleep_score", 0) for s in sleep_data if s.get("sleep_score")]
    
    avg_hours = sum(hours) / len(hours) if hours else 0
    avg_score = sum(scores) / len(scores) if scores else 0
    
    return {
        "stats": {
            "Avg Sleep": f"{avg_hours:.1f}h",
            "Avg Score": f"{avg_score:.0f}/100"
        },
        "chart": {
            "title": "Sleep Analysis",
            "chart": {
                "type": "bar",
                "data": {
                    "labels": dates,
                    "datasets": [
                        {
                            "label": "Sleep Hours",
                            "data": hours,
                            "backgroundColor": "rgba(54, 162, 235, 0.6)",
                            "borderColor": "rgba(54, 162, 235, 1)",
                            "borderWidth": 2,
                            "yAxisID": "y"
                        },
                        {
                            "label": "Sleep Score",
                            "data": scores,
                            "type": "line",
                            "borderColor": "rgba(255, 206, 86, 1)",
                            "backgroundColor": "rgba(255, 206, 86, 0.1)",
                            "borderWidth": 3,
                            "tension": 0.4,
                            "yAxisID": "y1"
                        }
                    ]
                },
                "options": {
                    "responsive": True,
                    "interaction": {
                        "mode": "index",
                        "intersect": False
                    },
                    "scales": {
                        "y": {
                            "type": "linear",
                            "display": True,
                            "position": "left",
                            "title": {
                                "display": True,
                                "text": "Hours"
                            }
                        },
                        "y1": {
                            "type": "linear",
                            "display": True,
                            "position": "right",
                            "title": {
                                "display": True,
                                "text": "Score"
                            },
                            "grid": {
                                "drawOnChartArea": False
                            },
                            "max": 100
                        }
                    },
                    "plugins": {
                        "legend": {
                            "display": True
                        }
                    }
                }
            }
        }
    }


def create_body_battery_chart(bb_data):
    """Create Body Battery (recovery) chart."""
    dates = [b["date"] for b in bb_data if b.get("charged") is not None]
    charged = [b.get("charged", 0) for b in bb_data if b.get("charged") is not None]
    highest = [b.get("highest", 0) for b in bb_data if b.get("highest") is not None]
    
    avg_charged = sum(charged) / len(charged) if charged else 0
    avg_highest = sum(highest) / len(highest) if highest else 0
    
    # Color code based on levels
    colors = []
    for val in highest:
        if val >= 75:
            colors.append("rgba(75, 192, 192, 0.6)")  # Green
        elif val >= 50:
            colors.append("rgba(255, 206, 86, 0.6)")  # Yellow
        elif val >= 25:
            colors.append("rgba(255, 159, 64, 0.6)")  # Orange
        else:
            colors.append("rgba(255, 99, 132, 0.6)")  # Red
    
    return {
        "stats": {
            "Avg Charged": f"+{avg_charged:.0f}",
            "Avg Peak": f"{avg_highest:.0f}/100"
        },
        "chart": {
            "title": "Body Battery (Recovery)",
            "chart": {
                "type": "bar",
                "data": {
                    "labels": dates,
                    "datasets": [{
                        "label": "Highest Body Battery",
                        "data": highest,
                        "backgroundColor": colors,
                        "borderWidth": 0
                    }]
                },
                "options": {
                    "responsive": True,
                    "scales": {
                        "y": {
                            "beginAtZero": True,
                            "max": 100,
                            "title": {
                                "display": True,
                                "text": "Body Battery"
                            }
                        }
                    },
                    "plugins": {
                        "legend": {
                            "display": False
                        }
                    }
                }
            }
        }
    }


def create_hrv_chart(hrv_data, hr_data):
    """Create HRV and resting heart rate trend chart."""
    dates = [h["date"] for h in hrv_data if h.get("last_night_avg")]
    hrv_values = [h.get("last_night_avg", 0) for h in hrv_data if h.get("last_night_avg")]
    
    # Match dates with heart rate data
    hr_map = {h["date"]: h.get("resting_hr") for h in hr_data if h.get("resting_hr")}
    rhr_values = [hr_map.get(date, 0) for date in dates]
    
    avg_hrv = sum(hrv_values) / len(hrv_values) if hrv_values else 0
    avg_rhr = sum(rhr_values) / len(rhr_values) if rhr_values else 0
    
    return {
        "stats": {
            "Avg HRV": f"{avg_hrv:.0f} ms",
            "Avg RHR": f"{avg_rhr:.0f} bpm"
        },
        "chart": {
            "title": "HRV & Resting Heart Rate",
            "chart": {
                "type": "line",
                "data": {
                    "labels": dates,
                    "datasets": [
                        {
                            "label": "HRV (ms)",
                            "data": hrv_values,
                            "borderColor": "rgba(153, 102, 255, 1)",
                            "backgroundColor": "rgba(153, 102, 255, 0.1)",
                            "borderWidth": 3,
                            "tension": 0.4,
                            "yAxisID": "y"
                        },
                        {
                            "label": "Resting HR (bpm)",
                            "data": rhr_values,
                            "borderColor": "rgba(255, 99, 132, 1)",
                            "backgroundColor": "rgba(255, 99, 132, 0.1)",
                            "borderWidth": 3,
                            "tension": 0.4,
                            "yAxisID": "y1"
                        }
                    ]
                },
                "options": {
                    "responsive": True,
                    "interaction": {
                        "mode": "index",
                        "intersect": False
                    },
                    "scales": {
                        "y": {
                            "type": "linear",
                            "display": True,
                            "position": "left",
                            "title": {
                                "display": True,
                                "text": "HRV (ms)"
                            }
                        },
                        "y1": {
                            "type": "linear",
                            "display": True,
                            "position": "right",
                            "title": {
                                "display": True,
                                "text": "Heart Rate (bpm)"
                            },
                            "grid": {
                                "drawOnChartArea": False
                            }
                        }
                    },
                    "plugins": {
                        "legend": {
                            "display": True
                        }
                    }
                }
            }
        }
    }


def create_activities_chart(activities_data):
    """Create activities/workouts summary chart."""
    # Group by type
    types = {}
    for activity in activities_data:
        activity_type = activity.get("activity_type", "Unknown")
        if activity_type not in types:
            types[activity_type] = {"count": 0, "calories": 0, "duration": 0}
        types[activity_type]["count"] += 1
        types[activity_type]["calories"] += activity.get("calories", 0)
        types[activity_type]["duration"] += activity.get("duration_seconds", 0) / 3600
    
    labels = list(types.keys())
    counts = [types[t]["count"] for t in labels]
    calories = [types[t]["calories"] for t in labels]
    
    total_activities = sum(counts)
    total_calories = sum(calories)
    
    return {
        "stats": {
            "Activities": f"{total_activities}",
            "Total Calories": f"{total_calories:.0f}"
        },
        "chart": {
            "title": "Activities Summary",
            "chart": {
                "type": "bar",
                "data": {
                    "labels": labels,
                    "datasets": [
                        {
                            "label": "Count",
                            "data": counts,
                            "backgroundColor": "rgba(75, 192, 192, 0.6)",
                            "borderColor": "rgba(75, 192, 192, 1)",
                            "borderWidth": 2,
                            "yAxisID": "y"
                        },
                        {
                            "label": "Calories",
                            "data": calories,
                            "backgroundColor": "rgba(255, 159, 64, 0.6)",
                            "borderColor": "rgba(255, 159, 64, 1)",
                            "borderWidth": 2,
                            "yAxisID": "y1"
                        }
                    ]
                },
                "options": {
                    "responsive": True,
                    "scales": {
                        "y": {
                            "type": "linear",
                            "display": True,
                            "position": "left",
                            "title": {
                                "display": True,
                                "text": "Count"
                            }
                        },
                        "y1": {
                            "type": "linear",
                            "display": True,
                            "position": "right",
                            "title": {
                                "display": True,
                                "text": "Calories"
                            },
                            "grid": {
                                "drawOnChartArea": False
                            }
                        }
                    },
                    "plugins": {
                        "legend": {
                            "display": True
                        }
                    }
                }
            }
        }
    }


def main():
    parser = argparse.ArgumentParser(description="Generate Garmin health charts")
    parser.add_argument("chart", choices=["sleep", "body_battery", "hrv", "activities", "dashboard"],
                       help="Type of chart to generate")
    parser.add_argument("--days", type=int, default=30, help="Number of days (default: 30)")
    parser.add_argument("--output", help="Output HTML file (default: opens in browser)")
    
    args = parser.parse_args()
    
    # Get authenticated client
    client = get_client()
    if not client:
        print("❌ Not authenticated. Run: python3 scripts/garmin_auth.py login", file=sys.stderr)
        sys.exit(1)
    
    print(f"📊 Fetching {args.days} days of data...", file=sys.stderr)
    
    # Prepare chart data
    charts_data = {"stats": {}, "charts": []}
    
    if args.chart in ["sleep", "dashboard"]:
        print("  - Sleep data", file=sys.stderr)
        sleep_data = fetch_sleep(client, args.days).get("sleep", [])
        if sleep_data:
            result = create_sleep_chart(sleep_data)
            charts_data["stats"].update(result.get("stats", {}))
            charts_data["charts"].append(result["chart"])
    
    if args.chart in ["body_battery", "dashboard"]:
        print("  - Body Battery data", file=sys.stderr)
        bb_data = fetch_body_battery(client, args.days).get("body_battery", [])
        if bb_data:
            result = create_body_battery_chart(bb_data)
            charts_data["stats"].update(result.get("stats", {}))
            charts_data["charts"].append(result["chart"])
    
    if args.chart in ["hrv", "dashboard"]:
        print("  - HRV & Heart Rate data", file=sys.stderr)
        hrv_data = fetch_hrv(client, args.days).get("hrv", [])
        hr_data = fetch_heart_rate(client, args.days).get("heart_rate", [])
        if hrv_data:
            result = create_hrv_chart(hrv_data, hr_data)
            charts_data["stats"].update(result.get("stats", {}))
            charts_data["charts"].append(result["chart"])
    
    if args.chart in ["activities", "dashboard"]:
        print("  - Activities data", file=sys.stderr)
        activities_data = fetch_activities(client, args.days).get("activities", [])
        if activities_data:
            result = create_activities_chart(activities_data)
            charts_data["stats"].update(result.get("stats", {}))
            charts_data["charts"].append(result["chart"])
    
    # Generate HTML
    title = f"Garmin {args.chart.title()} - Last {args.days} Days"
    html = generate_html(charts_data, title)
    
    # Save or open
    if args.output:
        output_path = Path(args.output).expanduser()
        output_path.write_text(html)
        print(f"✅ Chart saved to {output_path}", file=sys.stderr)
    else:
        # Save to temp file and open
        import tempfile
        with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False) as f:
            f.write(html)
            temp_path = f.name
        
        print(f"✅ Opening chart in browser...", file=sys.stderr)
        webbrowser.open(f"file://{temp_path}")


if __name__ == "__main__":
    main()


path: scripts/garmin_data.py
content: #!/usr/bin/env python3
"""
Fetch health data from Garmin Connect.
Outputs JSON to stdout for parsing by the agent.
"""

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

# Import auth helper
sys.path.insert(0, str(Path(__file__).parent))
from garmin_auth import get_client

try:
    from garminconnect import Garmin
except ImportError:
    print('{"error": "garminconnect not installed. Run: pip3 install garminconnect"}', file=sys.stderr)
    sys.exit(1)


def get_date_range(days=None, start=None, end=None):
    """Calculate date range for queries."""
    if start and end:
        return start, end
    
    end_date = datetime.now()
    start_date = end_date - timedelta(days=days or 7)
    
    return start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d")


def fetch_sleep(client, days=7, start=None, end=None):
    """Fetch sleep data."""
    start_date, end_date = get_date_range(days, start, end)
    
    try:
        sleep_data = []
        current = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")
        
        while current <= end:
            date_str = current.strftime("%Y-%m-%d")
            try:
                data = client.get_sleep_data(date_str)
                if data:
                    # Sleep data is nested inside dailySleepDTO
                    sleep_dto = data.get("dailySleepDTO", {})
                    if sleep_dto:
                        sleep_data.append({
                            "date": date_str,
                            "sleep_time_seconds": sleep_dto.get("sleepTimeSeconds"),
                            "deep_sleep_seconds": sleep_dto.get("deepSleepSeconds"),
                            "light_sleep_seconds": sleep_dto.get("lightSleepSeconds"),
                            "rem_sleep_seconds": sleep_dto.get("remSleepSeconds"),
                            "awake_seconds": sleep_dto.get("awakeSleepSeconds"),
                            "sleep_score": sleep_dto.get("sleepScores", {}).get("overall", {}).get("value"),
                            "restless_periods": data.get("restlessMomentsCount"),  # This is on root
                            "avg_hr": sleep_dto.get("averageHeartRate"),
                            "avg_hrv": data.get("avgOvernightHrv"),  # This is on root
                            "avg_respiration": sleep_dto.get("averageRespirationValue")
                        })
            except Exception as e:
                print(f"⚠️  No sleep data for {date_str}: {e}", file=sys.stderr)
            
            current += timedelta(days=1)
        
        return {"sleep": sleep_data, "start": start_date, "end": end_date}
    
    except Exception as e:
        return {"error": str(e)}


def fetch_hrv(client, days=7, start=None, end=None):
    """Fetch HRV data."""
    start_date, end_date = get_date_range(days, start, end)
    
    try:
        hrv_data = []
        current = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")
        
        while current <= end:
            date_str = current.strftime("%Y-%m-%d")
            try:
                data = client.get_hrv_data(date_str)
                if data and "hrvSummary" in data:
                    summary = data["hrvSummary"]
                    hrv_data.append({
                        "date": date_str,
                        "last_night_avg": summary.get("lastNightAvg"),
                        "last_night_5min_high": summary.get("lastNight5MinHigh"),
                        "last_night_5min_low": summary.get("lastNight5MinLow"),
                        "weekly_avg": summary.get("weeklyAvg"),
                        "baseline_balanced_low": summary.get("baselineBalancedLow"),
                        "baseline_balanced_high": summary.get("baselineBalancedHigh"),
                        "status": summary.get("status")
                    })
            except Exception:
                pass
            
            current += timedelta(days=1)
        
        return {"hrv": hrv_data, "start": start_date, "end": end_date}
    
    except Exception as e:
        return {"error": str(e)}


def fetch_body_battery(client, days=7, start=None, end=None):
    """Fetch Body Battery data (Garmin's recovery metric)."""
    start_date, end_date = get_date_range(days, start, end)
    
    try:
        bb_data = []
        current = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")
        
        while current <= end:
            date_str = current.strftime("%Y-%m-%d")
            try:
                data = client.get_body_battery(date_str)
                if data and len(data) > 0:
                    day_data = data[0]
                    charged = day_data.get("charged")
                    drained = day_data.get("drained")
                    
                    # Parse bodyBatteryValuesArray to get highest/lowest
                    values_array = day_data.get("bodyBatteryValuesArray", [])
                    values = [v[1] for v in values_array if len(v) > 1]  # Extract values from [timestamp, value] pairs
                    
                    highest = max(values) if values else None
                    lowest = min(values) if values else None
                    
                    bb_data.append({
                        "date": date_str,
                        "charged": charged,
                        "drained": drained,
                        "highest": highest,
                        "lowest": lowest
                    })
            except Exception:
                pass
            
            current += timedelta(days=1)
        
        return {"body_battery": bb_data, "start": start_date, "end": end_date}
    
    except Exception as e:
        return {"error": str(e)}


def fetch_heart_rate(client, days=7, start=None, end=None):
    """Fetch heart rate data."""
    start_date, end_date = get_date_range(days, start, end)
    
    try:
        hr_data = []
        current = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")
        
        while current <= end:
            date_str = current.strftime("%Y-%m-%d")
            try:
                data = client.get_heart_rates(date_str)
                if data:
                    hr_data.append({
                        "date": date_str,
                        "resting_hr": data.get("restingHeartRate"),
                        "max_hr": data.get("maxHeartRate"),
                        "min_hr": data.get("minHeartRate")
                    })
            except Exception:
                pass
            
            current += timedelta(days=1)
        
        return {"heart_rate": hr_data, "start": start_date, "end": end_date}
    
    except Exception as e:
        return {"error": str(e)}


def fetch_activities(client, days=7, start=None, end=None):
    """Fetch activities/workouts."""
    start_date, end_date = get_date_range(days, start, end)
    
    try:
        # Garmin API uses offset-based pagination
        activities = client.get_activities_by_date(start_date, end_date, activitytype="")
        
        activity_list = []
        for activity in activities:
            activity_list.append({
                "date": activity.get("startTimeLocal", "").split(" ")[0],
                "activity_type": activity.get("activityType", {}).get("typeKey"),
                "activity_name": activity.get("activityName"),
                "duration_seconds": activity.get("duration"),
                "distance_meters": activity.get("distance"),
                "calories": activity.get("calories"),
                "avg_hr": activity.get("averageHR"),
                "max_hr": activity.get("maxHR"),
                "elevation_gain": activity.get("elevationGain"),
                "avg_speed": activity.get("averageSpeed")
            })
        
        return {"activities": activity_list, "start": start_date, "end": end_date, "count": len(activity_list)}
    
    except Exception as e:
        return {"error": str(e)}


def fetch_stress(client, days=7, start=None, end=None):
    """Fetch stress levels."""
    start_date, end_date = get_date_range(days, start, end)
    
    try:
        stress_data = []
        current = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")
        
        while current <= end:
            date_str = current.strftime("%Y-%m-%d")
            try:
                data = client.get_stress_data(date_str)
                if data:
                    stress_data.append({
                        "date": date_str,
                        "avg_stress": data.get("avgStressLevel"),
                        "max_stress": data.get("maxStressLevel"),
                        "rest_stress": data.get("restStressLevel"),
                        "activity_stress": data.get("activityStressLevel"),
                        "low_stress_duration": data.get("lowStressDuration"),
                        "medium_stress_duration": data.get("mediumStressDuration"),
                        "high_stress_duration": data.get("highStressDuration")
                    })
            except Exception:
                pass
            
            current += timedelta(days=1)
        
        return {"stress": stress_data, "start": start_date, "end": end_date}
    
    except Exception as e:
        return {"error": str(e)}


def fetch_summary(client, days=7, start=None, end=None):
    """Fetch combined summary with key metrics."""
    start_date, end_date = get_date_range(days, start, end)
    
    try:
        # Fetch multiple data types
        sleep = fetch_sleep(client, days, start, end).get("sleep", [])
        hrv = fetch_hrv(client, days, start, end).get("hrv", [])
        bb = fetch_body_battery(client, days, start, end).get("body_battery", [])
        hr = fetch_heart_rate(client, days, start, end).get("heart_rate", [])
        activities = fetch_activities(client, days, start, end).get("activities", [])
        
        # Calculate averages (handle None values)
        sleep_times = [s.get("sleep_time_seconds") for s in sleep if s.get("sleep_time_seconds")]
        avg_sleep_hours = (sum(sleep_times) / len(sleep_times) / 3600) if sleep_times else 0
        
        sleep_scores = [s.get("sleep_score") for s in sleep if s.get("sleep_score") is not None]
        avg_sleep_score = (sum(sleep_scores) / len(sleep_scores)) if sleep_scores else 0
        
        hrv_values = [h.get("last_night_avg") for h in hrv if h.get("last_night_avg") is not None]
        avg_hrv = (sum(hrv_values) / len(hrv_values)) if hrv_values else 0
        
        rhr_values = [h.get("resting_hr") for h in hr if h.get("resting_hr") is not None]
        avg_rhr = (sum(rhr_values) / len(rhr_values)) if rhr_values else 0
        
        bb_charged_values = [b.get("charged") for b in bb if b.get("charged") is not None]
        avg_bb_charged = (sum(bb_charged_values) / len(bb_charged_values)) if bb_charged_values else 0
        
        return {
            "summary": {
                "period": f"{start_date} to {end_date}",
                "days": days,
                "avg_sleep_hours": round(avg_sleep_hours, 1),
                "avg_sleep_score": round(avg_sleep_score, 1),
                "avg_hrv_ms": round(avg_hrv, 1),
                "avg_resting_hr": round(avg_rhr, 1),
                "avg_body_battery_charged": round(avg_bb_charged, 1),
                "total_activities": len(activities),
                "total_calories": sum(a.get("calories", 0) for a in activities if a.get("calories"))
            },
            "sleep": sleep,
            "hrv": hrv,
            "body_battery": bb,
            "heart_rate": hr,
            "activities": activities
        }
    
    except Exception as e:
        return {"error": str(e)}


def fetch_profile(client):
    """Fetch user profile."""
    try:
        profile = client.get_full_name()
        stats = client.get_user_summary(datetime.now().strftime("%Y-%m-%d"))
        
        return {
            "profile": {
                "name": profile,
                "display_name": stats.get("displayName"),
                "email": stats.get("email")
            }
        }
    
    except Exception as e:
        return {"error": str(e)}


def main():
    parser = argparse.ArgumentParser(description="Fetch Garmin health data")
    parser.add_argument("metric", choices=["sleep", "hrv", "body_battery", "heart_rate", "activities", "stress", "summary", "profile"],
                       help="Type of data to fetch")
    parser.add_argument("--days", type=int, default=7, help="Number of days to fetch (default: 7)")
    parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
    parser.add_argument("--end", help="End date (YYYY-MM-DD)")
    
    args = parser.parse_args()
    
    # Get authenticated client
    client = get_client()
    if not client:
        print('{"error": "Not authenticated. Run: python3 scripts/garmin_auth.py login --email YOUR_EMAIL --password YOUR_PASSWORD"}')
        sys.exit(1)
    
    # Fetch requested data
    if args.metric == "sleep":
        result = fetch_sleep(client, args.days, args.start, args.end)
    elif args.metric == "hrv":
        result = fetch_hrv(client, args.days, args.start, args.end)
    elif args.metric == "body_battery":
        result = fetch_body_battery(client, args.days, args.start, args.end)
    elif args.metric == "heart_rate":
        result = fetch_heart_rate(client, args.days, args.start, args.end)
    elif args.metric == "activities":
        result = fetch_activities(client, args.days, args.start, args.end)
    elif args.metric == "stress":
        result = fetch_stress(client, args.days, args.start, args.end)
    elif args.metric == "summary":
        result = fetch_summary(client, args.days, args.start, args.end)
    elif args.metric == "profile":
        result = fetch_profile(client)
    
    # Output JSON
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()


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