mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-02-15 03:41:35 +00:00
sync
This commit is contained in:
@@ -56,6 +56,17 @@ docker compose up -d --build
|
|||||||
- Mock server implementation
|
- Mock server implementation
|
||||||
- Test coverage for 200/404 responses
|
- 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
|
## Gear Management Implementation Details
|
||||||
- [x] Implemented `GetGearStats` endpoint
|
- [x] Implemented `GetGearStats` endpoint
|
||||||
- Retrieves detailed statistics for a gear item
|
- Retrieves detailed statistics for a gear item
|
||||||
@@ -68,11 +79,14 @@ docker compose up -d --build
|
|||||||
- Test coverage for success and error cases
|
- Test coverage for success and error cases
|
||||||
- Pagination verification
|
- 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
|
## Next Steps
|
||||||
- Implement activity upload/download functionality
|
- Create streaming FIT encoder
|
||||||
- Add FIT file encoder implementation
|
|
||||||
- Implement additional API endpoints
|
|
||||||
- Complete FIT encoder implementation
|
|
||||||
- Add comprehensive test coverage for all endpoints
|
- Add comprehensive test coverage for all endpoints
|
||||||
- Improve error handling and logging
|
- Improve error handling and logging
|
||||||
- Add session management for MFA flow
|
|
||||||
|
|||||||
@@ -16,26 +16,26 @@
|
|||||||
## Phase Implementation Details
|
## Phase Implementation Details
|
||||||
|
|
||||||
### Phase 1: Setup & Core Structure
|
### Phase 1: Setup & Core Structure
|
||||||
- [ ] Initialize Go module: `go mod init github.com/sstent/go-garminconnect`
|
- [x] Initialize Go module: `go mod init github.com/sstent/go-garminconnect`
|
||||||
- [ ] Create directory structure
|
- [x] Create directory structure
|
||||||
- [ ] Set up CI/CD pipeline
|
- [x] Set up CI/CD pipeline
|
||||||
- [ ] Create Makefile with build/test targets
|
- [x] Create Makefile with build/test targets
|
||||||
- [ ] Add basic README with project overview
|
- [x] Add basic README with project overview
|
||||||
|
|
||||||
### Phase 2: Authentication Implementation
|
### Phase 2: Authentication Implementation
|
||||||
- [ ] Implement OAuth2 authentication flow
|
- [x] Implement OAuth2 authentication flow
|
||||||
- [ ] Create token storage interface
|
- [x] Create token storage interface
|
||||||
- [ ] Implement session management with auto-refresh
|
- [x] Implement session management with auto-refresh
|
||||||
- [ ] Handle MFA authentication
|
- [x] Handle MFA authentication
|
||||||
- [ ] Test against sandbox environment
|
- [x] Test against sandbox environment
|
||||||
|
|
||||||
### Phase 3: API Client Core
|
### Phase 3: API Client Core
|
||||||
- [ ] Create Client struct with configuration
|
- [x] Create Client struct with configuration
|
||||||
- [ ] Implement generic request handler
|
- [x] Implement generic request handler
|
||||||
- [ ] Add automatic token refresh
|
- [x] Add automatic token refresh
|
||||||
- [ ] Implement rate limiting
|
- [x] Implement rate limiting
|
||||||
- [ ] Set up connection pooling
|
- [x] Set up connection pooling
|
||||||
- [ ] Create response parsing utilities
|
- [x] Create response parsing utilities
|
||||||
|
|
||||||
### Phase 4: Endpoint Implementation
|
### Phase 4: Endpoint Implementation
|
||||||
#### Health Data Endpoints
|
#### Health Data Endpoints
|
||||||
@@ -52,7 +52,10 @@
|
|||||||
- Added GPS track point timestamp parsing
|
- Added GPS track point timestamp parsing
|
||||||
- Custom time handling with garminTime structure
|
- Custom time handling with garminTime structure
|
||||||
- Comprehensive table-driven tests
|
- 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
|
- [x] Gear management
|
||||||
- Implemented GetGearStats
|
- Implemented GetGearStats
|
||||||
- Implemented GetGearActivities with pagination
|
- Implemented GetGearActivities with pagination
|
||||||
@@ -64,10 +67,12 @@
|
|||||||
- [ ] Goals/badges
|
- [ ] Goals/badges
|
||||||
|
|
||||||
### Phase 5: FIT Handling
|
### Phase 5: FIT Handling
|
||||||
- [ ] Port FIT encoder from Python
|
- [x] Port FIT encoder from Python
|
||||||
- [ ] Implement weight composition encoding
|
- Implemented core encoder with header/CRC
|
||||||
|
- Added support for activity messages
|
||||||
|
- [x] Implement weight composition encoding
|
||||||
- [ ] Create streaming FIT encoder
|
- [ ] Create streaming FIT encoder
|
||||||
- [ ] Add FIT parser
|
- [x] Add FIT parser
|
||||||
|
|
||||||
### Phase 6: Testing & Quality
|
### Phase 6: Testing & Quality
|
||||||
- [ ] Table-driven endpoint tests
|
- [ ] Table-driven endpoint tests
|
||||||
@@ -80,7 +85,7 @@
|
|||||||
- [ ] Complete GoDoc coverage
|
- [ ] Complete GoDoc coverage
|
||||||
- [ ] Create usage examples
|
- [ ] Create usage examples
|
||||||
- [ ] Build CLI example app
|
- [ ] Build CLI example app
|
||||||
- [ ] Write migration guide
|
- [x] Write migration guide
|
||||||
|
|
||||||
## Weekly Milestones
|
## Weekly Milestones
|
||||||
| Week | Focus Area | Key Deliverables |
|
| Week | Focus Area | Key Deliverables |
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sstent/go-garminconnect/internal/fit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Activity represents a Garmin Connect activity
|
// Activity represents a Garmin Connect activity
|
||||||
@@ -115,7 +124,7 @@ func (ar *ActivityResponse) ToActivity() Activity {
|
|||||||
// Weather contains weather conditions during activity
|
// Weather contains weather conditions during activity
|
||||||
type Weather struct {
|
type Weather struct {
|
||||||
Condition string `json:"condition"`
|
Condition string `json:"condition"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
Humidity float64 `json:"humidity"`
|
Humidity float64 `json:"humidity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +166,6 @@ func (gtp *GPSTrackPoint) UnmarshalJSON(data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivitiesResponse represents the response from the activities endpoint
|
|
||||||
// ActivitiesResponse represents the response from the activities endpoint
|
// ActivitiesResponse represents the response from the activities endpoint
|
||||||
type ActivitiesResponse struct {
|
type ActivitiesResponse struct {
|
||||||
Activities []ActivityResponse `json:"activities"`
|
Activities []ActivityResponse `json:"activities"`
|
||||||
@@ -217,3 +225,85 @@ func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (*Act
|
|||||||
|
|
||||||
return &activityDetail, nil
|
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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dghubble/oauth1"
|
"github.com/dghubble/oauth1"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileStorage(t *testing.T) {
|
func TestFileStorage(t *testing.T) {
|
||||||
// Create temp directory for tests
|
// Setup
|
||||||
tempDir, err := os.MkdirTemp("", "garmin-test")
|
tempDir := t.TempDir()
|
||||||
assert.NoError(t, err)
|
storage := NewFileStorage()
|
||||||
defer os.RemoveAll(tempDir)
|
storage.Path = filepath.Join(tempDir, "token.json")
|
||||||
|
|
||||||
storage := &FileStorage{
|
t.Run("SaveToken and GetToken", func(t *testing.T) {
|
||||||
Path: filepath.Join(tempDir, "token.json"),
|
token := &oauth1.Token{
|
||||||
}
|
Token: "test_token",
|
||||||
|
TokenSecret: "test_secret",
|
||||||
// Test saving and loading token
|
|
||||||
t.Run("SaveAndLoadToken", func(t *testing.T) {
|
|
||||||
testToken := &oauth1.Token{
|
|
||||||
Token: "access-token",
|
|
||||||
TokenSecret: "access-secret",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save token
|
// Save token
|
||||||
err := storage.SaveToken(testToken)
|
err := storage.SaveToken(token)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Load token
|
// Get token
|
||||||
loadedToken, err := storage.GetToken()
|
retrievedToken, err := storage.GetToken()
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, testToken.Token, loadedToken.Token)
|
|
||||||
assert.Equal(t, testToken.TokenSecret, loadedToken.TokenSecret)
|
// Verify
|
||||||
|
assert.Equal(t, token.Token, retrievedToken.Token)
|
||||||
|
assert.Equal(t, token.TokenSecret, retrievedToken.TokenSecret)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test missing token file
|
t.Run("EmptyToken", func(t *testing.T) {
|
||||||
t.Run("TokenMissing", 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()
|
_, err := storage.GetToken()
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
require.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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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