2026-07-25 20:41:44 +02:00
|
|
|
// Package garmin wraps a direct garminconnect subprocess (see
|
|
|
|
|
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
|
|
|
|
|
// geniusrun never deals with the wire protocol directly.
|
2026-07-17 18:33:06 +02:00
|
|
|
package garmin
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-25 20:41:44 +02:00
|
|
|
"bufio"
|
2026-07-17 18:33:06 +02:00
|
|
|
"context"
|
2026-07-25 20:41:44 +02:00
|
|
|
_ "embed"
|
2026-07-17 18:33:06 +02:00
|
|
|
"encoding/json"
|
2026-07-27 18:53:59 +02:00
|
|
|
"errors"
|
2026-07-17 18:33:06 +02:00
|
|
|
"fmt"
|
2026-07-25 20:41:44 +02:00
|
|
|
"io"
|
2026-07-26 14:33:20 +02:00
|
|
|
"log/slog"
|
2026-07-25 20:41:44 +02:00
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
2026-07-25 21:02:49 +02:00
|
|
|
"strconv"
|
2026-07-17 18:33:06 +02:00
|
|
|
"sync"
|
2026-07-26 14:33:20 +02:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"geniusrun/backend/internal/applog"
|
2026-07-17 18:33:06 +02:00
|
|
|
)
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
//go:embed pyscript/wrapper.py
|
|
|
|
|
var wrapperScript string
|
|
|
|
|
|
|
|
|
|
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
|
|
|
|
|
// subprocess -- well above the default 64KB bufio.Scanner limit, since
|
|
|
|
|
// get_activity_details responses (per-second telemetry) can be several MB.
|
|
|
|
|
const maxWrapperLineBytes = 16 * 1024 * 1024
|
|
|
|
|
|
2026-07-24 21:08:07 +02:00
|
|
|
// Client is the interface the rest of geniusrun depends on. The real
|
2026-07-25 20:41:44 +02:00
|
|
|
// implementation drives an embedded Python wrapper subprocess over stdio;
|
|
|
|
|
// internal/garmin/mock provides a fake for tests and frontend-only
|
|
|
|
|
// development.
|
2026-07-17 18:33:06 +02:00
|
|
|
type Client interface {
|
|
|
|
|
// Authenticate triggers Garmin login using credentials the subprocess
|
|
|
|
|
// was started with. Spawns the subprocess on first call.
|
|
|
|
|
Authenticate(ctx context.Context) (AuthResult, error)
|
|
|
|
|
// CompleteMFA submits an MFA code for a login started by Authenticate.
|
|
|
|
|
CompleteMFA(ctx context.Context, code string) (AuthResult, error)
|
2026-07-17 18:57:54 +02:00
|
|
|
// UpdateCredentials replaces the Garmin email/password used to spawn
|
|
|
|
|
// the subprocess, and terminates any already-running subprocess (which
|
|
|
|
|
// would otherwise still be authenticated under the old credentials).
|
|
|
|
|
// The next call that needs the subprocess spawns a fresh one with the
|
|
|
|
|
// new credentials.
|
|
|
|
|
UpdateCredentials(email, password string)
|
2026-07-17 18:33:06 +02:00
|
|
|
// GetActivities lists activities between start and end (YYYY-MM-DD).
|
|
|
|
|
GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error)
|
|
|
|
|
// GetActivitySplits fetches lap/split summaries for one activity.
|
|
|
|
|
GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error)
|
|
|
|
|
// GetActivityDetails fetches raw per-second telemetry for one activity.
|
|
|
|
|
GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error)
|
2026-07-19 11:55:13 +02:00
|
|
|
// GetWorkoutByID fetches a structured workout's step-by-step plan.
|
|
|
|
|
GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error)
|
2026-07-17 18:33:06 +02:00
|
|
|
// Close terminates the subprocess, if running.
|
|
|
|
|
Close() error
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// Config configures how the Garmin wrapper subprocess is spawned.
|
2026-07-17 18:33:06 +02:00
|
|
|
type Config struct {
|
2026-07-25 20:41:44 +02:00
|
|
|
// PythonPath is the python3 interpreter to run the embedded wrapper
|
|
|
|
|
// script with. Empty defaults to "python3" resolved via PATH.
|
|
|
|
|
PythonPath string
|
2026-07-17 18:33:06 +02:00
|
|
|
GarminEmail string
|
|
|
|
|
GarminPassword string
|
2026-07-26 21:25:39 +02:00
|
|
|
// TokenStorePath is passed as GARMIN_TOKENSTORE so the wrapper
|
|
|
|
|
// persists/resumes a Garmin session there.
|
2026-07-17 18:33:06 +02:00
|
|
|
TokenStorePath string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// wireRequest is one line sent to the wrapper subprocess's stdin.
|
|
|
|
|
type wireRequest struct {
|
|
|
|
|
ID int `json:"id"`
|
|
|
|
|
Cmd string `json:"cmd"`
|
|
|
|
|
Params any `json:"params,omitempty"`
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// wireResponse is one line read from the wrapper subprocess's stdout.
|
|
|
|
|
type wireResponse struct {
|
2026-07-27 18:53:59 +02:00
|
|
|
ID int `json:"id"`
|
|
|
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
|
|
|
|
NotFound bool `json:"not_found,omitempty"`
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-27 18:53:59 +02:00
|
|
|
// ErrNotFound wraps any error a Client method returns when the wrapper
|
|
|
|
|
// reported a definitive HTTP 404 (garminconnect's own
|
|
|
|
|
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
|
|
|
|
|
// on Garmin's side after being linked to an activity. Callers use
|
|
|
|
|
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
|
|
|
|
|
// worth retrying.
|
|
|
|
|
var ErrNotFound = errors.New("garmin: resource not found")
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// callParams is the Params payload for a generic "call" request: dispatches
|
|
|
|
|
// to any garminconnect.Garmin method by name.
|
|
|
|
|
type callParams struct {
|
|
|
|
|
Method string `json:"method"`
|
|
|
|
|
Args map[string]any `json:"args,omitempty"`
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// authResultWire is the Result payload for authenticate/complete_mfa.
|
|
|
|
|
type authResultWire struct {
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
func mapAuthStatus(s string) AuthStatus {
|
|
|
|
|
switch s {
|
|
|
|
|
case "success":
|
|
|
|
|
return AuthSuccess
|
|
|
|
|
case "mfa_required":
|
|
|
|
|
return AuthMFARequired
|
|
|
|
|
case "failed":
|
|
|
|
|
return AuthFailed
|
|
|
|
|
default:
|
|
|
|
|
return AuthUnknown
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// subprocessClient is the real Client implementation, backed by a wrapper
|
|
|
|
|
// subprocess spoken to over newline-delimited JSON on stdio.
|
|
|
|
|
type subprocessClient struct {
|
|
|
|
|
cfg Config
|
2026-07-17 18:57:54 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
|
|
|
|
|
cmd *exec.Cmd
|
|
|
|
|
stdin io.WriteCloser
|
|
|
|
|
enc *json.Encoder
|
|
|
|
|
scanner *bufio.Scanner
|
|
|
|
|
scriptPath string // temp file holding the embedded wrapper.py, written once
|
|
|
|
|
started bool
|
|
|
|
|
nextID int
|
2026-07-25 20:50:39 +02:00
|
|
|
stderrDone chan struct{} // closed once the stderr-copy goroutine has finished reading
|
2026-07-25 20:41:44 +02:00
|
|
|
}
|
2026-07-17 18:57:54 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// ensureStarted spawns the wrapper subprocess if it isn't already running.
|
|
|
|
|
// Callers must hold c.mu.
|
2026-07-26 14:33:20 +02:00
|
|
|
func (c *subprocessClient) ensureStarted(ctx context.Context) error {
|
2026-07-17 18:57:54 +02:00
|
|
|
if c.started {
|
2026-07-25 20:41:44 +02:00
|
|
|
return nil
|
2026-07-17 18:57:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
if c.scriptPath == "" {
|
|
|
|
|
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
2026-07-25 20:41:44 +02:00
|
|
|
return fmt.Errorf("write embedded wrapper script: %w", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
if _, err := f.WriteString(wrapperScript); err != nil {
|
|
|
|
|
f.Close()
|
|
|
|
|
return fmt.Errorf("write embedded wrapper script: %w", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
if err := f.Close(); err != nil {
|
|
|
|
|
return fmt.Errorf("write embedded wrapper script: %w", err)
|
|
|
|
|
}
|
|
|
|
|
c.scriptPath = f.Name()
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
pythonPath := c.cfg.PythonPath
|
|
|
|
|
if pythonPath == "" {
|
|
|
|
|
pythonPath = "python3"
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
cmd := exec.Command(pythonPath, c.scriptPath)
|
|
|
|
|
extraEnv := []string{
|
|
|
|
|
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
|
|
|
|
|
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
|
2026-07-26 21:25:39 +02:00
|
|
|
"GARMIN_TOKENSTORE=" + c.cfg.TokenStorePath,
|
2026-07-25 20:41:44 +02:00
|
|
|
"PYTHONUNBUFFERED=1",
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
cmd.Env = append(os.Environ(), extraEnv...)
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
stdin, err := cmd.StdinPipe()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("open wrapper stdin: %w", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("open wrapper stdout: %w", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
stderr, err := cmd.StderrPipe()
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
2026-07-25 20:41:44 +02:00
|
|
|
return fmt.Errorf("open wrapper stderr: %w", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
if err := cmd.Start(); err != nil {
|
|
|
|
|
return fmt.Errorf("spawn garmin wrapper subprocess: %w", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:50:39 +02:00
|
|
|
// wrapper.py logs auth/rate-limit diagnostics to stderr. Per os/exec's
|
|
|
|
|
// StderrPipe docs, it's incorrect to call Wait before all reads from the
|
|
|
|
|
// pipe have completed, so close() waits on stderrDone before Wait-ing.
|
|
|
|
|
c.stderrDone = make(chan struct{})
|
|
|
|
|
stderrDone := c.stderrDone
|
|
|
|
|
go func() {
|
|
|
|
|
io.Copy(os.Stderr, stderr)
|
|
|
|
|
close(stderrDone)
|
|
|
|
|
}()
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
c.cmd = cmd
|
|
|
|
|
c.stdin = stdin
|
|
|
|
|
c.enc = json.NewEncoder(stdin)
|
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
|
|
|
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
|
|
|
|
c.scanner = scanner
|
|
|
|
|
c.started = true
|
|
|
|
|
c.nextID = 0
|
2026-07-26 14:33:20 +02:00
|
|
|
|
|
|
|
|
applog.FromContext(ctx).Info("garmin wrapper spawning",
|
|
|
|
|
"python_path", pythonPath,
|
|
|
|
|
)
|
2026-07-25 20:41:44 +02:00
|
|
|
return nil
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// roundTrip sends one request and returns its result payload, or an error
|
|
|
|
|
// if the wrapper reported one. Callers must hold c.mu and have already
|
2026-07-26 14:33:20 +02:00
|
|
|
// called ensureStarted. Logs exactly one "garmin wrapper call" line
|
|
|
|
|
// regardless of outcome (see internal/applog) -- cmd/params are always
|
|
|
|
|
// safe to log in full here: Garmin credentials only ever reach the
|
|
|
|
|
// subprocess via env vars at spawn time (see ensureStarted), never through
|
|
|
|
|
// these wire params.
|
|
|
|
|
func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error) {
|
|
|
|
|
start := time.Now()
|
|
|
|
|
defer func() {
|
|
|
|
|
attrs := []slog.Attr{
|
|
|
|
|
slog.String("cmd", cmdName),
|
|
|
|
|
slog.Any("params", params),
|
|
|
|
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
|
|
|
|
}
|
|
|
|
|
level := slog.LevelInfo
|
|
|
|
|
if result != nil {
|
|
|
|
|
attrs = append(attrs, slog.String("result_preview", truncate(string(result), 500)))
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
level = slog.LevelWarn
|
|
|
|
|
attrs = append(attrs, slog.String("error", err.Error()))
|
|
|
|
|
}
|
|
|
|
|
applog.FromContext(ctx).LogAttrs(context.Background(), level, "garmin wrapper call", attrs...)
|
|
|
|
|
}()
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
c.nextID++
|
|
|
|
|
id := c.nextID
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-26 14:33:20 +02:00
|
|
|
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
|
|
|
|
|
err = fmt.Errorf("write %s request: %w", cmdName, err)
|
|
|
|
|
return nil, err
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
if !c.scanner.Scan() {
|
2026-07-26 14:33:20 +02:00
|
|
|
if serr := c.scanner.Err(); serr != nil {
|
|
|
|
|
err = fmt.Errorf("read %s response: %w", cmdName, serr)
|
|
|
|
|
} else {
|
|
|
|
|
err = fmt.Errorf("read %s response: subprocess closed its output", cmdName)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
return nil, err
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
var resp wireResponse
|
2026-07-26 14:33:20 +02:00
|
|
|
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
|
|
|
|
|
err = fmt.Errorf("parse %s response: %w", cmdName, uerr)
|
|
|
|
|
return nil, err
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
if resp.ID != id {
|
2026-07-26 14:33:20 +02:00
|
|
|
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
|
|
|
|
|
return nil, err
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
if resp.Error != "" {
|
2026-07-27 18:53:59 +02:00
|
|
|
if resp.NotFound {
|
|
|
|
|
err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)
|
|
|
|
|
} else {
|
|
|
|
|
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
return nil, err
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
result = resp.Result
|
|
|
|
|
return result, nil
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:54:54 +02:00
|
|
|
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-07-26 14:33:20 +02:00
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
2026-07-25 20:54:54 +02:00
|
|
|
return AuthResult{}, err
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
raw, err := c.roundTrip(ctx, "authenticate", nil)
|
2026-07-25 20:54:54 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return AuthResult{}, err
|
|
|
|
|
}
|
|
|
|
|
var wire authResultWire
|
|
|
|
|
if err := json.Unmarshal(raw, &wire); err != nil {
|
|
|
|
|
return AuthResult{}, fmt.Errorf("parse authenticate result: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-07-26 14:33:20 +02:00
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
2026-07-25 20:54:54 +02:00
|
|
|
return AuthResult{}, err
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
raw, err := c.roundTrip(ctx, "complete_mfa", map[string]any{"code": code})
|
2026-07-25 20:54:54 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return AuthResult{}, err
|
|
|
|
|
}
|
|
|
|
|
var wire authResultWire
|
|
|
|
|
if err := json.Unmarshal(raw, &wire); err != nil {
|
|
|
|
|
return AuthResult{}, fmt.Errorf("parse complete_mfa result: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// UpdateCredentials implements Client.
|
|
|
|
|
func (c *subprocessClient) UpdateCredentials(email, password string) {
|
2026-07-19 11:55:13 +02:00
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
c.cfg.GarminEmail = email
|
|
|
|
|
c.cfg.GarminPassword = password
|
|
|
|
|
c.close()
|
2026-07-19 11:55:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// Close implements Client.
|
|
|
|
|
func (c *subprocessClient) Close() error {
|
2026-07-17 18:33:06 +02:00
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
2026-07-25 20:41:44 +02:00
|
|
|
return c.close()
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
// close terminates the subprocess, if running. Callers must hold c.mu.
|
|
|
|
|
func (c *subprocessClient) close() error {
|
2026-07-17 18:33:06 +02:00
|
|
|
if !c.started {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
c.started = false
|
|
|
|
|
|
|
|
|
|
if c.stdin != nil {
|
|
|
|
|
c.stdin.Close()
|
|
|
|
|
}
|
|
|
|
|
if c.cmd != nil && c.cmd.Process != nil {
|
|
|
|
|
c.cmd.Process.Kill()
|
|
|
|
|
}
|
2026-07-25 20:50:39 +02:00
|
|
|
if c.stderrDone != nil {
|
|
|
|
|
// Killing the process closes its end of the stderr pipe, which
|
|
|
|
|
// unblocks the copy goroutine's Read with EOF; wait for it to finish
|
|
|
|
|
// before Wait, per os/exec's StderrPipe doc.
|
|
|
|
|
<-c.stderrDone
|
|
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
var err error
|
|
|
|
|
if c.cmd != nil {
|
|
|
|
|
err = c.cmd.Wait()
|
|
|
|
|
}
|
2026-07-25 20:50:39 +02:00
|
|
|
c.cmd, c.stdin, c.enc, c.scanner, c.stderrDone = nil, nil, nil, nil, nil
|
2026-07-25 20:41:44 +02:00
|
|
|
return err
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func truncate(s string, n int) string {
|
|
|
|
|
if len(s) <= n {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return s[:n] + "...(truncated)"
|
|
|
|
|
}
|
2026-07-25 21:02:49 +02:00
|
|
|
|
|
|
|
|
var _ Client = (*subprocessClient)(nil)
|
|
|
|
|
|
|
|
|
|
// NewClient builds a Client. The subprocess is not spawned until the first
|
|
|
|
|
// call that needs it (Authenticate, or any data call once authenticated).
|
|
|
|
|
func NewClient(cfg Config) Client {
|
|
|
|
|
return &subprocessClient{cfg: cfg}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-07-26 14:33:20 +02:00
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
2026-07-25 21:02:49 +02:00
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
2026-07-25 21:02:49 +02:00
|
|
|
Method: "get_activities_by_date",
|
|
|
|
|
Args: map[string]any{"startdate": startDate, "enddate": endDate},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var rawActivities []json.RawMessage
|
|
|
|
|
if err := json.Unmarshal(raw, &rawActivities); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("get_activities_by_date did not return a JSON array (%q): %w", truncate(string(raw), 200), err)
|
|
|
|
|
}
|
|
|
|
|
if limit > 0 && limit < len(rawActivities) {
|
|
|
|
|
rawActivities = rawActivities[:limit]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activities := make([]Activity, 0, len(rawActivities))
|
|
|
|
|
for _, raw := range rawActivities {
|
|
|
|
|
var a Activity
|
|
|
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse activity: %w", err)
|
|
|
|
|
}
|
|
|
|
|
a.Raw = raw
|
|
|
|
|
activities = append(activities, a)
|
|
|
|
|
}
|
|
|
|
|
return activities, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-07-26 14:33:20 +02:00
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
2026-07-25 21:02:49 +02:00
|
|
|
return ActivitySplits{}, err
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
2026-07-25 21:02:49 +02:00
|
|
|
Method: "get_activity_splits",
|
|
|
|
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ActivitySplits{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var envelope struct {
|
|
|
|
|
ActivityID int64 `json:"activityId"`
|
|
|
|
|
Laps []json.RawMessage `json:"lapDTOs"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
|
|
|
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))}
|
|
|
|
|
for _, raw := range envelope.Laps {
|
|
|
|
|
var l Lap
|
|
|
|
|
if err := json.Unmarshal(raw, &l); err != nil {
|
|
|
|
|
return ActivitySplits{}, fmt.Errorf("parse lap: %w", err)
|
|
|
|
|
}
|
|
|
|
|
l.Raw = raw
|
|
|
|
|
splits.Laps = append(splits.Laps, l)
|
|
|
|
|
}
|
|
|
|
|
return splits, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-07-26 14:33:20 +02:00
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
2026-07-25 21:02:49 +02:00
|
|
|
return ActivityDetails{}, err
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
2026-07-25 21:02:49 +02:00
|
|
|
Method: "get_activity_details",
|
|
|
|
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ActivityDetails{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var details ActivityDetails
|
|
|
|
|
if err := json.Unmarshal(raw, &details); err != nil {
|
|
|
|
|
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
|
|
|
|
|
}
|
|
|
|
|
details.Raw = json.RawMessage(raw)
|
|
|
|
|
return details, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-07-26 14:33:20 +02:00
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
2026-07-25 21:02:49 +02:00
|
|
|
return Workout{}, err
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
2026-07-25 21:02:49 +02:00
|
|
|
Method: "get_workout_by_id",
|
|
|
|
|
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Workout{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var workout Workout
|
|
|
|
|
if err := json.Unmarshal(raw, &workout); err != nil {
|
|
|
|
|
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
|
|
|
|
|
}
|
|
|
|
|
workout.Raw = json.RawMessage(raw)
|
|
|
|
|
return workout, nil
|
|
|
|
|
}
|