feat(garmin): implement data-fetch methods via generic call dispatch
GetActivities/GetActivitySplits/GetActivityDetails/GetWorkoutByID now send
{"cmd":"call","params":{"method":...,"args":...}} instead of named MCP
tools. subprocessClient fully implements Client.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -311,3 +312,127 @@ func truncate(s string, n int) string {
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if err := c.ensureStarted(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.roundTrip("call", callParams{
|
||||
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()
|
||||
|
||||
if err := c.ensureStarted(); err != nil {
|
||||
return ActivitySplits{}, err
|
||||
}
|
||||
raw, err := c.roundTrip("call", callParams{
|
||||
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()
|
||||
|
||||
if err := c.ensureStarted(); err != nil {
|
||||
return ActivityDetails{}, err
|
||||
}
|
||||
raw, err := c.roundTrip("call", callParams{
|
||||
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()
|
||||
|
||||
if err := c.ensureStarted(); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
raw, err := c.roundTrip("call", callParams{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -276,3 +276,121 @@ func TestSubprocessClient_CompleteMFA_Failed(t *testing.T) {
|
||||
t.Errorf("CompleteMFA status = %v, want AuthFailed", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivities_UsesGarminconnectKwargNames(t *testing.T) {
|
||||
var gotMethod string
|
||||
var gotArgs map[string]any
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
t.Fatalf("unmarshal params: %v", err)
|
||||
}
|
||||
gotMethod, gotArgs = p.Method, p.Args
|
||||
return fakeResult([]map[string]any{
|
||||
{"activityId": float64(123), "activityType": map[string]any{"typeKey": "running"}},
|
||||
})
|
||||
})
|
||||
|
||||
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivities: %v", err)
|
||||
}
|
||||
if gotMethod != "get_activities_by_date" {
|
||||
t.Errorf("method = %q, want get_activities_by_date", gotMethod)
|
||||
}
|
||||
if gotArgs["startdate"] != "2026-07-01" || gotArgs["enddate"] != "2026-07-25" {
|
||||
t.Errorf("args = %+v, want startdate/enddate matching garminconnect's real kwargs", gotArgs)
|
||||
}
|
||||
if len(activities) != 1 || activities[0].ActivityID != 123 {
|
||||
t.Fatalf("activities = %+v", activities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivities_AppliesLimitClientSide(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
raw := make([]map[string]any, 5)
|
||||
for i := range raw {
|
||||
raw[i] = map[string]any{"activityId": float64(i)}
|
||||
}
|
||||
return fakeResult(raw)
|
||||
})
|
||||
|
||||
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivities: %v", err)
|
||||
}
|
||||
if len(activities) != 2 {
|
||||
t.Fatalf("len(activities) = %d, want 2", len(activities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivitySplits_ParsesLapDTOsEnvelope(t *testing.T) {
|
||||
var gotMethod string
|
||||
var gotArgs map[string]any
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
json.Unmarshal(params, &p)
|
||||
gotMethod, gotArgs = p.Method, p.Args
|
||||
return fakeResult(map[string]any{
|
||||
"activityId": float64(111),
|
||||
"lapDTOs": []map[string]any{{"lapIndex": float64(1), "distance": float64(1000)}},
|
||||
})
|
||||
})
|
||||
|
||||
splits, err := c.GetActivitySplits(context.Background(), 111)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivitySplits: %v", err)
|
||||
}
|
||||
if gotMethod != "get_activity_splits" || gotArgs["activity_id"] != "111" {
|
||||
t.Errorf("method/args = %q/%+v, want get_activity_splits with activity_id=\"111\"", gotMethod, gotArgs)
|
||||
}
|
||||
if splits.ActivityID != 111 || len(splits.Laps) != 1 || splits.Laps[0].LapIndex != 1 {
|
||||
t.Fatalf("splits = %+v", splits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetActivityDetails_ParsesRawTelemetry(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
json.Unmarshal(params, &p)
|
||||
if p.Method != "get_activity_details" || p.Args["activity_id"] != "222" {
|
||||
t.Fatalf("unexpected call: %+v", p)
|
||||
}
|
||||
return fakeResult(map[string]any{
|
||||
"activityId": float64(222),
|
||||
"metricDescriptors": []map[string]any{{"key": "directHeartRate", "metricsIndex": float64(0)}},
|
||||
"activityDetailMetrics": []map[string]any{{"metrics": []any{float64(101)}}},
|
||||
})
|
||||
})
|
||||
|
||||
details, err := c.GetActivityDetails(context.Background(), 222)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityDetails: %v", err)
|
||||
}
|
||||
if details.ActivityID != 222 || len(details.MetricDescriptors) != 1 {
|
||||
t.Fatalf("details = %+v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) {
|
||||
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||
var p callParams
|
||||
json.Unmarshal(params, &p)
|
||||
if p.Method != "get_workout_by_id" || p.Args["workout_id"] != "333" {
|
||||
t.Fatalf("unexpected call: %+v", p)
|
||||
}
|
||||
return fakeResult(map[string]any{
|
||||
"workoutId": float64(333),
|
||||
"workoutName": "Tempo",
|
||||
"workoutSegments": []map[string]any{{"segmentOrder": float64(1), "workoutSteps": []any{}}},
|
||||
})
|
||||
})
|
||||
|
||||
workout, err := c.GetWorkoutByID(context.Background(), 333)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkoutByID: %v", err)
|
||||
}
|
||||
if workout.WorkoutID != 333 || workout.WorkoutName != "Tempo" || len(workout.Segments) != 1 {
|
||||
t.Fatalf("workout = %+v", workout)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user