All checks were successful
Deploy / deploy (push) Successful in 1m38s
The connect.garmin.com/modern/proxy URL returns HTML (website) instead of JSON API responses. Garth library uses connectapi.garmin.com subdomain directly, which is the actual API endpoint. - Change base URL from connect.garmin.com/modern/proxy to connectapi.garmin.com - Update User-Agent to match garth library: GCM-iOS-5.19.1.2 - Factor out headers into getGarminHeaders() to avoid duplication - Remove NK header (not needed when using connectapi subdomain) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
219 lines
5.8 KiB
TypeScript
219 lines
5.8 KiB
TypeScript
// ABOUTME: Garmin Connect API client using stored OAuth tokens.
|
|
// ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin.
|
|
|
|
import { logger } from "@/lib/logger";
|
|
import type { GarminTokens, HrvStatus } from "@/types";
|
|
|
|
// Use connectapi subdomain directly (same as garth library)
|
|
const GARMIN_API_URL = "https://connectapi.garmin.com";
|
|
|
|
// Headers matching garth library's http.py USER_AGENT
|
|
function getGarminHeaders(oauth2Token: string): Record<string, string> {
|
|
return {
|
|
Authorization: `Bearer ${oauth2Token}`,
|
|
"User-Agent": "GCM-iOS-5.19.1.2",
|
|
};
|
|
}
|
|
|
|
export interface BodyBatteryData {
|
|
current: number | null;
|
|
yesterdayLow: number | null;
|
|
}
|
|
|
|
export async function fetchGarminData(
|
|
endpoint: string,
|
|
oauth2Token: string,
|
|
): Promise<unknown> {
|
|
const response = await fetch(`${GARMIN_API_URL}${endpoint}`, {
|
|
headers: getGarminHeaders(oauth2Token),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Garmin API error: ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export function isTokenExpired(tokens: GarminTokens): boolean {
|
|
const expiresAt = new Date(tokens.expires_at);
|
|
return expiresAt <= new Date();
|
|
}
|
|
|
|
/**
|
|
* Calculate days until refresh token expiry.
|
|
* This is what users care about - when they need to re-authenticate.
|
|
* Falls back to access token expiry if refresh token expiry not available.
|
|
*/
|
|
export function daysUntilExpiry(tokens: GarminTokens): number {
|
|
const expiresAt = tokens.refresh_token_expires_at
|
|
? new Date(tokens.refresh_token_expires_at)
|
|
: new Date(tokens.expires_at);
|
|
const now = new Date();
|
|
const diffMs = expiresAt.getTime() - now.getTime();
|
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
export async function fetchHrvStatus(
|
|
date: string,
|
|
oauth2Token: string,
|
|
): Promise<HrvStatus> {
|
|
try {
|
|
const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, {
|
|
headers: getGarminHeaders(oauth2Token),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
logger.warn(
|
|
{ status: response.status, endpoint: "hrv-service" },
|
|
"Garmin HRV API error",
|
|
);
|
|
return "Unknown";
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!text.startsWith("{") && !text.startsWith("[")) {
|
|
logger.error(
|
|
{ endpoint: "hrv-service", responseBody: text.slice(0, 1000) },
|
|
"Garmin returned non-JSON response",
|
|
);
|
|
return "Unknown";
|
|
}
|
|
const data = JSON.parse(text);
|
|
const status = data?.hrvSummary?.status;
|
|
|
|
if (status === "BALANCED") {
|
|
logger.info({ status: "BALANCED" }, "Garmin HRV data received");
|
|
return "Balanced";
|
|
}
|
|
if (status === "UNBALANCED") {
|
|
logger.info({ status: "UNBALANCED" }, "Garmin HRV data received");
|
|
return "Unbalanced";
|
|
}
|
|
logger.info(
|
|
{ rawStatus: status, hasData: !!data?.hrvSummary },
|
|
"Garmin HRV returned unknown status",
|
|
);
|
|
return "Unknown";
|
|
} catch (error) {
|
|
logger.error(
|
|
{ err: error, endpoint: "hrv-service" },
|
|
"Garmin HRV fetch failed",
|
|
);
|
|
return "Unknown";
|
|
}
|
|
}
|
|
|
|
export async function fetchBodyBattery(
|
|
date: string,
|
|
oauth2Token: string,
|
|
): Promise<BodyBatteryData> {
|
|
try {
|
|
const response = await fetch(
|
|
`${GARMIN_API_URL}/usersummary-service/stats/bodyBattery/dates/${date}`,
|
|
{
|
|
headers: getGarminHeaders(oauth2Token),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
logger.warn(
|
|
{ status: response.status, endpoint: "bodyBattery" },
|
|
"Garmin body battery API error",
|
|
);
|
|
return { current: null, yesterdayLow: null };
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!text.startsWith("{") && !text.startsWith("[")) {
|
|
logger.error(
|
|
{ endpoint: "bodyBattery", responseBody: text.slice(0, 1000) },
|
|
"Garmin returned non-JSON response",
|
|
);
|
|
return { current: null, yesterdayLow: null };
|
|
}
|
|
const data = JSON.parse(text);
|
|
|
|
const currentData = data?.bodyBatteryValuesArray?.[0];
|
|
const current = currentData?.charged ?? null;
|
|
|
|
const yesterdayStats = data?.bodyBatteryStatList?.[0];
|
|
const yesterdayLow = yesterdayStats?.min ?? null;
|
|
|
|
logger.info(
|
|
{
|
|
current,
|
|
yesterdayLow,
|
|
hasCurrentData: !!currentData,
|
|
hasYesterdayData: !!yesterdayStats,
|
|
},
|
|
"Garmin body battery data received",
|
|
);
|
|
|
|
return { current, yesterdayLow };
|
|
} catch (error) {
|
|
logger.error(
|
|
{ err: error, endpoint: "bodyBattery" },
|
|
"Garmin body battery fetch failed",
|
|
);
|
|
return { current: null, yesterdayLow: null };
|
|
}
|
|
}
|
|
|
|
export async function fetchIntensityMinutes(
|
|
oauth2Token: string,
|
|
): Promise<number> {
|
|
try {
|
|
const response = await fetch(
|
|
`${GARMIN_API_URL}/fitnessstats-service/activity`,
|
|
{
|
|
headers: getGarminHeaders(oauth2Token),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
logger.warn(
|
|
{ status: response.status, endpoint: "fitnessstats" },
|
|
"Garmin intensity minutes API error",
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!text.startsWith("{") && !text.startsWith("[")) {
|
|
logger.error(
|
|
{ endpoint: "fitnessstats", responseBody: text.slice(0, 1000) },
|
|
"Garmin returned non-JSON response",
|
|
);
|
|
return 0;
|
|
}
|
|
const data = JSON.parse(text);
|
|
const weeklyTotal = data?.weeklyTotal;
|
|
|
|
if (!weeklyTotal) {
|
|
logger.info(
|
|
{ hasWeeklyTotal: false },
|
|
"Garmin intensity minutes: no weekly data",
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
const moderate = weeklyTotal.moderateIntensityMinutes ?? 0;
|
|
const vigorous = weeklyTotal.vigorousIntensityMinutes ?? 0;
|
|
const total = moderate + vigorous;
|
|
|
|
logger.info(
|
|
{ moderate, vigorous, total },
|
|
"Garmin intensity minutes data received",
|
|
);
|
|
|
|
return total;
|
|
} catch (error) {
|
|
logger.error(
|
|
{ err: error, endpoint: "fitnessstats" },
|
|
"Garmin intensity minutes fetch failed",
|
|
);
|
|
return 0;
|
|
}
|
|
}
|