Skill: crabsticksalad/fitbit-tracker (version 0.2.2)

--- name: fitbit-tracker description: Personal Fitbit integration for daily health tracking with adaptive sleep and activity reporting version: 0.2.2 triggers: - "how did I sleep" - "sleep" - "sleep report" - "fitbit" - "health" - "steps" - "activity" - "full report" - "daily summary" metadata: clawdbot: emoji: "💪" requires: bins: [python3] config: env: FITBIT_CLIENT_ID: description: Fitbit OAuth client ID required: true FITBIT_CLIENT_SECRET: description: Fitbit OAuth client secret required: true FITBIT_REDIRECT_URI: description: OAuth redirect URI (typically http://localhost:8080) required: true FITBIT_TZ: description: Timezone for date calculations (e.g. Europe/London) default: UTC --- # Fitbit Tracker Effortless health monitoring powered by the official Fitbit API. No apps, no dashboards — just ask and get your health stats instantly. ## Features ### Smart Sleep Tracking - Automatically separates naps from main sleep - Reports duration, efficiency, and all sleep stages (Deep, Light, REM, Wake) - Nap detection with separate reporting ### Complete Activity Picture - Steps, calories, distance, active minutes - Resting heart rate - Heart rate zones (Out of Range, Fat Burn, Cardio, Peak) ### Adaptive Reporting Only shows what you ask for — no unnecessary data. | You say... | Reports... | |-------------|-----------| | "how did I sleep" / "sleep" | Duration, efficiency, all stages, nap | | "just my steps" | Steps only | | "activity today" | Steps, calories, distance, active mins, HR zones | | "full report" / "everything" / "summary" | All available data | | "fitbit" / "health" | Complete daily summary | ### Clean Formatting - Numbers formatted for readability (e.g., "8,234 steps") - Stages grouped logically - No raw data dumps ## Data Available **Sleep:** - Duration (total sleep time) - Sleep efficiency % - Sleep score (when available) - Sleep stages: Deep, Light, REM, Wake - Nap duration (when taken) **Activity:** - Steps - Calories (total + BMR) - Distance (km) - Resting heart rate - Active minutes (Very Active, Fairly, Lightly, Sedentary) - Heart rate zones ## Setup ### 1. Create Fitbit Developer App 1. Go to [dev.fitbit.com](https://dev.fitbit.com) 2. Log in and click **Register an App** 3. Fill in: - **Application Name**: OpenClaw Fitbit (or any name) - **Description**: Fitbit integration for OpenClaw - **Application Website**: `https://github.com/yourusername/openclaw` - **OAuth 2.0 Application Type**: Choose **Personal** - **Callback URL**: `http://localhost:8080` (for local) or your redirect URI 4. Accept the terms and register 5. Copy your **Client ID** and **Client Secret** ### 2. Configure Environment Variables ```bash export FITBIT_CLIENT_ID="your_client_id" export FITBIT_CLIENT_SECRET="your_client_secret" export FITBIT_REDIRECT_URI="http://localhost:8080" export FITBIT_TZ="Europe/London" # Your timezone ``` Or add to `~/.openclaw/.env`: ``` FITBIT_CLIENT_ID=your_client_id FITBIT_CLIENT_SECRET=your_client_secret FITBIT_REDIRECT_URI=http://localhost:8080 FITBIT_TZ=Europe/London ``` ### 3. Authenticate Run the OAuth login script: ```bash python3 scripts/fitbit_oauth_login.py ``` This will: - Open Fitbit authorization page in your browser - Ask you to approve access - Exchange the code for tokens - Save tokens to `~/.config/openclaw/fitbit/token.json` Tokens are automatically refreshed when they expire. ## Commands The skill uses a 3-step pipeline: ```bash # Step 1: Fetch raw data from Fitbit API # IMPORTANT: For sleep queries (morning), use --date today not yesterday! # Fitbit returns last night's sleep under today's date. python3 scripts/fitbit_fetch_daily.py --date today --out /tmp/fitbit_raw.json # Step 2: Normalize into clean format (extracts actual sleep time, stages, activity) python3 scripts/fitbit_normalize_daily.py /tmp/fitbit_raw.json --out /tmp/fitbit_day.json # Step 3: Render for display (use --channel discord, telegram, or generic) python3 scripts/fitbit_render.py /tmp/fitbit_day.json --channel discord ``` For a specific date (YYYY-MM-DD format): ```bash python3 scripts/fitbit_fetch_daily.py --date 2026-03-25 --out /tmp/fitbit_raw.json ``` For sleep section only: ```bash python3 scripts/fitbit_render.py /tmp/fitbit_day.json --channel discord --section sleep ``` **Critical date rule:** When user asks about sleep in the morning (e.g., "how did I sleep"), use `--date today`. Fitbit's sleep API associates sleep with the date you woke up, so last night's sleep (Mar 25 11pm → Mar 26 7am) appears under date "today" (Mar 26). Only use `--date yesterday` for activity-only queries when you specifically want the previous full day's activity data. **Important:** Always run the full pipeline (fetch → normalize → render). Never use raw API `duration` field directly — it includes wake periods inside the sleep window. The normalized `duration_minutes` field (which maps to Fitbit's `minutesAsleep`) is the actual sleep time. ## Usage Examples **Sleep report:** ``` Fitbit — 2026-03-21 - Sleep: 7h 32m (score 85) | 93% efficiency - Stages: Deep: 1h 42m, Light: 3h 20m, REM: 1h 45m, Wake: 45m - Nap: 1h 6m ``` **Full daily summary:** ``` Fitbit — 2026-03-21 - Sleep: 7h 32m (score 85) | 93% efficiency - Stages: Deep: 1h 42m, Light: 3h 20m, REM: 1h 45m, Wake: 45m - Nap: 1h 6m - Steps: 8,234 - Calories: 1,892 (1,048 BMR) - Distance: 6.2 km - Resting HR: 58 bpm - Active mins: V. Active: 45m, Fair: 23m, Light: 1h 24m, Sedentary: 8h 12m - HR Zones: Out of Range: 12h, Fat Burn: 1h 30m, Cardio: 32m, Peak: 8m ``` **Steps only:** ``` - Steps: 8,234 ``` ## Python Dependencies No third-party dependencies required. Uses Python standard library: - `urllib.request` - HTTP requests - `json` - JSON parsing - `datetime` - Date handling - `zoneinfo` - Timezone support (Python 3.9+) ## Troubleshooting ### "Missing env var: FITBIT_CLIENT_ID" Environment variables not loaded. Source your `.env` file or ensure variables are set in the gateway environment: ```bash source ~/.openclaw/.env ``` ### "Token expired" error Tokens auto-refresh. If you see this error repeatedly: 1. Delete `~/.config/openclaw/fitbit/token.json` 2. Re-run `python3 scripts/fitbit_oauth_login.py` ### "No data found for this day" - Check `FITBIT_TZ` matches your timezone - Try `--date yesterday` to confirm data exists - Verify Fitbit account has data for the date ### Nap not separating from main sleep This may indicate `isMainSleep` flag isn't set correctly on your Fitbit account. The skill filters records by this flag — some older Fitbit devices don't set it properly. ### Sleep score not showing Not all Fitbit accounts/devices provide sleep scores. This is a Fitbit API limitation, not the skill. Efficiency and stages are always reported when available. ## API Endpoints Used - `GET /1/user/-/activities/date/{date}.json` - Daily activity summary - `GET /1.2/user/-/sleep/date/{date}.json` - Sleep records ## References - API details: `references/fitbit_api.md` - Output schema: `references/output_schema.md`

path: README.md
content: # fitbit-tracker

**The smartest way to track your Fitbit data in any chat.**

Effortless health monitoring powered by the official Fitbit API. No apps, no dashboards — just ask and get your health stats instantly.

## Features

- **Smart sleep tracking** — Automatically separates naps from main sleep
- **Complete activity picture** — Steps, calories, distance, active minutes, HR zones
- **Adaptive reporting** — Only shows what you ask for
- **Clean formatting** — Numbers formatted for readability
- **Nap detection** — Automatically identifies and reports daytime sleep

## Quick Start

### 1. Create Fitbit Developer App

1. Go to [dev.fitbit.com](https://dev.fitbit.com)
2. Register a new app
3. Set OAuth redirect URI to `http://localhost:8080`
4. Copy your Client ID and Client Secret

### 2. Configure

```bash
export FITBIT_CLIENT_ID="your_client_id"
export FITBIT_CLIENT_SECRET="your_client_secret"
export FITBIT_REDIRECT_URI="http://localhost:8080"
export FITBIT_TZ="Europe/London"
```

### 3. Authenticate

```bash
python3 scripts/fitbit_oauth_login.py
```

### 4. Use

Ask naturally:
- "how did I sleep?" → Sleep report with stages
- "just my steps" → Step count
- "full report" → Everything

## Example Output

**Sleep:**
```
Fitbit — 2026-03-21
- Sleep: 7h 32m (score 85) | 93% efficiency
  - Stages: Deep: 1h 42m, Light: 3h 20m, REM: 1h 45m, Wake: 45m
- Nap: 1h 6m
```

**Full summary:**
```
Fitbit — 2026-03-21
- Sleep: 7h 32m | 93% efficiency
  - Stages: Deep: 1h 42m, Light: 3h 20m, REM: 1h 45m, Wake: 45m
- Nap: 1h 6m
- Steps: 8,234
- Calories: 1,892 (1,048 BMR)
- Distance: 6.2 km
- Resting HR: 58 bpm
  - Active mins: V. Active: 45m, Fair: 23m, Light: 1h 24m
  - HR Zones: Fat Burn: 1h 30m, Cardio: 32m
```

## Requirements

- Fitbit account
- Fitbit OAuth app (dev.fitbit.com)
- Python 3.9+ (standard library only, no deps)

## Data

Uses official Fitbit API:
- Activity summary (steps, calories, distance, HR zones)
- Sleep records with staging (Deep, Light, REM, Wake)

No third-party services. No data stored externally.

## License

MIT


path: _meta.json
content: {
  "version": "0.2.2",
  "createdAt": 1742500000000,
  "author": "Crabsticksalad",
  "tags": ["fitbit", "health", "sleep", "activity", "wearable"],
  "description": "Full Fitbit integration with sleep staging, activity tracking, HR zones, and section-based reporting"
}


path: references/fitbit_api.md
content: # Fitbit API Reference

## Authentication

Fitbit uses OAuth 2.0 for authentication.

**Authorization URL:**
```
https://www.fitbit.com/oauth2/authorize
```

**Token URL:**
```
https://api.fitbit.com/oauth2/token
```

Authentication uses HTTP Basic auth with `client_id:client_secret` as credentials.

## Scopes

This skill requires these scopes:
- `activity` - Daily activity data
- `sleep` - Sleep records and stages
- `heartrate` - Heart rate data
- `profile` - User profile
- `weight` - Weight data (optional)

## API Endpoints

### Daily Activity Summary

```
GET https://api.fitbit.com/1/user/-/activities/date/{date}.json
```

**Parameters:**
- `date` - YYYY-MM-DD format or "today"/"yesterday"

**Response fields (summary):**
| Field | Type | Description |
|-------|------|-------------|
| `steps` | int | Step count |
| `caloriesOut` | int | Total calories burned |
| `caloriesBMR` | int | Basal metabolic rate calories |
| `activityCalories` | int | Calories from activity |
| `distance` | float | Total distance (km) |
| `distances` | array | Distance breakdown by activity |
| `sedentaryMinutes` | int | Sedentary time (minutes) |
| `lightlyActiveMinutes` | int | Light activity time |
| `fairlyActiveMinutes` | int | Moderate activity time |
| `veryActiveMinutes` | int | Vigorous activity time |
| `restingHeartRate` | int | Resting heart rate (bpm) |
| `heartRateZones` | array | Time in each HR zone |

**Heart rate zones:**
| Zone | Description |
|------|-------------|
| Out of Range | Below fat burn zone |
| Fat Burn | 50-70% of max HR |
| Cardio | 70-85% of max HR |
| Peak | Above 85% of max HR |

### Sleep Records

```
GET https://api.fitbit.com/1.2/user/-/sleep/date/{date}.json
```

**Parameters:**
- `date` - YYYY-MM-DD format or "today"/"yesterday"

**Response structure:**
```json
{
  "sleep": [
    {
      "dateOfSleep": "2026-03-21",
      "duration": 29460000,        // milliseconds
      "efficiency": 89,             // percentage
      "endTime": "2026-03-21T06:29:30.000",
      "isMainSleep": true,          // false = nap
      "levels": {
        "data": [...],               // minute-by-minute stages
        "summary": {
          "deep": { "minutes": 92, "count": 3 },
          "light": { "minutes": 240, "count": 5 },
          "rem": { "minutes": 105, "count": 4 },
          "wake": { "minutes": 54, "count": 2 }
        }
      },
      "minutesAsleep": 437,
      "startTime": "2026-03-20T22:18:00.000",
      "timeInBed": 491,
      "type": "stages"              // or "classic"
    }
  ],
  "summary": {
    "totalMinutesAsleep": 437,
    "totalTimeInBed": 491,
    "totalSleepRecords": 2
  }
}
```

### Key Differences

**Main Sleep vs Naps:**
- `isMainSleep: true` - Primary sleep session (usually overnight)
- `isMainSleep: false` - Nap or short sleep

Fitbit returns records in reverse chronological order (naps first, main sleep second).

**Sleep Stages vs Classic:**
- `type: "stages"` - Uses Fitbit's Smart Wake alarm staging (recommended)
- `type: "classic"` - Traditional sleep detection

## Rate Limits

- 150 requests per hour for user-level endpoints
- 500 requests per hour for summary endpoints
- Tokens auto-refresh when expired

## Common Errors

| Error | Cause | Solution |
|-------|-------|----------|
| 401 Unauthorized | Invalid/expired token | Re-run OAuth login |
| 429 Too Many Requests | Rate limit exceeded | Wait and retry |
| 400 Bad Request | Invalid date format | Use YYYY-MM-DD |


path: references/output_schema.md
content: # Output Schema

## Normalized Daily Output

`fitbit_normalize_daily.py` transforms raw Fitbit API responses into a clean, consistent format.

### Complete Schema

```json
{
  "date": "YYYY-MM-DD",
  "timezone": "Europe/London",
  "generated_at": "2026-03-21T10:30:00.000Z",

  "sleep": {
    "duration_minutes": 437,
    "efficiency": 89,
    "time_in_bed_minutes": 491,
    "sleep_score": 85,
    "deep_minutes": 92,
    "light_minutes": 240,
    "rem_minutes": 105,
    "wake_minutes": 54,
    "nap_minutes": 66,
    "source": "fitbit"
  },

  "activity": {
    "steps": 8234,
    "distance_km": 6.2,
    "calories_out": 1892,
    "calories_bmr": 1048,
    "activity_calories": 298,
    "sedentary_minutes": 492,
    "lightly_active_minutes": 84,
    "fairly_active_minutes": 23,
    "very_active_minutes": 45,
    "resting_heart_rate": 58,
    "heart_rate_zones": [
      {
        "name": "Out of Range",
        "minutes": 720,
        "calories": 1200,
        "min_hr": 60,
        "max_hr": 100
      },
      {
        "name": "Fat Burn",
        "minutes": 90,
        "calories": 350,
        "min_hr": 100,
        "max_hr": 140
      },
      {
        "name": "Cardio",
        "minutes": 32,
        "calories": 200,
        "min_hr": 140,
        "max_hr": 170
      },
      {
        "name": "Peak",
        "minutes": 8,
        "calories": 50,
        "min_hr": 170,
        "max_hr": 220
      }
    ],
    "source": "fitbit"
  },

  "sources_present": ["fitbit"],
  "source": {
    "raw_files": ["/tmp/fitbit_raw_2026-03-21.json"]
  }
}
```

## Field Descriptions

### Top Level

| Field | Type | Description |
|-------|------|-------------|
| `date` | string | Date in YYYY-MM-DD format |
| `timezone` | string | IANA timezone (e.g., Europe/London) |
| `generated_at` | string | ISO-8601 timestamp when generated |

### Sleep Object

| Field | Type | Description |
|-------|------|-------------|
| `duration_minutes` | int\|null | Actual sleep time (excludes awake periods) |
| `efficiency` | int\|null | Sleep efficiency percentage |
| `time_in_bed_minutes` | int\|null | Total time spent in bed |
| `sleep_score` | int\|null | Overall sleep score (not always available) |
| `deep_minutes` | int\|null | Deep sleep stage minutes |
| `light_minutes` | int\|null | Light sleep stage minutes |
| `rem_minutes` | int\|null | REM sleep stage minutes |
| `wake_minutes` | int\|null | Wake/awake minutes during night |
| `nap_minutes` | int\|null | Nap duration (if daytime sleep detected) |
| `source` | string | Always "fitbit" |

### Activity Object

| Field | Type | Description |
|-------|------|-------------|
| `steps` | int\|null | Daily step count |
| `distance_km` | float\|null | Total distance in kilometers |
| `calories_out` | int\|null | Total calories burned |
| `calories_bmr` | int\|null | Basal metabolic rate calories |
| `activity_calories` | int\|null | Calories from activity (excludes BMR) |
| `sedentary_minutes` | int\|null | Minutes in sedentary state |
| `lightly_active_minutes` | int\|null | Light activity minutes |
| `fairly_active_minutes` | int\|null | Moderate activity minutes |
| `very_active_minutes` | int\|null | Vigorous activity minutes |
| `resting_heart_rate` | int\|null | Resting heart rate in BPM |
| `heart_rate_zones` | array | Time in each heart rate zone |
| `source` | string | Always "fitbit" |

### Heart Rate Zone Object

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Zone name (Out of Range, Fat Burn, Cardio, Peak) |
| `minutes` | int | Minutes spent in this zone |
| `calories` | int | Calories burned in this zone |
| `min_hr` | int | Minimum heart rate for this zone |
| `max_hr` | int | Maximum heart rate for this zone |

## Notes

- All `null` fields indicate data not available from Fitbit API
- `nap_minutes` is extracted separately from main sleep records
- Heart rate zones are only included when there is time spent in them
- `activity_calories` = `calories_out` - `calories_bmr`


path: requirements.txt
content: # No external dependencies required
# Uses Python standard library only:
# - urllib.request
# - json
# - datetime
# - zoneinfo (Python 3.9+)


path: scripts/fitbit_fetch_daily.py
content: #!/usr/bin/env python3
"""Fetch Fitbit daily summaries (activity + sleep) for a local day.

Endpoints (typical):
- GET /1/user/-/activities/date/<date>.json
- GET /1.2/user/-/sleep/date/<date>.json

No third-party deps.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import urllib.request
from datetime import datetime, timedelta, timezone
from typing import Any, Dict

try:
    from zoneinfo import ZoneInfo
except Exception:  # pragma: no cover
    ZoneInfo = None  # type: ignore

from fitbit_token import get_access_token


API_BASE = "https://api.fitbit.com"


def must_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        print(f"Missing env var: {name}", file=sys.stderr)
        sys.exit(2)
    return v


def _tzinfo(tz_name: str):
    if ZoneInfo is None:
        return None
    try:
        return ZoneInfo(tz_name)
    except Exception:
        return None


def resolve_date(s: str, tz_name: str) -> str:
    s = s.strip().lower()
    tz = _tzinfo(tz_name)
    now = datetime.now(tz) if tz else datetime.now()
    if s == "today":
        return now.strftime("%Y-%m-%d")
    if s == "yesterday":
        return (now - timedelta(days=1)).strftime("%Y-%m-%d")
    datetime.strptime(s, "%Y-%m-%d")
    return s


def http_get_json(url: str, access_token: str) -> Any:
    req = urllib.request.Request(url, method="GET")
    req.add_header("Authorization", f"Bearer {access_token}")
    req.add_header("Accept", "application/json")
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode("utf-8"))


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--date", required=True, help="today|yesterday|YYYY-MM-DD")
    ap.add_argument("--out", required=True)
    ap.add_argument("--tz", default=os.environ.get("FITBIT_TZ", "UTC"))
    args = ap.parse_args()

    day = resolve_date(args.date, args.tz)

    client_id = must_env("FITBIT_CLIENT_ID")
    client_secret = must_env("FITBIT_CLIENT_SECRET")
    access_token = get_access_token(client_id=client_id, client_secret=client_secret)

    act_url = f"{API_BASE}/1/user/-/activities/date/{day}.json"
    sleep_url = f"{API_BASE}/1.2/user/-/sleep/date/{day}.json"

    bundle: Dict[str, Any] = {
        "requested_date": day,
        "requested_tz": args.tz,
        "fetched_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        "api_base": API_BASE,
        "endpoints": {
            "activity": {"url": act_url, "data": http_get_json(act_url, access_token)},
            "sleep": {"url": sleep_url, "data": http_get_json(sleep_url, access_token)},
        },
    }

    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(bundle, f, indent=2, sort_keys=True)
        f.write("\n")


if __name__ == "__main__":
    main()


path: scripts/fitbit_normalize_daily.py
content: #!/usr/bin/env python3
"""Normalize Fitbit daily raw bundle into Wellness-like fields.

Usage:
  python3 scripts/fitbit_normalize_daily.py raw.json --out fitbit_day.json
"""

from __future__ import annotations

import argparse
import json
from datetime import datetime, timezone
from typing import Any, Dict, Optional


def safe_get(d: Any, *keys):
    cur = d
    for k in keys:
        if not isinstance(cur, dict):
            return None
        cur = cur.get(k)
    return cur


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("raw")
    ap.add_argument("--out", required=True)
    args = ap.parse_args()

    raw = json.load(open(args.raw, "r", encoding="utf-8"))
    date = raw.get("requested_date")
    tz = raw.get("requested_tz")

    ep = raw.get("endpoints") or {}
    act = (ep.get("activity") or {}).get("data")
    slp = (ep.get("sleep") or {}).get("data")

    steps = safe_get(act, "summary", "steps")
    dist = None
    # Fitbit distance is in summary.distances list; pick "total" or first.
    distances = safe_get(act, "summary", "distances")
    if isinstance(distances, list) and distances:
        for x in distances:
            if isinstance(x, dict) and x.get("activity") == "total":
                dist = x.get("distance")
                break
        if dist is None and isinstance(distances[0], dict):
            dist = distances[0].get("distance")

    # Get full activity summary
    calories_out = safe_get(act, "summary", "caloriesOut")
    calories_bmr = safe_get(act, "summary", "caloriesBMR")
    activity_calories = safe_get(act, "summary", "activityCalories")
    sedentary_min = safe_get(act, "summary", "sedentaryMinutes")
    lightly_active = safe_get(act, "summary", "lightlyActiveMinutes")
    fairly_active = safe_get(act, "summary", "fairlyActiveMinutes")
    very_active = safe_get(act, "summary", "veryActiveMinutes")
    resting_hr = safe_get(act, "summary", "restingHeartRate")

    # Heart rate zones
    hr_zones = []
    for zone in safe_get(act, "summary", "heartRateZones") or []:
        if isinstance(zone, dict):
            hr_zones.append({
                "name": zone.get("name"),
                "minutes": zone.get("minutes"),
                "calories": zone.get("caloriesOut"),
                "min_hr": zone.get("min"),
                "max_hr": zone.get("max")
            })

    # Sleep data
    sleeps = safe_get(slp, "sleep")
    sleep_minutes = None
    sleep_score = None
    sleep_efficiency = None
    sleep_deep = None
    sleep_light = None
    sleep_rem = None
    sleep_wake = None
    time_in_bed = None
    nap_minutes = None

    if isinstance(sleeps, list) and sleeps:
        # Separate main sleep from naps (Fitbit returns nap before main sleep)
        main_records = [s for s in sleeps if s.get("isMainSleep")]
        nap_records = [s for s in sleeps if not s.get("isMainSleep")]

        # Main sleep
        s0 = main_records[0] if main_records else (sleeps[0] if isinstance(sleeps[0], dict) else None)
        if isinstance(s0, dict):
            sleep_minutes = s0.get("minutesAsleep")
            sleep_efficiency = s0.get("efficiency")
            time_in_bed = s0.get("timeInBed")
            # Sleep stages from summary
            stages = s0.get("levels", {}).get("summary", {}) if isinstance(s0.get("levels"), dict) else {}
            if isinstance(stages, dict):
                sleep_deep = stages.get("deep", {}).get("minutes") if isinstance(stages.get("deep"), dict) else None
                sleep_light = stages.get("light", {}).get("minutes") if isinstance(stages.get("light"), dict) else None
                sleep_rem = stages.get("rem", {}).get("minutes") if isinstance(stages.get("rem"), dict) else None
                sleep_wake = stages.get("wake", {}).get("minutes") if isinstance(stages.get("wake"), dict) else None
            # score may be in s0['sleepScore']['overallScore'] depending on API.
            ss = s0.get("sleepScore")
            if isinstance(ss, dict):
                sleep_score = ss.get("overallScore") or ss.get("score")

        # Nap (take first nap if multiple)
        if nap_records and isinstance(nap_records[0], dict):
            nap_minutes = nap_records[0].get("minutesAsleep") or nap_records[0].get("timeInBed")

    out: Dict[str, Any] = {
        "date": date,
        "timezone": tz,
        "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        "sleep": {
            "duration_minutes": sleep_minutes,
            "efficiency": sleep_efficiency,
            "time_in_bed_minutes": time_in_bed,
            "sleep_score": sleep_score,
            "deep_minutes": sleep_deep,
            "light_minutes": sleep_light,
            "rem_minutes": sleep_rem,
            "wake_minutes": sleep_wake,
            "nap_minutes": nap_minutes,
            "source": "fitbit",
        },
        "activity": {
            "steps": steps,
            "distance_km": dist,
            "calories_out": calories_out,
            "calories_bmr": calories_bmr,
            "activity_calories": activity_calories,
            "sedentary_minutes": sedentary_min,
            "lightly_active_minutes": lightly_active,
            "fairly_active_minutes": fairly_active,
            "very_active_minutes": very_active,
            "resting_heart_rate": resting_hr,
            "heart_rate_zones": hr_zones,
            "source": "fitbit",
        },
        "sources_present": ["fitbit"],
        "source": {"raw_files": [args.raw]},
    }

    json.dump(out, open(args.out, "w", encoding="utf-8"), indent=2, sort_keys=True)
    open(args.out, "a", encoding="utf-8").write("\n")


if __name__ == "__main__":
    main()


path: scripts/fitbit_oauth_login.py
content: #!/usr/bin/env python3
"""Interactive Fitbit OAuth login.

Env:
- FITBIT_CLIENT_ID
- FITBIT_CLIENT_SECRET
- FITBIT_REDIRECT_URI
Optional:
- FITBIT_SCOPES (default: activity sleep heartrate profile weight)

Modes:
- default: copy/paste redirect URL or code
- --loopback: listen on loopback redirect URI (127.0.0.1)
"""

from __future__ import annotations

import argparse
import os
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer

from fitbit_token import AUTH_URL, exchange_code_for_token, save_token, token_path


DEFAULT_SCOPES = "activity sleep heartrate profile weight"


def must_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        print(f"Missing env var: {name}", file=sys.stderr)
        sys.exit(2)
    return v


def parse_code(user_input: str) -> str:
    s = user_input.strip()
    if s.startswith("http://") or s.startswith("https://"):
        u = urllib.parse.urlparse(s)
        q = urllib.parse.parse_qs(u.query)
        code = (q.get("code") or [None])[0]
        if code:
            return code
        raise SystemExit("Could not find ?code= in the pasted redirect URL")
    return s


def listen_for_code(redirect_uri: str, timeout_s: int = 180) -> str:
    u = urllib.parse.urlparse(redirect_uri)
    if u.scheme != "http" or u.hostname not in ("127.0.0.1", "localhost"):
        raise SystemExit("Loopback mode requires FITBIT_REDIRECT_URI like http://127.0.0.1:<port>/<path>")
    if not u.port:
        raise SystemExit("Loopback mode requires an explicit port in FITBIT_REDIRECT_URI")
    path = u.path or "/"

    got = {"code": None}

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):  # noqa: N802
            parsed = urllib.parse.urlparse(self.path)
            if parsed.path != path:
                self.send_response(404)
                self.end_headers()
                return
            q = urllib.parse.parse_qs(parsed.query)
            code = (q.get("code") or [None])[0]
            if code:
                got["code"] = code
                self.send_response(200)
                self.send_header("Content-Type", "text/plain; charset=utf-8")
                self.end_headers()
                self.wfile.write(b"OK. You can close this tab and return to OpenClaw.\n")
            else:
                self.send_response(400)
                self.end_headers()

        def log_message(self, fmt, *args):
            return

    httpd = HTTPServer((u.hostname, int(u.port)), Handler)
    httpd.timeout = 1

    import time

    deadline = time.time() + timeout_s
    while time.time() < deadline and not got["code"]:
        httpd.handle_request()

    try:
        httpd.server_close()
    except Exception:
        pass

    if not got["code"]:
        raise SystemExit("Timed out waiting for OAuth redirect. Use copy/paste mode instead.")
    return str(got["code"])


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--loopback", action="store_true")
    args = ap.parse_args()

    client_id = must_env("FITBIT_CLIENT_ID")
    client_secret = must_env("FITBIT_CLIENT_SECRET")
    redirect_uri = must_env("FITBIT_REDIRECT_URI")
    scopes = os.environ.get("FITBIT_SCOPES", DEFAULT_SCOPES)

    params = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": scopes,
    }
    auth_url = AUTH_URL + "?" + urllib.parse.urlencode(params)

    print("Open this URL in a browser and approve access:\n")
    print(auth_url)

    if args.loopback:
        print("\nWaiting for redirect on:")
        print(redirect_uri)
        code = listen_for_code(redirect_uri)
    else:
        print("\nAfter approval, paste either the full redirect URL or just the code:")
        code = parse_code(input("> "))

    tok = exchange_code_for_token(
        code=code,
        redirect_uri=redirect_uri,
        client_id=client_id,
        client_secret=client_secret,
    )

    p = save_token(tok, token_path())
    print(f"\n[OK] Token saved to: {p}")


if __name__ == "__main__":
    main()


path: scripts/fitbit_render.py
content: #!/usr/bin/env python3
"""Render normalized Fitbit daily JSON.

Usage:
  python3 scripts/fitbit_render.py day.json --format markdown --channel generic
"""

from __future__ import annotations

import argparse
import json
from typing import Any, Dict


def style(channel: str, markdown: bool) -> Dict[str, Any]:
    c = (channel or "generic").lower()
    if not markdown or c == "telegram":
        return {"md": False, "bL": "", "bR": "", "bullet": "-"}
    if c in ("slack", "whatsapp"):
        return {"md": True, "bL": "*", "bR": "*", "bullet": "-"}
    return {"md": True, "bL": "**", "bR": "**", "bullet": "-"}


def fmt_minutes(mins: int) -> str:
    """Convert minutes to 'Xh Ym' format."""
    if mins is None:
        return "—"
    h = mins // 60
    m = mins % 60
    if h > 0 and m > 0:
        return f"{h}h {m}m"
    elif h > 0:
        return f"{h}h"
    else:
        return f"{m}m"


def render_sleep(sleep, st):
    """Render sleep section only."""
    lines = []
    if sleep.get("duration_minutes") is not None:
        sleep_info = fmt_minutes(sleep.get('duration_minutes'))
        if sleep.get("sleep_score"):
            sleep_info += f" (score {sleep.get('sleep_score')})"
        if sleep.get("efficiency"):
            sleep_info += f" | {sleep.get('efficiency')}% efficiency"
        lines.append(f"{st['bullet']} Sleep: {sleep_info}")

        stages = []
        if sleep.get("deep_minutes") is not None:
            stages.append(f"Deep: {fmt_minutes(sleep.get('deep_minutes'))}")
        if sleep.get("light_minutes") is not None:
            stages.append(f"Light: {fmt_minutes(sleep.get('light_minutes'))}")
        if sleep.get("rem_minutes") is not None:
            stages.append(f"REM: {fmt_minutes(sleep.get('rem_minutes'))}")
        if sleep.get("wake_minutes") is not None:
            stages.append(f"Wake: {fmt_minutes(sleep.get('wake_minutes'))}")
        if stages:
            lines.append(f"  {st['bullet']} Stages: {', '.join(stages)}")

    # Nap (if present)
    if sleep.get("nap_minutes") is not None:
        lines.append(f"{st['bullet']} Nap: {fmt_minutes(sleep.get('nap_minutes'))}")

    return lines


def render_activity(act, st):
    """Render activity section only."""
    lines = []
    if act.get("steps") is not None:
        lines.append(f"{st['bullet']} Steps: {act.get('steps'):,}")
    if act.get("calories_out") is not None:
        lines.append(f"{st['bullet']} Calories: {act.get('calories_out'):,} ({act.get('calories_bmr', 0):,} BMR)")
    if act.get("distance_km") is not None:
        lines.append(f"{st['bullet']} Distance: {act.get('distance_km')} km")
    if act.get("resting_heart_rate") is not None:
        lines.append(f"{st['bullet']} Resting HR: {act.get('resting_heart_rate')} bpm")

    active_mins = []
    if act.get("very_active_minutes") is not None and act.get("very_active_minutes", 0) > 0:
        active_mins.append(f"V. Active: {fmt_minutes(act.get('very_active_minutes'))}")
    if act.get("fairly_active_minutes") is not None and act.get("fairly_active_minutes", 0) > 0:
        active_mins.append(f"Fair: {fmt_minutes(act.get('fairly_active_minutes'))}")
    if act.get("lightly_active_minutes") is not None and act.get("lightly_active_minutes", 0) > 0:
        active_mins.append(f"Light: {fmt_minutes(act.get('lightly_active_minutes'))}")
    if act.get("sedentary_minutes") is not None and act.get("sedentary_minutes", 0) > 0:
        active_mins.append(f"Sedentary: {fmt_minutes(act.get('sedentary_minutes'))}")
    if active_mins:
        lines.append(f"  {st['bullet']} Active mins: {', '.join(active_mins)}")

    hr_zones = act.get("heart_rate_zones") or []
    if hr_zones:
        zone_info = []
        for zone in hr_zones:
            if isinstance(zone, dict) and zone.get("minutes", 0) > 0:
                zone_info.append(f"{zone.get('name')}: {fmt_minutes(zone.get('minutes'))}")
        if zone_info:
            lines.append(f"  {st['bullet']} HR Zones: {', '.join(zone_info)}")
    return lines


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("input")
    ap.add_argument("--format", choices=["text", "markdown"], default="markdown")
    ap.add_argument("--channel", default="generic")
    ap.add_argument("--section", choices=["all", "sleep", "activity"], default="all",
                    help="Which section to render: all, sleep, or activity")
    args = ap.parse_args()

    doc = json.load(open(args.input, "r", encoding="utf-8"))
    st = style(args.channel, args.format == "markdown")

    date = doc.get("date") or "(unknown date)"
    sleep = doc.get("sleep") or {}
    act = doc.get("activity") or {}

    def b(s: str) -> str:
        return f"{st['bL']}{s}{st['bR']}" if st["md"] and st["bL"] else s

    lines = [b(f"Fitbit — {date}")]

    section = args.section.lower()

    if section in ("all", "sleep"):
        lines.extend(render_sleep(sleep, st))

    if section in ("all", "activity"):
        lines.extend(render_activity(act, st))

    if len(lines) == 1:
        lines.append(f"{st['bullet']} No data found for this day")

    print("\n".join(lines).strip() + "\n", end="")


if __name__ == "__main__":
    main()


path: scripts/fitbit_token.py
content: #!/usr/bin/env python3
"""Fitbit token utilities (OAuth2).

No third-party deps.
"""

from __future__ import annotations

import base64
import json
import os
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional


AUTH_URL = "https://www.fitbit.com/oauth2/authorize"
TOKEN_URL = "https://api.fitbit.com/oauth2/token"

DEFAULT_TOKEN_PATH = os.path.expanduser("~/.config/openclaw/fitbit/token.json")


class FitbitAuthError(RuntimeError):
    pass


def token_path() -> str:
    return os.environ.get("FITBIT_TOKEN_PATH", DEFAULT_TOKEN_PATH)


def _mkdirp(p: str) -> None:
    Path(p).parent.mkdir(parents=True, exist_ok=True)


def now_s() -> int:
    return int(time.time())


def load_token(path: Optional[str] = None) -> Dict[str, Any]:
    p = path or token_path()
    with open(p, "r", encoding="utf-8") as f:
        return json.load(f)


def save_token(tok: Dict[str, Any], path: Optional[str] = None) -> str:
    p = path or token_path()
    _mkdirp(p)
    tmp = p + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(tok, f, indent=2, sort_keys=True)
        f.write("\n")
    os.replace(tmp, p)
    try:
        os.chmod(p, 0o600)
    except PermissionError:
        pass
    return p


def is_expired(tok: Dict[str, Any], skew_s: int = 60) -> bool:
    exp = tok.get("expires_at")
    if exp is None:
        return False
    try:
        return now_s() >= int(exp) - skew_s
    except Exception:
        return False


def _basic_auth(client_id: str, client_secret: str) -> str:
    raw = f"{client_id}:{client_secret}".encode("utf-8")
    return base64.b64encode(raw).decode("ascii")


def _post_form(url: str, data: Dict[str, str], headers: Dict[str, str]) -> Dict[str, Any]:
    body = urllib.parse.urlencode(data).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")
    for k, v in headers.items():
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace")
        raise FitbitAuthError(f"HTTP {e.code} calling {url}: {raw}") from e


def exchange_code_for_token(*, code: str, redirect_uri: str, client_id: str, client_secret: str) -> Dict[str, Any]:
    tok = _post_form(
        TOKEN_URL,
        {
            "code": code,
            "grant_type": "authorization_code",
            "redirect_uri": redirect_uri,
        },
        {"Authorization": f"Basic {_basic_auth(client_id, client_secret)}"},
    )
    _annotate(tok)
    return tok


def refresh_access_token(*, refresh_token: str, client_id: str, client_secret: str) -> Dict[str, Any]:
    tok = _post_form(
        TOKEN_URL,
        {
            "refresh_token": refresh_token,
            "grant_type": "refresh_token",
        },
        {"Authorization": f"Basic {_basic_auth(client_id, client_secret)}"},
    )
    _annotate(tok)
    return tok


def _annotate(tok: Dict[str, Any]) -> None:
    exp_in = tok.get("expires_in")
    if exp_in is not None:
        try:
            tok["expires_at"] = now_s() + int(exp_in)
        except Exception:
            pass


def get_access_token(*, client_id: str, client_secret: str, token_file: Optional[str] = None) -> str:
    p = token_file or token_path()
    tok = load_token(p)
    access = tok.get("access_token")
    if not access:
        raise FitbitAuthError(f"No access_token found in {p}. Run fitbit_oauth_login.py")
    if not is_expired(tok):
        return str(access)

    rt = tok.get("refresh_token")
    if not rt:
        raise FitbitAuthError("Token expired and no refresh_token available; re-run OAuth login")

    new_tok = refresh_access_token(refresh_token=str(rt), client_id=client_id, client_secret=client_secret)
    if not new_tok.get("refresh_token"):
        new_tok["refresh_token"] = rt
    save_token(new_tok, p)
    return str(new_tok["access_token"])


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