Files
knur-app/lib/garmin/client.ts
Dominik Klarkowski 36407f534b init
2026-06-16 09:43:48 +02:00

165 lines
6.3 KiB
TypeScript

import { GarminConnect } from "garmin-connect";
import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
import type { RoutePoint, RunningActivityInput } from "@/lib/models/running";
import { getSavedOauth1Token } from "@/lib/models/garmin-auth";
import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso";
const FETCH_LIMIT = 50;
export class GarminLoginRequiredError extends Error {
constructor() {
super("Wymagane logowanie do Garmin Connect.");
}
}
function parseGarminDate(value: string): Date {
return new Date(`${value.replace(" ", "T")}Z`);
}
function isRunningActivity(activity: IActivity): boolean {
return activity.activityType?.typeKey?.toLowerCase().includes("running") ?? false;
}
function toNumber(value: unknown): number | undefined {
return typeof value === "number" && value > 0 ? value : undefined;
}
function toText(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function mapActivity(activity: IActivity): RunningActivityInput {
return {
garminActivityId: activity.activityId,
name: activity.activityName,
startTime: parseGarminDate(activity.startTimeGMT),
durationSec: activity.duration,
distanceM: activity.distance,
avgPaceSecPerKm: activity.averageSpeed > 0 ? 1000 / activity.averageSpeed : 0,
avgHr: activity.averageHR || undefined,
maxHr: activity.maxHR || undefined,
calories: activity.calories || undefined,
elevationGainM: activity.elevationGain || undefined,
avgCadence: activity.averageRunningCadenceInStepsPerMinute || undefined,
avgVerticalOscillationCm: toNumber(activity.avgVerticalOscillation),
avgGroundContactTimeMs: toNumber(activity.avgGroundContactTime),
avgStrideLengthCm: activity.avgStrideLength || undefined,
avgGroundContactBalanceLeftPct: toNumber(activity.avgGroundContactBalance),
avgVerticalRatioPct: toNumber(activity.avgVerticalRatio),
vo2Max: activity.vO2MaxValue || undefined,
aerobicTrainingEffect: toNumber(activity.aerobicTrainingEffect),
anaerobicTrainingEffect: toNumber(activity.anaerobicTrainingEffect),
trainingEffectLabel: toText(activity.trainingEffectLabel),
avgPowerW: toNumber(activity.avgPower),
maxPowerW: toNumber(activity.maxPower),
normPowerW: toNumber(activity.normPower),
avgRespirationRate: toNumber(activity.avgRespirationRate),
hasRoute: activity.hasPolyline || undefined,
};
}
const GC_API = "https://connectapi.garmin.com";
const MAX_POLYLINE_POINTS = 500;
type GarminPolylinePoint = { lat: number; lon: number; altitude?: number };
type GarminActivityDetailsResponse = {
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
};
export async function fetchActivityRoutePoints(
client: GarminConnect,
garminActivityId: number
): Promise<RoutePoint[] | null> {
const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`;
const data = await client.get<GarminActivityDetailsResponse>(url);
const polyline = data?.geoPolylineDTO?.polyline;
if (!Array.isArray(polyline) || polyline.length === 0) return null;
return polyline.map((p) => [p.lat, p.lon] as RoutePoint);
}
function getCredentials(): { username: string; password: string } {
const username = process.env.GARMIN_EMAIL;
const password = process.env.GARMIN_PASSWORD;
if (!username || !password) {
throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD).");
}
return { username, password };
}
async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise<void> {
const http = client.client;
if (!http.OAUTH_CONSUMER) {
await http.fetchOauthConsumer();
}
const consumer = http.OAUTH_CONSUMER;
if (!consumer) {
throw new Error("Nie udało się pobrać konfiguracji OAuth Garmin.");
}
const oauth = http.getOauthClient(consumer);
http.oauth1Token = oauth1Token;
await http.exchange({ oauth, token: oauth1Token });
}
/**
* Returns a client authenticated using a previously saved OAuth1 token
* (long-lived, survives across syncs) - no MFA needed if it's still valid.
*/
export async function getAuthorizedClient(): Promise<GarminConnect> {
const saved = await getSavedOauth1Token();
if (!saved) {
throw new GarminLoginRequiredError();
}
const client = new GarminConnect({ username: "", password: "" });
try {
await exchangeOauth1Token(client, saved);
} catch {
throw new GarminLoginRequiredError();
}
return client;
}
async function establishClientFromTicket(ticket: string): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
const client = new GarminConnect({ username: "", password: "" });
await client.client.fetchOauthConsumer();
const oauth1 = await client.client.getOauth1Token(ticket);
await client.client.exchange(oauth1);
return { client, oauth1Token: oauth1.token };
}
/**
* Starts a fresh SSO login using env credentials. If the account requires
* MFA, returns the pending state needed to complete it via
* `completeGarminMfaLogin` once the user supplies the emailed code.
*/
export async function beginGarminLogin(): Promise<
{ client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa }
> {
const { username, password } = getCredentials();
const result = await loginAndGetTicket(username, password);
if ("mfaRequired" in result) return result;
return establishClientFromTicket(result.ticket);
}
export async function completeGarminMfaLogin(
pendingState: GarminPendingMfa,
code: string
): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> {
const ticket = await completeMfaAndGetTicket(pendingState, code);
return establishClientFromTicket(ticket);
}
/**
* Returns all recent running activities (mapped), regardless of `since` -
* callers should upsert all of them so previously-synced activities get
* backfilled with newly added metric fields, but can use `since` to decide
* which ones are "new" for reporting purposes.
*/
export async function fetchRunningActivities(client: GarminConnect): Promise<RunningActivityInput[]> {
const activities = await client.getActivities(0, FETCH_LIMIT);
return activities.filter(isRunningActivity).map(mapActivity);
}