All checks were successful
Deploy / deploy (push) Successful in 2m27s
Body Battery: - Change endpoint from /usersummary-service/stats/bodyBattery/dates/ to /wellness-service/wellness/bodyBattery/reports/daily - Parse new response format: array with bodyBatteryValuesArray time series - Current value = last entry's level (index 2) - YesterdayLow = min level from yesterday's data Intensity Minutes: - Change endpoint from /fitnessstats-service/activity to /usersummary-service/stats/im/weekly - Add date parameter to function signature - Parse new response format: array with moderateValue/vigorousValue Endpoints verified against python-garminconnect source code. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
248 lines
7.0 KiB
TypeScript
248 lines
7.0 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 {
|
|
// Calculate yesterday's date for the API request
|
|
const dateObj = new Date(date);
|
|
dateObj.setDate(dateObj.getDate() - 1);
|
|
const yesterday = dateObj.toISOString().split("T")[0];
|
|
|
|
const response = await fetch(
|
|
`${GARMIN_API_URL}/wellness-service/wellness/bodyBattery/reports/daily?startDate=${yesterday}&endDate=${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) as Array<{
|
|
date: string;
|
|
bodyBatteryValuesArray?: Array<[number, string, number, number]>;
|
|
}>;
|
|
|
|
// Find today's and yesterday's data from the response array
|
|
const todayData = data?.find((d) => d.date === date);
|
|
const yesterdayData = data?.find((d) => d.date === yesterday);
|
|
|
|
// Current = last value in today's bodyBatteryValuesArray (index 2 is the level)
|
|
const todayValues = todayData?.bodyBatteryValuesArray ?? [];
|
|
const current =
|
|
todayValues.length > 0 ? todayValues[todayValues.length - 1][2] : null;
|
|
|
|
// Yesterday low = minimum level in yesterday's bodyBatteryValuesArray
|
|
const yesterdayValues = yesterdayData?.bodyBatteryValuesArray ?? [];
|
|
const yesterdayLow =
|
|
yesterdayValues.length > 0
|
|
? Math.min(...yesterdayValues.map((v) => v[2]))
|
|
: null;
|
|
|
|
logger.info(
|
|
{
|
|
current,
|
|
yesterdayLow,
|
|
hasCurrentData: todayValues.length > 0,
|
|
hasYesterdayData: yesterdayValues.length > 0,
|
|
},
|
|
"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(
|
|
date: string,
|
|
oauth2Token: string,
|
|
): Promise<number> {
|
|
try {
|
|
// Calculate 7 days before the date for weekly range
|
|
const endDate = date;
|
|
const startDateObj = new Date(date);
|
|
startDateObj.setDate(startDateObj.getDate() - 7);
|
|
const startDate = startDateObj.toISOString().split("T")[0];
|
|
|
|
const response = await fetch(
|
|
`${GARMIN_API_URL}/usersummary-service/stats/im/weekly?start=${startDate}&end=${endDate}`,
|
|
{
|
|
headers: getGarminHeaders(oauth2Token),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
logger.warn(
|
|
{ status: response.status, endpoint: "intensityMinutes" },
|
|
"Garmin intensity minutes API error",
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!text.startsWith("{") && !text.startsWith("[")) {
|
|
logger.error(
|
|
{ endpoint: "intensityMinutes", responseBody: text.slice(0, 1000) },
|
|
"Garmin returned non-JSON response",
|
|
);
|
|
return 0;
|
|
}
|
|
const data = JSON.parse(text) as Array<{
|
|
calendarDate: string;
|
|
moderateValue?: number;
|
|
vigorousValue?: number;
|
|
}>;
|
|
|
|
const entry = data?.[0];
|
|
if (!entry) {
|
|
logger.info(
|
|
{ hasData: false },
|
|
"Garmin intensity minutes: no weekly data",
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
const moderate = entry.moderateValue ?? 0;
|
|
const vigorous = entry.vigorousValue ?? 0;
|
|
const total = moderate + vigorous;
|
|
|
|
logger.info(
|
|
{ moderate, vigorous, total },
|
|
"Garmin intensity minutes data received",
|
|
);
|
|
|
|
return total;
|
|
} catch (error) {
|
|
logger.error(
|
|
{ err: error, endpoint: "intensityMinutes" },
|
|
"Garmin intensity minutes fetch failed",
|
|
);
|
|
return 0;
|
|
}
|
|
}
|