diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c25047b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Eric Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8d2a2d --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# Garmin Parser + +TypeScript client and REST API for fetching health and fitness data from [Garmin Connect](https://connect.garmin.com/). + +## Features + +- **CLI mode** — Fetch today's data interactively or export a date range to JSON +- **REST API mode** — Serve Garmin data over HTTP with Express +- **Docker support** — Multi-arch container image with GitHub Actions CI/CD +- **Session persistence** — OAuth tokens are saved and reused across restarts +- Daily steps, calories (total / active / BMR), heart rate, sleep, weight, and hydration +- Recent activities with per-activity calorie breakdown + +## Prerequisites + +- Node.js >= 20 +- A [Garmin Connect](https://connect.garmin.com/) account + +## Quick Start + +### 1. Install dependencies + +```bash +npm install +``` + +### 2. Configure credentials + +```bash +cp .env.example .env +``` + +Edit `.env` and fill in your Garmin Connect credentials: + +``` +GARMIN_USERNAME=your.email@example.com +GARMIN_PASSWORD=your_password_here +``` + +### 3. Run in CLI mode + +```bash +# Show today's data +npm start + +# Export last 7 days to JSON +npm start -- --days 7 + +# Export last 28 days to JSON +npm start -- --days 28 +``` + +Output files are saved to `./output/`. + +### 4. Run as REST API server + +```bash +# Build and start +npm run build +npm run serve + +# Or run in dev mode +npm run dev:server +``` + +The server starts on port `3000` by default (configurable via `PORT` env var). + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Liveness probe | +| `GET` | `/ready` | Readiness probe (checks Garmin client status) | +| `GET` | `/api/profile` | User profile | +| `GET` | `/api/daily?date=YYYY-MM-DD` | Daily data for a specific date (defaults to today) | +| `GET` | `/api/range?days=7` | Aggregated data for a date range (1–28 days) | +| `GET` | `/api/activities?limit=10` | Recent activities (1–50) | + +### Example response — `/api/daily` + +```json +{ + "success": true, + "data": { + "date": "2025-12-25", + "steps": 8432, + "calories": { "total": 2150, "active": 650, "bmr": 1500 }, + "heartRate": { "restingHeartRate": 58, "maxHeartRate": 142, "minHeartRate": 52 }, + "sleep": { "durationSeconds": 28800, "deepSleepSeconds": 7200, "lightSleepSeconds": 14400, "remSleepSeconds": 5400, "awakeSleepSeconds": 1800 }, + "weight": 68.2, + "hydration": 2000 + } +} +``` + +## Docker + +### Pull from GitHub Container Registry + +```bash +docker pull ghcr.io/happyeric77/garmin-parser:latest +``` + +### Run + +```bash +docker run -d \ + -p 3000:3000 \ + -e GARMIN_USERNAME=your.email@example.com \ + -e GARMIN_PASSWORD=your_password \ + -v garmin-tokens:/app/tokens \ + ghcr.io/happyeric77/garmin-parser:latest +``` + +### Build locally + +```bash +docker build -t garmin-parser . +``` + +## Project Structure + +``` +src/ + garmin-client.ts # Garmin Connect client wrapper + server.ts # Express REST API server + index.ts # CLI entry point +``` + +## License + +[MIT](LICENSE) diff --git a/package.json b/package.json index eb998e9..bbdb537 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "health", "api" ], - "author": "", - "license": "ISC", - "description": "POC for fetching Garmin Connect data using TypeScript", + "author": "Eric Lee", + "license": "MIT", + "description": "TypeScript client and REST API for fetching health and fitness data from Garmin Connect", "dependencies": { "cli-progress": "^3.12.0", "dotenv": "^17.2.3", diff --git a/src/garmin-client.ts b/src/garmin-client.ts index 5aea149..587be83 100644 --- a/src/garmin-client.ts +++ b/src/garmin-client.ts @@ -53,9 +53,9 @@ export interface GarminWeightData { } export interface CaloriesData { - total: number | null; // 總消耗卡路里 (BMR + 活動) - active: number | null; // 活動消耗卡路里 - bmr: number | null; // 基礎代謝卡路里 (Basal Metabolic Rate) + total: number | null; // Total calories burned (BMR + active) + active: number | null; // Active calories burned + bmr: number | null; // Basal Metabolic Rate calories } export interface DailyData { diff --git a/src/test-calories-api.ts b/src/test-calories-api.ts deleted file mode 100644 index ef53636..0000000 --- a/src/test-calories-api.ts +++ /dev/null @@ -1,282 +0,0 @@ -import * as dotenv from 'dotenv'; -import { GarminConnect } from 'garmin-connect'; -import * as path from 'path'; -import * as fs from 'fs'; - -// Load environment variables -dotenv.config(); - -// Extended GarminConnect type to include methods not in type definitions -interface ExtendedGarminConnect extends GarminConnect { - exportTokenToFile(path: string): void; - loadTokenByFile(path: string): void; - get(url: string, data?: any): Promise; -} - -const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', - red: '\x1b[31m', -}; - -function log(color: string, label: string, message: string): void { - console.log(`${color}[${label}]${colors.reset} ${message}`); -} - -function formatDate(date: Date): string { - return date.toISOString().split('T')[0]; -} - -async function testEndpoint( - client: ExtendedGarminConnect, - name: string, - url: string, - params?: any -): Promise { - console.log(`\n${colors.bright}${colors.blue}${'='.repeat(60)}${colors.reset}`); - console.log(`${colors.bright}Testing: ${name}${colors.reset}`); - console.log(`${colors.cyan}URL: ${url}${colors.reset}`); - if (params) { - console.log(`${colors.cyan}Params: ${JSON.stringify(params)}${colors.reset}`); - } - console.log(`${colors.blue}${'='.repeat(60)}${colors.reset}`); - - try { - const result = await client.get(url, params ? { params } : undefined); - log(colors.green, 'SUCCESS', 'API returned data'); - - // Pretty print the result (limited) - const resultStr = JSON.stringify(result, null, 2); - if (resultStr.length > 2000) { - console.log(resultStr.substring(0, 2000) + '\n... (truncated)'); - } else { - console.log(resultStr); - } - - return result; - } catch (error: any) { - log(colors.red, 'FAILED', error.message || 'Unknown error'); - return null; - } -} - -async function main() { - console.log(` -${colors.bright}${colors.green} -╔════════════════════════════════════════════════════════════╗ -║ Garmin API Endpoint Tester - Calories ║ -╚════════════════════════════════════════════════════════════╝ -${colors.reset}`); - - // Validate environment variables - const username = process.env.GARMIN_USERNAME; - const password = process.env.GARMIN_PASSWORD; - - if (!username || !password) { - console.error(`${colors.red}Error: Missing Garmin credentials in .env${colors.reset}`); - process.exit(1); - } - - // Create client - const client = new GarminConnect({ - username, - password, - }) as ExtendedGarminConnect; - - const tokenPath = path.join(process.cwd(), 'tokens'); - - // Try to load existing tokens - try { - if (fs.existsSync(path.join(tokenPath, 'oauth1_token.json'))) { - log(colors.yellow, 'AUTH', 'Loading stored tokens...'); - client.loadTokenByFile(tokenPath); - // Test if session is valid - await client.getUserProfile(); - log(colors.green, 'AUTH', 'Session restored successfully!'); - } else { - throw new Error('No tokens found'); - } - } catch { - log(colors.yellow, 'AUTH', 'Logging in fresh...'); - await client.login(); - client.exportTokenToFile(tokenPath); - log(colors.green, 'AUTH', 'Login successful!'); - } - - // Test date - use yesterday to ensure data exists - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - const dateStr = formatDate(yesterday); - log(colors.cyan, 'INFO', `Testing with date: ${dateStr}`); - - // Base API URL - const API_BASE = 'https://connectapi.garmin.com'; - - // Get user display name for some endpoints - const profile = await client.getUserProfile() as any; - const displayName = profile.displayName || profile.userName; - log(colors.cyan, 'INFO', `User display name: ${displayName}`); - - // ============================================================ - // Test various endpoints that might contain calorie data - // ============================================================ - - const results: { [key: string]: any } = {}; - - // 1. User Summary Daily - This is the most likely endpoint - results['usersummary-daily'] = await testEndpoint( - client, - 'User Summary Daily', - `${API_BASE}/usersummary-service/usersummary/daily/${dateStr}` - ); - - // 2. User Summary Daily with displayName - results['usersummary-daily-displayname'] = await testEndpoint( - client, - 'User Summary Daily (with displayName)', - `${API_BASE}/usersummary-service/usersummary/daily/${displayName}`, - { calendarDate: dateStr } - ); - - // 3. Wellness Daily Summary Chart - results['wellness-dailysummary'] = await testEndpoint( - client, - 'Wellness Daily Summary Chart', - `${API_BASE}/wellness-service/wellness/dailySummaryChart/${displayName}`, - { date: dateStr } - ); - - // 4. Wellness Daily Summary (alternative) - results['wellness-dailysummary-alt'] = await testEndpoint( - client, - 'Wellness Daily Summary (alt)', - `${API_BASE}/wellness-service/wellness/dailySummaryChart`, - { date: dateStr } - ); - - // 5. Fitness Stats - Calories - results['fitnessstats-calories'] = await testEndpoint( - client, - 'Fitness Stats - Calories', - `${API_BASE}/fitnessstats-service/activity`, - { - aggregation: 'daily', - startDate: dateStr, - endDate: dateStr, - metric: 'calories' - } - ); - - // 6. User Summary - All Stats - results['usersummary-stats'] = await testEndpoint( - client, - 'User Summary Stats', - `${API_BASE}/usersummary-service/stats/${dateStr}/${dateStr}` - ); - - // 7. Daily Summary from proxy - results['proxy-dailysummary'] = await testEndpoint( - client, - 'Proxy Daily Summary', - `https://connect.garmin.com/modern/proxy/usersummary-service/usersummary/daily/${dateStr}` - ); - - // 8. Wellness epoch - results['wellness-epoch'] = await testEndpoint( - client, - 'Wellness Epoch', - `${API_BASE}/wellness-service/wellness/epoch/wellness/${dateStr}/${dateStr}` - ); - - // 9. User Summary Calories Burned - results['usersummary-caloriesburned'] = await testEndpoint( - client, - 'User Summary Calories Burned', - `${API_BASE}/usersummary-service/stats/calories/daily/${dateStr}/${dateStr}` - ); - - // ============================================================ - // Summary - // ============================================================ - - console.log(`\n${colors.bright}${colors.green}${'='.repeat(60)}${colors.reset}`); - console.log(`${colors.bright}${colors.green}SUMMARY - Calorie Fields Found${colors.reset}`); - console.log(`${colors.green}${'='.repeat(60)}${colors.reset}\n`); - - const calorieFields = [ - 'totalKilocalories', - 'activeKilocalories', - 'bmrKilocalories', - 'wellnessKilocalories', - 'burnedKilocalories', - 'consumedKilocalories', - 'remainingKilocalories', - 'netCalorieGoal', - 'totalCalories', - 'calories', - ]; - - for (const [endpointName, result] of Object.entries(results)) { - if (result) { - const resultStr = JSON.stringify(result); - const foundFields: string[] = []; - - for (const field of calorieFields) { - if (resultStr.includes(field)) { - foundFields.push(field); - } - } - - if (foundFields.length > 0) { - console.log(`${colors.green}✓ ${endpointName}${colors.reset}`); - console.log(` Fields: ${foundFields.join(', ')}`); - - // Try to extract actual values - for (const field of foundFields) { - const value = extractValue(result, field); - if (value !== undefined) { - console.log(` ${field}: ${value}`); - } - } - console.log(''); - } - } - } - - // Save all results to file for analysis - const outputDir = path.join(process.cwd(), 'output'); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - const outputPath = path.join(outputDir, `api-test-results-${formatDate(new Date())}.json`); - fs.writeFileSync(outputPath, JSON.stringify(results, null, 2)); - console.log(`\n${colors.cyan}Full results saved to: ${outputPath}${colors.reset}`); -} - -function extractValue(obj: any, field: string): any { - if (obj === null || obj === undefined) return undefined; - - if (typeof obj === 'object') { - if (field in obj) { - return obj[field]; - } - - for (const key of Object.keys(obj)) { - const value = extractValue(obj[key], field); - if (value !== undefined) { - return value; - } - } - } - - return undefined; -} - -main().catch(console.error);