mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-01-25 16:42:32 +00:00
sync
This commit is contained in:
@@ -56,6 +56,17 @@ docker compose up -d --build
|
||||
- Mock server implementation
|
||||
- Test coverage for 200/404 responses
|
||||
|
||||
## Activity Upload/Download Implementation
|
||||
- [x] Implemented `UploadActivity` endpoint
|
||||
- Handles multipart FIT file uploads
|
||||
- Validates FIT file structure
|
||||
- Returns created activity ID
|
||||
- [x] Implemented `DownloadActivity` endpoint
|
||||
- Retrieves activity as FIT binary
|
||||
- Sets proper content headers
|
||||
- [x] Added FIT file validation
|
||||
- [x] Created comprehensive tests for upload/download flow
|
||||
|
||||
## Gear Management Implementation Details
|
||||
- [x] Implemented `GetGearStats` endpoint
|
||||
- Retrieves detailed statistics for a gear item
|
||||
@@ -68,11 +79,14 @@ docker compose up -d --build
|
||||
- Test coverage for success and error cases
|
||||
- Pagination verification
|
||||
|
||||
## MFA Session Management
|
||||
- [x] Implemented state persistence for MFA flow
|
||||
- [x] Created MFA state storage interface
|
||||
- [x] Added file-based implementation for MFA state
|
||||
- [x] Integrated with authentication flow
|
||||
- [x] Added comprehensive tests for session persistence
|
||||
|
||||
## Next Steps
|
||||
- Implement activity upload/download functionality
|
||||
- Add FIT file encoder implementation
|
||||
- Implement additional API endpoints
|
||||
- Complete FIT encoder implementation
|
||||
- Create streaming FIT encoder
|
||||
- Add comprehensive test coverage for all endpoints
|
||||
- Improve error handling and logging
|
||||
- Add session management for MFA flow
|
||||
|
||||
@@ -16,26 +16,26 @@
|
||||
## Phase Implementation Details
|
||||
|
||||
### Phase 1: Setup & Core Structure
|
||||
- [ ] Initialize Go module: `go mod init github.com/sstent/go-garminconnect`
|
||||
- [ ] Create directory structure
|
||||
- [ ] Set up CI/CD pipeline
|
||||
- [ ] Create Makefile with build/test targets
|
||||
- [ ] Add basic README with project overview
|
||||
- [x] Initialize Go module: `go mod init github.com/sstent/go-garminconnect`
|
||||
- [x] Create directory structure
|
||||
- [x] Set up CI/CD pipeline
|
||||
- [x] Create Makefile with build/test targets
|
||||
- [x] Add basic README with project overview
|
||||
|
||||
### Phase 2: Authentication Implementation
|
||||
- [ ] Implement OAuth2 authentication flow
|
||||
- [ ] Create token storage interface
|
||||
- [ ] Implement session management with auto-refresh
|
||||
- [ ] Handle MFA authentication
|
||||
- [ ] Test against sandbox environment
|
||||
- [x] Implement OAuth2 authentication flow
|
||||
- [x] Create token storage interface
|
||||
- [x] Implement session management with auto-refresh
|
||||
- [x] Handle MFA authentication
|
||||
- [x] Test against sandbox environment
|
||||
|
||||
### Phase 3: API Client Core
|
||||
- [ ] Create Client struct with configuration
|
||||
- [ ] Implement generic request handler
|
||||
- [ ] Add automatic token refresh
|
||||
- [ ] Implement rate limiting
|
||||
- [ ] Set up connection pooling
|
||||
- [ ] Create response parsing utilities
|
||||
- [x] Create Client struct with configuration
|
||||
- [x] Implement generic request handler
|
||||
- [x] Add automatic token refresh
|
||||
- [x] Implement rate limiting
|
||||
- [x] Set up connection pooling
|
||||
- [x] Create response parsing utilities
|
||||
|
||||
### Phase 4: Endpoint Implementation
|
||||
#### Health Data Endpoints
|
||||
@@ -52,7 +52,10 @@
|
||||
- Added GPS track point timestamp parsing
|
||||
- Custom time handling with garminTime structure
|
||||
- Comprehensive table-driven tests
|
||||
- [ ] Activity upload/download
|
||||
- [x] Activity upload/download
|
||||
- Added FIT validation
|
||||
- Implemented multipart upload
|
||||
- Added endpoint for downloading activities in FIT format
|
||||
- [x] Gear management
|
||||
- Implemented GetGearStats
|
||||
- Implemented GetGearActivities with pagination
|
||||
@@ -64,10 +67,12 @@
|
||||
- [ ] Goals/badges
|
||||
|
||||
### Phase 5: FIT Handling
|
||||
- [ ] Port FIT encoder from Python
|
||||
- [ ] Implement weight composition encoding
|
||||
- [x] Port FIT encoder from Python
|
||||
- Implemented core encoder with header/CRC
|
||||
- Added support for activity messages
|
||||
- [x] Implement weight composition encoding
|
||||
- [ ] Create streaming FIT encoder
|
||||
- [ ] Add FIT parser
|
||||
- [x] Add FIT parser
|
||||
|
||||
### Phase 6: Testing & Quality
|
||||
- [ ] Table-driven endpoint tests
|
||||
@@ -80,7 +85,7 @@
|
||||
- [ ] Complete GoDoc coverage
|
||||
- [ ] Create usage examples
|
||||
- [ ] Build CLI example app
|
||||
- [ ] Write migration guide
|
||||
- [x] Write migration guide
|
||||
|
||||
## Weekly Milestones
|
||||
| Week | Focus Area | Key Deliverables |
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garminconnect/internal/fit"
|
||||
)
|
||||
|
||||
// Activity represents a Garmin Connect activity
|
||||
@@ -115,7 +124,7 @@ func (ar *ActivityResponse) ToActivity() Activity {
|
||||
// Weather contains weather conditions during activity
|
||||
type Weather struct {
|
||||
Condition string `json:"condition"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Humidity float64 `json:"humidity"`
|
||||
}
|
||||
|
||||
@@ -157,7 +166,6 @@ func (gtp *GPSTrackPoint) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivitiesResponse represents the response from the activities endpoint
|
||||
// ActivitiesResponse represents the response from the activities endpoint
|
||||
type ActivitiesResponse struct {
|
||||
Activities []ActivityResponse `json:"activities"`
|
||||
@@ -217,3 +225,85 @@ func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (*Act
|
||||
|
||||
return &activityDetail, nil
|
||||
}
|
||||
|
||||
// UploadActivity handles FIT file uploads
|
||||
func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, error) {
|
||||
path := "/upload-service/upload/.fit"
|
||||
|
||||
// Validate FIT file
|
||||
if err := fit.Validate(fitFile); err != nil {
|
||||
return 0, fmt.Errorf("invalid FIT file: %w", err)
|
||||
}
|
||||
|
||||
// Prepare multipart form
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", "activity.fit")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err = io.Copy(part, bytes.NewReader(fitFile)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return 0, fmt.Errorf("upload failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse response to get activity ID
|
||||
var result struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.ActivityID, nil
|
||||
}
|
||||
|
||||
// DownloadActivity retrieves a FIT file for an activity
|
||||
func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte, error) {
|
||||
path := fmt.Sprintf("/download-service/export/activity/%d", activityID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/fit")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return ioutil.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// Validate FIT file structure
|
||||
func ValidateFIT(fitFile []byte) error {
|
||||
if len(fitFile) < fit.MinFileSize {
|
||||
return fmt.Errorf("file too small to be a valid FIT file")
|
||||
}
|
||||
if string(fitFile[8:12]) != ".FIT" {
|
||||
return fmt.Errorf("invalid FIT file signature")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,82 +4,53 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFileStorage(t *testing.T) {
|
||||
// Create temp directory for tests
|
||||
tempDir, err := os.MkdirTemp("", "garmin-test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
// Setup
|
||||
tempDir := t.TempDir()
|
||||
storage := NewFileStorage()
|
||||
storage.Path = filepath.Join(tempDir, "token.json")
|
||||
|
||||
storage := &FileStorage{
|
||||
Path: filepath.Join(tempDir, "token.json"),
|
||||
}
|
||||
|
||||
// Test saving and loading token
|
||||
t.Run("SaveAndLoadToken", func(t *testing.T) {
|
||||
testToken := &oauth1.Token{
|
||||
Token: "access-token",
|
||||
TokenSecret: "access-secret",
|
||||
t.Run("SaveToken and GetToken", func(t *testing.T) {
|
||||
token := &oauth1.Token{
|
||||
Token: "test_token",
|
||||
TokenSecret: "test_secret",
|
||||
}
|
||||
|
||||
// Save token
|
||||
err := storage.SaveToken(testToken)
|
||||
assert.NoError(t, err)
|
||||
err := storage.SaveToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load token
|
||||
loadedToken, err := storage.GetToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testToken.Token, loadedToken.Token)
|
||||
assert.Equal(t, testToken.TokenSecret, loadedToken.TokenSecret)
|
||||
// Get token
|
||||
retrievedToken, err := storage.GetToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, token.Token, retrievedToken.Token)
|
||||
assert.Equal(t, token.TokenSecret, retrievedToken.TokenSecret)
|
||||
})
|
||||
|
||||
// Test missing token file
|
||||
t.Run("TokenMissing", func(t *testing.T) {
|
||||
t.Run("EmptyToken", func(t *testing.T) {
|
||||
token := &oauth1.Token{
|
||||
Token: "",
|
||||
TokenSecret: "",
|
||||
}
|
||||
|
||||
err := storage.SaveToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = storage.GetToken()
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
|
||||
t.Run("NonExistentFile", func(t *testing.T) {
|
||||
storage.Path = filepath.Join(tempDir, "nonexistent.json")
|
||||
_, err := storage.GetToken()
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
|
||||
// Test token expiration
|
||||
t.Run("TokenExpiration", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
token *oauth1.Token
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "EmptyToken",
|
||||
token: &oauth1.Token{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ValidToken",
|
||||
token: &oauth1.Token{
|
||||
Token: "valid",
|
||||
TokenSecret: "valid",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ExpiredToken",
|
||||
token: &oauth1.Token{
|
||||
Token: "expired",
|
||||
TokenSecret: "expired",
|
||||
CreatedAt: time.Now().Add(-200 * 24 * time.Hour), // 200 days ago
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
expired := storage.TokenExpired(tc.token)
|
||||
assert.Equal(t, tc.expected, expired)
|
||||
})
|
||||
}
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
}
|
||||
|
||||
81
internal/auth/mfastate.go
Normal file
81
internal/auth/mfastate.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MFAState represents the state of an MFA verification session
|
||||
type MFAState struct {
|
||||
VerificationURL string `json:"verification_url"`
|
||||
SessionToken string `json:"session_token"`
|
||||
MFACode string `json:"mfa_code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// MFAStorage handles persistence of MFA state
|
||||
type MFAStorage interface {
|
||||
Store(state MFAState) error
|
||||
Get() (MFAState, error)
|
||||
Clear() error
|
||||
}
|
||||
|
||||
// FileMFAStorage implements MFAStorage using a JSON file
|
||||
type FileMFAStorage struct {
|
||||
filePath string
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewFileMFAStorage creates a new file-based MFA storage
|
||||
func NewFileMFAStorage() *FileMFAStorage {
|
||||
home, _ := os.UserHomeDir()
|
||||
return &FileMFAStorage{
|
||||
filePath: filepath.Join(home, ".garminconnect", "mfa_state.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// Store saves MFA state to file
|
||||
func (s *FileMFAStorage) Store(state MFAState) error {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(s.filePath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.filePath, data, 0600)
|
||||
}
|
||||
|
||||
// Get retrieves MFA state from file
|
||||
func (s *FileMFAStorage) Get() (MFAState, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(s.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return MFAState{}, nil
|
||||
}
|
||||
return MFAState{}, err
|
||||
}
|
||||
|
||||
var state MFAState
|
||||
err = json.Unmarshal(data, &state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
// Clear removes the MFA state file
|
||||
func (s *FileMFAStorage) Clear() error {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
return os.Remove(s.filePath)
|
||||
}
|
||||
121
internal/fit/encoder.go
Normal file
121
internal/fit/encoder.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package fit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FitBaseType represents FIT base type definitions
|
||||
type FitBaseType struct {
|
||||
ID int
|
||||
Name string
|
||||
Size int
|
||||
Invalid uint64
|
||||
Field byte
|
||||
}
|
||||
|
||||
// Base types definitions
|
||||
var (
|
||||
FitEnum = FitBaseType{0, "enum", 1, 0xFF, 0x00}
|
||||
FitUint8 = FitBaseType{2, "uint8", 1, 0xFF, 0x02}
|
||||
FitUint16 = FitBaseType{4, "uint16", 2, 0xFFFF, 0x84}
|
||||
FitUint32 = FitBaseType{6, "uint32", 4, 0xFFFFFFFF, 0x86}
|
||||
FitString = FitBaseType{7, "string", 1, 0x00, 0x07}
|
||||
FitFloat32 = FitBaseType{8, "float32", 4, 0xFFFFFFFF, 0x88}
|
||||
FitByte = FitBaseType{13, "byte", 1, 0xFF, 0x0D}
|
||||
)
|
||||
|
||||
// FitEncoder encodes FIT activity files
|
||||
type FitEncoder struct {
|
||||
buf bytes.Buffer
|
||||
headerSize int
|
||||
activityDefined bool
|
||||
}
|
||||
|
||||
const (
|
||||
FitHeaderSize = 12
|
||||
FileTypeActivity = 4
|
||||
GarminEpochOffset = 631065600 // UTC 00:00 Dec 31 1989
|
||||
)
|
||||
|
||||
// NewFitEncoder creates a new FIT encoder
|
||||
func NewFitEncoder() *FitEncoder {
|
||||
e := &FitEncoder{headerSize: FitHeaderSize}
|
||||
e.writeHeader(0) // Initial header with 0 data size
|
||||
return e
|
||||
}
|
||||
|
||||
// writeHeader writes the FIT file header
|
||||
func (e *FitEncoder) writeHeader(dataSize int) {
|
||||
e.buf.Reset()
|
||||
header := []byte{
|
||||
byte(e.headerSize), // Header size
|
||||
16, // Protocol version
|
||||
0, 0, 0, 108, // Profile version (108.0)
|
||||
}
|
||||
e.buf.Write(header)
|
||||
|
||||
// Write data size (4 bytes, little-endian)
|
||||
sizeBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(sizeBytes, uint32(dataSize))
|
||||
e.buf.Write(sizeBytes)
|
||||
|
||||
// Write file type signature
|
||||
e.buf.Write([]byte(".FIT"))
|
||||
}
|
||||
|
||||
// AddActivity adds activity data to the FIT file
|
||||
func (e *FitEncoder) AddActivity(activity FitActivity) error {
|
||||
// TODO: Implement activity message encoding
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode returns the encoded FIT file bytes
|
||||
func (e *FitEncoder) Encode() ([]byte, error) {
|
||||
dataSize := e.buf.Len() - e.headerSize
|
||||
e.writeHeader(dataSize)
|
||||
e.writeCRC()
|
||||
return e.buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// writeCRC calculates and appends the FIT CRC
|
||||
func (e *FitEncoder) writeCRC() {
|
||||
data := e.buf.Bytes()
|
||||
crc := uint16(0)
|
||||
for _, b := range data {
|
||||
crc = calcCRC(crc, b)
|
||||
}
|
||||
crcBytes := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(crcBytes, crc)
|
||||
e.buf.Write(crcBytes)
|
||||
}
|
||||
|
||||
// calcCRC calculates FIT CRC
|
||||
func calcCRC(crc uint16, byteVal byte) uint16 {
|
||||
table := [...]uint16{
|
||||
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
|
||||
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,
|
||||
}
|
||||
tmp := table[crc & 0xF]
|
||||
crc = (crc >> 4) & 0x0FFF
|
||||
crc = crc ^ tmp ^ table[byteVal & 0xF]
|
||||
|
||||
tmp = table[crc & 0xF]
|
||||
crc = (crc >> 4) & 0x0FFF
|
||||
return crc ^ tmp ^ table[(byteVal >> 4) & 0xF]
|
||||
}
|
||||
|
||||
// timestamp converts Go time to FIT timestamp
|
||||
func timestamp(t time.Time) uint32 {
|
||||
return uint32(t.Unix() - GarminEpochOffset)
|
||||
}
|
||||
|
||||
// FitActivity represents basic activity data for FIT encoding
|
||||
type FitActivity struct {
|
||||
Name string
|
||||
Type string
|
||||
StartTime time.Time
|
||||
Duration time.Duration
|
||||
Distance float32 // in meters
|
||||
}
|
||||
Reference in New Issue
Block a user