2026-07-25 20:41:44 +02:00
|
|
|
// Package garmin wraps a direct garminconnect subprocess (see
|
2026-08-04 16:04:18 +02:00
|
|
|
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
|
2026-07-25 20:41:44 +02:00
|
|
|
// 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"
|
2026-07-17 18:33:06 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
//go:embed wrapper/wrapper.py
|
2026-07-25 20:41:44 +02:00
|
|
|
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-08-04 16:04:18 +02:00
|
|
|
// ClientConfig configures how the Garmin wrapper subprocess is spawned.
|
|
|
|
|
type ClientConfig 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.
|
2026-08-04 16:42:25 +02:00
|
|
|
// ErrorType/Traceback carry the Python-side failure detail in the protocol
|
|
|
|
|
// itself (see wrapper.py's dispatch), so the Go side logs one complete
|
|
|
|
|
// structured record per failed call instead of correlating stderr noise.
|
2026-07-25 20:41:44 +02:00
|
|
|
type wireResponse struct {
|
2026-08-04 16:42:25 +02:00
|
|
|
ID int `json:"id"`
|
|
|
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
|
|
|
|
ErrorType string `json:"error_type,omitempty"`
|
|
|
|
|
Traceback string `json:"traceback,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 {
|
2026-08-04 16:04:18 +02:00
|
|
|
cfg ClientConfig
|
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 {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
return fmt.Errorf("write 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()
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
return fmt.Errorf("write 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 {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
return fmt.Errorf("write wrapper script: %w", err)
|
2026-07-25 20:41:44 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
return fmt.Errorf("spawn wrapper subprocess: %w", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-08-04 16:42:25 +02:00
|
|
|
// wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines
|
|
|
|
|
// (see its _log helper); forwardWrapperStderr re-emits them through the
|
|
|
|
|
// application logger so the backend's output stays one JSON stream. 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.
|
2026-07-25 20:50:39 +02:00
|
|
|
c.stderrDone = make(chan struct{})
|
|
|
|
|
stderrDone := c.stderrDone
|
|
|
|
|
go func() {
|
2026-08-04 16:42:25 +02:00
|
|
|
forwardWrapperStderr(stderr)
|
2026-07-25 20:50:39 +02:00
|
|
|
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
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
return nil
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
// execute sends one request and returns its result payload, or an error
|
2026-07-25 20:41:44 +02:00
|
|
|
// if the wrapper reported one. Callers must hold c.mu and have already
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
// called ensureStarted. Logs exactly one type=wrapper line regardless of
|
|
|
|
|
// outcome (the record's meaning is carried by its fields, no msg) --
|
|
|
|
|
// 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) execute(ctx context.Context, cmd string, params any) (result json.RawMessage, err error) {
|
2026-07-26 14:33:20 +02:00
|
|
|
start := time.Now()
|
2026-08-04 16:42:25 +02:00
|
|
|
var errorType, traceback string // Python-side failure detail, set from the error response
|
2026-07-26 14:33:20 +02:00
|
|
|
defer func() {
|
|
|
|
|
attrs := []slog.Attr{
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
slog.String("type", "wrapper"),
|
|
|
|
|
slog.String("class", "garmin.subprocessClient"),
|
|
|
|
|
slog.String("method", "execute"),
|
|
|
|
|
slog.String("cmd", cmd),
|
2026-07-26 14:33:20 +02:00
|
|
|
slog.Any("params", params),
|
|
|
|
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
|
|
|
|
}
|
|
|
|
|
level := slog.LevelInfo
|
|
|
|
|
if result != nil {
|
2026-08-04 16:33:26 +02:00
|
|
|
attrs = append(attrs, slog.String("result", truncate(string(result), 500)))
|
2026-07-26 14:33:20 +02:00
|
|
|
}
|
|
|
|
|
if err != nil {
|
2026-08-04 16:33:26 +02:00
|
|
|
level = slog.LevelError
|
2026-07-26 14:33:20 +02:00
|
|
|
attrs = append(attrs, slog.String("error", err.Error()))
|
2026-08-04 16:42:25 +02:00
|
|
|
if errorType != "" {
|
|
|
|
|
attrs = append(attrs, slog.String("error_type", errorType))
|
|
|
|
|
}
|
|
|
|
|
if traceback != "" {
|
|
|
|
|
attrs = append(attrs, slog.String("traceback", traceback))
|
|
|
|
|
}
|
2026-07-26 14:33:20 +02:00
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
slog.Default().LogAttrs(ctx, level, "", attrs...)
|
2026-07-26 14:33:20 +02:00
|
|
|
}()
|
|
|
|
|
|
2026-07-25 20:41:44 +02:00
|
|
|
c.nextID++
|
|
|
|
|
id := c.nextID
|
2026-07-17 18:33:06 +02:00
|
|
|
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmd, Params: params}); err != nil {
|
|
|
|
|
err = fmt.Errorf("write %s request: %w", cmd, err)
|
2026-07-26 14:33:20 +02:00
|
|
|
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 {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
err = fmt.Errorf("read %s response: %w", cmd, serr)
|
2026-07-26 14:33:20 +02:00
|
|
|
} else {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
err = fmt.Errorf("read %s response: subprocess closed its output", cmd)
|
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 {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
err = fmt.Errorf("parse %s response: %w", cmd, uerr)
|
2026-07-26 14:33:20 +02:00
|
|
|
return nil, err
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
if resp.ID != id {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmd, resp.ID, id)
|
2026-07-26 14:33:20 +02:00
|
|
|
return nil, err
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-25 20:41:44 +02:00
|
|
|
if resp.Error != "" {
|
2026-08-04 16:42:25 +02:00
|
|
|
errorType, traceback = resp.ErrorType, resp.Traceback
|
2026-07-27 18:53:59 +02:00
|
|
|
if resp.NotFound {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
err = fmt.Errorf("%s: %s: %w", cmd, resp.Error, ErrNotFound)
|
2026-07-27 18:53:59 +02:00
|
|
|
} else {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
err = fmt.Errorf("%s: %s", cmd, resp.Error)
|
2026-07-27 18:53:59 +02:00
|
|
|
}
|
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
|
|
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
raw, err := c.execute(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
|
|
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
raw, err := c.execute(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
|
|
|
}
|
|
|
|
|
|
2026-08-04 16:42:25 +02:00
|
|
|
// forwardWrapperStderr re-emits the wrapper subprocess's stderr through
|
|
|
|
|
// the application logger. wrapper.py writes JSON lines ({"level","msg",
|
|
|
|
|
// ...attrs} -- see its _log helper), which are decoded and re-logged at
|
|
|
|
|
// the corresponding level with their attrs preserved. Anything that isn't
|
|
|
|
|
// such a line (a Python startup crash before _log exists, a chatty
|
|
|
|
|
// third-party library printing directly) is wrapped as a warn-level
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
// type=wrapper record carrying the raw "line" rather than passed through, so the
|
2026-08-04 16:42:25 +02:00
|
|
|
// backend's combined output is JSON no matter what the subprocess does.
|
|
|
|
|
func forwardWrapperStderr(r io.Reader) {
|
|
|
|
|
scanner := bufio.NewScanner(r)
|
|
|
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tracebacks can exceed the default token size
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
|
line := scanner.Text()
|
|
|
|
|
var entry map[string]any
|
|
|
|
|
if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
slog.Warn("", "type", "wrapper", "class", "garmin", "method", "forwardWrapperStderr", "line", line)
|
2026-08-04 16:42:25 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
msg, _ := entry["msg"].(string)
|
|
|
|
|
levelName, _ := entry["level"].(string)
|
|
|
|
|
delete(entry, "msg")
|
|
|
|
|
delete(entry, "level")
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
// class is the Python module; method arrives in the entry itself
|
|
|
|
|
// (wrapper.py's _log stamps the emitting function).
|
|
|
|
|
attrs := make([]slog.Attr, 0, len(entry)+2)
|
|
|
|
|
attrs = append(attrs, slog.String("type", "wrapper"), slog.String("class", "wrapper"))
|
2026-08-04 16:42:25 +02:00
|
|
|
for k, v := range entry {
|
|
|
|
|
attrs = append(attrs, slog.Any(k, v))
|
|
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
slog.Default().LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...)
|
2026-08-04 16:42:25 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// wrapperLogLevel maps wrapper.py's level strings onto slog levels,
|
|
|
|
|
// defaulting to info for anything unrecognized.
|
|
|
|
|
func wrapperLogLevel(level string) slog.Level {
|
|
|
|
|
switch level {
|
|
|
|
|
case "debug":
|
|
|
|
|
return slog.LevelDebug
|
|
|
|
|
case "warn", "warning":
|
|
|
|
|
return slog.LevelWarn
|
|
|
|
|
case "error":
|
|
|
|
|
return slog.LevelError
|
|
|
|
|
default:
|
|
|
|
|
return slog.LevelInfo
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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).
|
2026-08-04 16:04:18 +02:00
|
|
|
func NewClient(cfg ClientConfig) Client {
|
2026-07-25 21:02:49 +02:00
|
|
|
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
|
|
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
raw, err := c.execute(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
|
|
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
raw, err := c.execute(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
|
|
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
raw, err := c.execute(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
|
|
|
|
|
}
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
raw, err := c.execute(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
|
|
|
|
|
}
|