commit 88b74b7dbc74ea41c829c69d8f753bca35d97608 Author: Thomas Klaehn Date: Sun Feb 8 07:28:32 2026 +0000 Initial commit Signed-off-by: Thomas Klaehn diff --git a/fitparser/crc.go b/fitparser/crc.go new file mode 100644 index 0000000..6aacffd --- /dev/null +++ b/fitparser/crc.go @@ -0,0 +1,43 @@ +package fitparser + +// CRC lookup table for FIT files +var crcTable = [16]uint16{ + 0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, + 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400, +} + +// CalculateCRC calculates the CRC-16 checksum for FIT files +// This uses the CRC-16-ANSI algorithm with polynomial 0x8005 +func CalculateCRC(data []byte) uint16 { + crc := uint16(0) + for _, b := range data { + // Compute CRC for lower nibble (bits 0-3) + tmp := crcTable[crc&0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crcTable[b&0xF] + + // Compute CRC for upper nibble (bits 4-7) + tmp = crcTable[crc&0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crcTable[(b>>4)&0xF] + } + + return crc +} + +// UpdateCRC updates an existing CRC with new data +func UpdateCRC(crc uint16, data []byte) uint16 { + for _, b := range data { + // Compute CRC for lower nibble (bits 0-3) + tmp := crcTable[crc&0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crcTable[b&0xF] + + // Compute CRC for upper nibble (bits 4-7) + tmp = crcTable[crc&0xF] + crc = (crc >> 4) & 0x0FFF + crc = crc ^ tmp ^ crcTable[(b>>4)&0xF] + } + + return crc +} diff --git a/fitparser/decoder.go b/fitparser/decoder.go new file mode 100644 index 0000000..2b9b852 --- /dev/null +++ b/fitparser/decoder.go @@ -0,0 +1,332 @@ +package fitparser + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" +) + +// Decoder decodes FIT files +type Decoder struct { + reader *bytes.Reader + header *Header + localMessageTypes [16]*MessageDefinition // Local message type -> definition + messages []*Message + crc uint16 + enableCRCCheck bool + offset int64 +} + +// NewDecoder creates a new FIT file decoder +func NewDecoder(data []byte) (*Decoder, error) { + if len(data) < HeaderMinSize { + return nil, fmt.Errorf("file too small to be a valid FIT file: %d bytes", len(data)) + } + + if !IsFIT(data) { + return nil, fmt.Errorf("not a valid FIT file: missing .FIT signature") + } + + return &Decoder{ + reader: bytes.NewReader(data), + enableCRCCheck: true, + crc: 0, + }, nil +} + +// EnableCRCCheck enables or disables CRC checking +func (d *Decoder) EnableCRCCheck(enable bool) { + d.enableCRCCheck = enable +} + +// Decode decodes the entire FIT file and returns all messages +func (d *Decoder) Decode() ([]*Message, error) { + // Parse header + headerData := make([]byte, HeaderWithCRCSize) + n, err := d.reader.Read(headerData) + if err != nil { + return nil, fmt.Errorf("failed to read header: %w", err) + } + + header, err := ParseHeader(headerData[:n]) + if err != nil { + return nil, fmt.Errorf("failed to parse header: %w", err) + } + d.header = header + + // Position reader at start of data records + d.reader.Seek(int64(header.Size), io.SeekStart) + d.offset = int64(header.Size) + + // Initialize CRC with entire header (all bytes up to data start) + // For 14-byte headers, this includes the header CRC (bytes 12-13) + // For 12-byte headers, this is just bytes 0-11 + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, headerData[:header.Size]) + } + + // Read data records + dataEnd := int64(header.Size) + int64(header.DataSize) + for d.offset < dataEnd { + msg, err := d.readRecord() + if err != nil { + return nil, fmt.Errorf("error reading record at offset %d: %w", d.offset, err) + } + + if msg != nil { + d.messages = append(d.messages, msg) + } + } + + // Verify file CRC if enabled + if d.enableCRCCheck { + fileCRCData := make([]byte, 2) + _, err := d.reader.Read(fileCRCData) + if err != nil { + return nil, fmt.Errorf("failed to read file CRC: %w", err) + } + + fileCRC := binary.LittleEndian.Uint16(fileCRCData) + if fileCRC != d.crc { + return nil, fmt.Errorf("file CRC mismatch: calculated 0x%04X, expected 0x%04X", d.crc, fileCRC) + } + } + + return d.messages, nil +} + +// readRecord reads a single record (definition or data message) +func (d *Decoder) readRecord() (*Message, error) { + recordHeader := make([]byte, 1) + _, err := d.reader.Read(recordHeader) + if err != nil { + return nil, fmt.Errorf("failed to read record header: %w", err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, recordHeader) + } + d.offset++ + + header := recordHeader[0] + + // Check if this is a definition message or data message + if (header & MesgDefinitionMask) != 0 { + // Definition message + return d.readDefinitionMessage(header) + } else { + // Data message (normal or compressed timestamp) + if (header & RecordHeaderCompressedMask) != 0 { + // Compressed timestamp header + return d.readCompressedTimestampMessage(header) + } else { + // Normal data message + return d.readDataMessage(header) + } + } +} + +// readDefinitionMessage reads a message definition +func (d *Decoder) readDefinitionMessage(header byte) (*Message, error) { + localMesgNum := header & LocalMesgNum + + // Read definition header (5 bytes: reserved, architecture, global_mesg_num, num_fields) + defHeader := make([]byte, 5) + _, err := d.reader.Read(defHeader) + if err != nil { + return nil, fmt.Errorf("failed to read definition header: %w", err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, defHeader) + } + d.offset += 5 + + msgDef := &MessageDefinition{ + Reserved: defHeader[0], + Architecture: defHeader[1], + NumFields: defHeader[4], + } + + // Parse global message number based on architecture + if msgDef.IsLittleEndian() { + msgDef.GlobalMesgNum = binary.LittleEndian.Uint16(defHeader[2:4]) + } else { + msgDef.GlobalMesgNum = binary.BigEndian.Uint16(defHeader[2:4]) + } + + // Read field definitions (3 bytes each: field_def_num, size, base_type) + msgDef.FieldDefinitions = make([]FieldDefinition, msgDef.NumFields) + for i := 0; i < int(msgDef.NumFields); i++ { + fieldDefData := make([]byte, 3) + _, err := d.reader.Read(fieldDefData) + if err != nil { + return nil, fmt.Errorf("failed to read field definition %d: %w", i, err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, fieldDefData) + } + d.offset += 3 + + msgDef.FieldDefinitions[i] = FieldDefinition{ + Num: fieldDefData[0], + Size: fieldDefData[1], + BaseType: fieldDefData[2], + } + } + + // Check for developer fields + if (header & DevDataMask) != 0 { + devFieldsData := make([]byte, 1) + _, err := d.reader.Read(devFieldsData) + if err != nil { + return nil, fmt.Errorf("failed to read num dev fields: %w", err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, devFieldsData) + } + d.offset++ + + msgDef.NumDevFields = devFieldsData[0] + + // Read developer field definitions (3 bytes each) + msgDef.DevFieldDefinitions = make([]DeveloperFieldDefinition, msgDef.NumDevFields) + for i := 0; i < int(msgDef.NumDevFields); i++ { + devFieldDefData := make([]byte, 3) + _, err := d.reader.Read(devFieldDefData) + if err != nil { + return nil, fmt.Errorf("failed to read dev field definition %d: %w", i, err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, devFieldDefData) + } + d.offset += 3 + + msgDef.DevFieldDefinitions[i] = DeveloperFieldDefinition{ + Num: devFieldDefData[0], + Size: devFieldDefData[1], + DeveloperDataIndex: devFieldDefData[2], + } + } + } + + // Store the definition for this local message number + d.localMessageTypes[localMesgNum] = msgDef + + return nil, nil // Definition messages don't produce output messages +} + +// readDataMessage reads a data message using a previously defined message definition +func (d *Decoder) readDataMessage(header byte) (*Message, error) { + localMesgNum := header & LocalMesgNum + + msgDef := d.localMessageTypes[localMesgNum] + if msgDef == nil { + return nil, fmt.Errorf("no definition found for local message number %d", localMesgNum) + } + + msg := NewMessage(msgDef.GlobalMesgNum) + + // Read and decode each field + for _, fieldDef := range msgDef.FieldDefinitions { + fieldData := make([]byte, fieldDef.Size) + _, err := d.reader.Read(fieldData) + if err != nil { + return nil, fmt.Errorf("failed to read field %d: %w", fieldDef.Num, err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, fieldData) + } + d.offset += int64(fieldDef.Size) + + // Decode the field value + value, err := DecodeField(fieldData, fieldDef, msgDef.IsLittleEndian()) + if err != nil { + return nil, fmt.Errorf("failed to decode field %d: %w", fieldDef.Num, err) + } + + msg.Fields[fieldDef.Num] = value + } + + // Read developer fields if present + for _, devFieldDef := range msgDef.DevFieldDefinitions { + devFieldData := make([]byte, devFieldDef.Size) + _, err := d.reader.Read(devFieldData) + if err != nil { + return nil, fmt.Errorf("failed to read dev field %d: %w", devFieldDef.Num, err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, devFieldData) + } + d.offset += int64(devFieldDef.Size) + + msg.DevFields[devFieldDef.Num] = devFieldData + } + + return msg, nil +} + +// readCompressedTimestampMessage reads a compressed timestamp data message +func (d *Decoder) readCompressedTimestampMessage(header byte) (*Message, error) { + localMesgNum := header & RecordHeaderLocalMesgNumMask + timeOffset := header & RecordHeaderTimeMask + + msgDef := d.localMessageTypes[localMesgNum] + if msgDef == nil { + return nil, fmt.Errorf("no definition found for local message number %d", localMesgNum) + } + + msg := NewMessage(msgDef.GlobalMesgNum) + + // Read and decode each field + for _, fieldDef := range msgDef.FieldDefinitions { + fieldData := make([]byte, fieldDef.Size) + _, err := d.reader.Read(fieldData) + if err != nil { + return nil, fmt.Errorf("failed to read field %d: %w", fieldDef.Num, err) + } + + if d.enableCRCCheck { + d.crc = UpdateCRC(d.crc, fieldData) + } + d.offset += int64(fieldDef.Size) + + // Decode the field value + value, err := DecodeField(fieldData, fieldDef, msgDef.IsLittleEndian()) + if err != nil { + return nil, fmt.Errorf("failed to decode field %d: %w", fieldDef.Num, err) + } + + msg.Fields[fieldDef.Num] = value + } + + // Store the compressed timestamp offset + // Note: Full timestamp reconstruction would require tracking the last full timestamp + // For now, we just store the offset value + msg.Fields[253] = uint32(timeOffset) // Field 253 is typically timestamp + + return msg, nil +} + +// GetHeader returns the parsed header +func (d *Decoder) GetHeader() *Header { + return d.header +} + +// CheckIntegrity checks if the file is a valid FIT file with correct CRC +func CheckIntegrity(data []byte) error { + decoder, err := NewDecoder(data) + if err != nil { + return err + } + + decoder.EnableCRCCheck(true) + _, err = decoder.Decode() + return err +} diff --git a/fitparser/decoder_test.go b/fitparser/decoder_test.go new file mode 100644 index 0000000..fab4cfb --- /dev/null +++ b/fitparser/decoder_test.go @@ -0,0 +1,246 @@ +package fitparser + +import ( + "fmt" + "os" + "testing" +) + +func TestIsFIT(t *testing.T) { + tests := []struct { + name string + data []byte + expected bool + }{ + { + name: "Valid FIT signature", + data: []byte{0x0E, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, '.', 'F', 'I', 'T', 0x00, 0x00}, + expected: true, + }, + { + name: "Invalid signature", + data: []byte{0x0E, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 'N', 'O', 'T', 'F', 0x00, 0x00}, + expected: false, + }, + { + name: "Too short", + data: []byte{0x0E, 0x10}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsFIT(tt.data) + if result != tt.expected { + t.Errorf("IsFIT() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestCalculateCRC(t *testing.T) { + // Test with known CRC values + data := []byte{0x0E, 0x10, 0xD9, 0x07, 0x00, 0x00, 0x00, 0x00, '.', 'F', 'I', 'T'} + crc := CalculateCRC(data) + + // CRC should be non-zero for this data + if crc == 0 { + t.Errorf("CalculateCRC() returned 0, expected non-zero value") + } + + // Test that same data produces same CRC + crc2 := CalculateCRC(data) + if crc != crc2 { + t.Errorf("CalculateCRC() not deterministic: got %d and %d", crc, crc2) + } +} + +func TestParseHeader(t *testing.T) { + tests := []struct { + name string + data []byte + wantErr bool + }{ + { + name: "Valid 12-byte header", + data: []byte{0x0C, 0x10, 0xD9, 0x07, 0x10, 0x00, 0x00, 0x00, '.', 'F', 'I', 'T'}, + wantErr: false, + }, + { + name: "Too short", + data: []byte{0x0C, 0x10}, + wantErr: true, + }, + { + name: "Invalid signature", + data: []byte{0x0C, 0x10, 0xD9, 0x07, 0x10, 0x00, 0x00, 0x00, 'N', 'O', 'P', 'E'}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header, err := ParseHeader(tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("ParseHeader() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && header == nil { + t.Errorf("ParseHeader() returned nil header without error") + } + }) + } +} + +func TestDecodeActivityFile(t *testing.T) { + // Test with a real FIT file from the SDK examples + filePath := "../example/testdata/Activity.fit" + + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Error reading test file %s: %v", filePath, err) + } + + decoder, err := NewDecoder(data) + if err != nil { + t.Fatalf("NewDecoder() error = %v", err) + } + + messages, err := decoder.Decode() + if err != nil { + t.Fatalf("Decode() error = %v", err) + } + + if len(messages) == 0 { + t.Error("Decode() returned no messages") + } + + // Print some statistics + t.Logf("Successfully decoded %d messages", len(messages)) + + // Count message types + msgCounts := make(map[uint16]int) + for _, msg := range messages { + msgCounts[msg.Num]++ + } + + t.Logf("Message type breakdown:") + for mesgNum, count := range msgCounts { + t.Logf(" %s (%d): %d messages", GetMessageName(mesgNum), mesgNum, count) + } + + // Verify we have essential messages + if msgCounts[MesgNumFileId] == 0 { + t.Error("Expected at least one FileId message") + } + if msgCounts[MesgNumRecord] == 0 { + t.Error("Expected at least one Record message") + } +} + +func TestDecodeSettingsFile(t *testing.T) { + filePath := "../example/testdata/Settings.fit" + + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Error reading test file %s: %v", filePath, err) + } + + decoder, err := NewDecoder(data) + if err != nil { + t.Fatalf("NewDecoder() error = %v", err) + } + + messages, err := decoder.Decode() + if err != nil { + t.Fatalf("Decode() error = %v", err) + } + + if len(messages) == 0 { + t.Error("Decode() returned no messages") + } + + t.Logf("Successfully decoded %d messages from Settings.fit", len(messages)) +} + +func TestCheckIntegrity(t *testing.T) { + filePath := "../example/testdata/Activity.fit" + + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Error reading test file %s: %v", filePath, err) + } + + err = CheckIntegrity(data) + if err != nil { + t.Errorf("CheckIntegrity() error = %v", err) + } +} + +func TestConvertSemicirclesToDegrees(t *testing.T) { + tests := []struct { + semicircles int32 + expected float64 + }{ + {0, 0.0}, + {2147483647, 180.0}, // Max positive (approximately) + {-2147483648, -180.0}, // Max negative (approximately) + {1073741824, 90.0}, // Quarter circle + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%d_semicircles", tt.semicircles), func(t *testing.T) { + result := ConvertSemicirclesToDegrees(tt.semicircles) + // Allow small floating point error + if result < tt.expected-0.1 || result > tt.expected+0.1 { + t.Errorf("ConvertSemicirclesToDegrees(%d) = %f, want %f", tt.semicircles, result, tt.expected) + } + }) + } +} + +func BenchmarkDecode(b *testing.B) { + filePath := "../example/testdata/Activity.fit" + + data, err := os.ReadFile(filePath) + if err != nil { + b.Fatalf("Error reading test file %s: %v", filePath, err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + decoder, err := NewDecoder(data) + if err != nil { + b.Fatalf("NewDecoder() error = %v", err) + } + + _, err = decoder.Decode() + if err != nil { + b.Fatalf("Decode() error = %v", err) + } + } +} + +func BenchmarkDecodeNoCRC(b *testing.B) { + filePath := "../example/testdata/Activity.fit" + + data, err := os.ReadFile(filePath) + if err != nil { + b.Fatalf("Error reading test file %s: %v", filePath, err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + decoder, err := NewDecoder(data) + if err != nil { + b.Fatalf("NewDecoder() error = %v", err) + } + + decoder.EnableCRCCheck(false) + _, err = decoder.Decode() + if err != nil { + b.Fatalf("Decode() error = %v", err) + } + } +} diff --git a/fitparser/header.go b/fitparser/header.go new file mode 100644 index 0000000..3cf4fb7 --- /dev/null +++ b/fitparser/header.go @@ -0,0 +1,110 @@ +package fitparser + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +// Header represents a FIT file header +type Header struct { + Size uint8 // Header size (12 or 14 bytes) + ProtocolVersion uint8 // Protocol version + ProfileVersion uint16 // Profile version + DataSize uint32 // Size of data records in bytes + DataType [4]byte // ".FIT" signature + CRC uint16 // CRC of header (only present if Size == 14) +} + +// ParseHeader parses a FIT file header from the given data +func ParseHeader(data []byte) (*Header, error) { + if len(data) < HeaderMinSize { + return nil, fmt.Errorf("header too small: got %d bytes, need at least %d", len(data), HeaderMinSize) + } + + h := &Header{ + Size: data[0], + ProtocolVersion: data[1], + DataType: [4]byte{data[8], data[9], data[10], data[11]}, + } + + // Validate header size + if h.Size != HeaderWithCRCSize && h.Size != HeaderWithoutCRCSize { + return nil, fmt.Errorf("invalid header size: %d (expected %d or %d)", + h.Size, HeaderWithoutCRCSize, HeaderWithCRCSize) + } + + // Ensure we have enough data + if len(data) < int(h.Size) { + return nil, fmt.Errorf("insufficient data for header: got %d bytes, need %d", len(data), h.Size) + } + + // Parse profile version (little endian) + h.ProfileVersion = binary.LittleEndian.Uint16(data[2:4]) + + // Parse data size (little endian) + h.DataSize = binary.LittleEndian.Uint32(data[4:8]) + + // Validate signature + if h.DataType != FITSignature { + return nil, fmt.Errorf("invalid FIT signature: got %s, expected .FIT", string(h.DataType[:])) + } + + // Parse CRC if present + if h.Size == HeaderWithCRCSize { + h.CRC = binary.LittleEndian.Uint16(data[12:14]) + + // Validate header CRC + calculatedCRC := CalculateCRC(data[0:12]) + if calculatedCRC != h.CRC { + return nil, fmt.Errorf("header CRC mismatch: got 0x%04X, expected 0x%04X", h.CRC, calculatedCRC) + } + } + + return h, nil +} + +// Validate performs basic validation on the header +func (h *Header) Validate() error { + if h.Size != HeaderWithCRCSize && h.Size != HeaderWithoutCRCSize { + return fmt.Errorf("invalid header size: %d", h.Size) + } + + if h.DataType != FITSignature { + return fmt.Errorf("invalid FIT signature: %s", string(h.DataType[:])) + } + + protocolMajor := h.ProtocolVersion >> ProtocolVersionMajorShift + if protocolMajor < 1 || protocolMajor > 2 { + return fmt.Errorf("unsupported protocol version: %d.%d", + protocolMajor, h.ProtocolVersion&ProtocolVersionMinorMask) + } + + return nil +} + +// ProtocolVersionMajor returns the major version of the protocol +func (h *Header) ProtocolVersionMajor() uint8 { + return h.ProtocolVersion >> ProtocolVersionMajorShift +} + +// ProtocolVersionMinor returns the minor version of the protocol +func (h *Header) ProtocolVersionMinor() uint8 { + return h.ProtocolVersion & ProtocolVersionMinorMask +} + +// IsFIT checks if the given data starts with a valid FIT file signature +func IsFIT(data []byte) bool { + if len(data) < HeaderMinSize { + return false + } + + signature := [4]byte{data[8], data[9], data[10], data[11]} + return bytes.Equal(signature[:], FITSignature[:]) +} + +// String returns a string representation of the header +func (h *Header) String() string { + return fmt.Sprintf("FIT Header: Protocol v%d.%d, Profile v%d, DataSize=%d bytes, HeaderSize=%d bytes", + h.ProtocolVersionMajor(), h.ProtocolVersionMinor(), h.ProfileVersion, h.DataSize, h.Size) +} diff --git a/fitparser/message.go b/fitparser/message.go new file mode 100644 index 0000000..b2286d2 --- /dev/null +++ b/fitparser/message.go @@ -0,0 +1,308 @@ +package fitparser + +import ( + "encoding/binary" + "fmt" + "math" +) + +// FieldDefinition represents a field definition in a message definition +type FieldDefinition struct { + Num uint8 // Field definition number + Size uint8 // Size in bytes + BaseType uint8 // Base type +} + +// DeveloperFieldDefinition represents a developer field definition +type DeveloperFieldDefinition struct { + Num uint8 + Size uint8 + DeveloperDataIndex uint8 +} + +// MessageDefinition represents a message definition record +type MessageDefinition struct { + Reserved uint8 + Architecture uint8 // 0 = little endian, 1 = big endian + GlobalMesgNum uint16 + NumFields uint8 + FieldDefinitions []FieldDefinition + NumDevFields uint8 + DevFieldDefinitions []DeveloperFieldDefinition +} + +// Message represents a decoded FIT message +type Message struct { + Num uint16 // Global message number + Fields map[uint8]interface{} // Field number -> value + DevFields map[uint8]interface{} // Developer field number -> value +} + +// NewMessage creates a new message +func NewMessage(num uint16) *Message { + return &Message{ + Num: num, + Fields: make(map[uint8]interface{}), + DevFields: make(map[uint8]interface{}), + } +} + +// GetFieldValue returns the value of a field by field number +func (m *Message) GetFieldValue(fieldNum uint8) (interface{}, bool) { + val, ok := m.Fields[fieldNum] + return val, ok +} + +// GetFieldValueUint8 returns a field value as uint8 +func (m *Message) GetFieldValueUint8(fieldNum uint8) (uint8, bool) { + if val, ok := m.Fields[fieldNum]; ok { + if v, ok := val.(uint8); ok { + return v, true + } + } + return 0, false +} + +// GetFieldValueUint16 returns a field value as uint16 +func (m *Message) GetFieldValueUint16(fieldNum uint8) (uint16, bool) { + if val, ok := m.Fields[fieldNum]; ok { + if v, ok := val.(uint16); ok { + return v, true + } + } + return 0, false +} + +// GetFieldValueUint32 returns a field value as uint32 +func (m *Message) GetFieldValueUint32(fieldNum uint8) (uint32, bool) { + if val, ok := m.Fields[fieldNum]; ok { + if v, ok := val.(uint32); ok { + return v, true + } + } + return 0, false +} + +// GetFieldValueString returns a field value as string +func (m *Message) GetFieldValueString(fieldNum uint8) (string, bool) { + if val, ok := m.Fields[fieldNum]; ok { + if v, ok := val.(string); ok { + return v, true + } + } + return "", false +} + +// IsLittleEndian returns true if the message definition uses little endian byte order +func (md *MessageDefinition) IsLittleEndian() bool { + return md.Architecture == 0 +} + +// DecodeField decodes a single field value from raw bytes +func DecodeField(data []byte, fieldDef FieldDefinition, littleEndian bool) (interface{}, error) { + if len(data) < int(fieldDef.Size) { + return nil, fmt.Errorf("insufficient data for field: need %d bytes, got %d", fieldDef.Size, len(data)) + } + + // Extract the base type number by masking (removes endian flag) + baseTypeNum := fieldDef.BaseType & BaseTypeNumMask + fieldData := data[:fieldDef.Size] + + switch baseTypeNum { + case 0x00, 0x02, 0x0A, 0x0D: // Enum (0x00), Uint8 (0x02), Uint8z (0x0A), Byte (0x0D) + if fieldDef.Size == 1 { + return uint8(fieldData[0]), nil + } + // Array of bytes + result := make([]uint8, fieldDef.Size) + copy(result, fieldData) + return result, nil + + case 0x01: // Sint8 + if fieldDef.Size == 1 { + return int8(fieldData[0]), nil + } + // Array of int8 + result := make([]int8, fieldDef.Size) + for i := range result { + result[i] = int8(fieldData[i]) + } + return result, nil + + case 0x04, 0x0B: // Uint16 (0x84 masked), Uint16z (0x8B masked) + if fieldDef.Size == 2 { + if littleEndian { + return binary.LittleEndian.Uint16(fieldData), nil + } + return binary.BigEndian.Uint16(fieldData), nil + } + // Array of uint16 + count := fieldDef.Size / 2 + result := make([]uint16, count) + for i := range result { + if littleEndian { + result[i] = binary.LittleEndian.Uint16(fieldData[i*2:]) + } else { + result[i] = binary.BigEndian.Uint16(fieldData[i*2:]) + } + } + return result, nil + + case 0x03: // Sint16 (0x83 masked) + if fieldDef.Size == 2 { + if littleEndian { + return int16(binary.LittleEndian.Uint16(fieldData)), nil + } + return int16(binary.BigEndian.Uint16(fieldData)), nil + } + // Array of int16 + count := fieldDef.Size / 2 + result := make([]int16, count) + for i := range result { + if littleEndian { + result[i] = int16(binary.LittleEndian.Uint16(fieldData[i*2:])) + } else { + result[i] = int16(binary.BigEndian.Uint16(fieldData[i*2:])) + } + } + return result, nil + + case 0x06, 0x0C: // Uint32 (0x86 masked), Uint32z (0x8C masked) + if fieldDef.Size == 4 { + if littleEndian { + return binary.LittleEndian.Uint32(fieldData), nil + } + return binary.BigEndian.Uint32(fieldData), nil + } + // Array of uint32 + count := fieldDef.Size / 4 + result := make([]uint32, count) + for i := range result { + if littleEndian { + result[i] = binary.LittleEndian.Uint32(fieldData[i*4:]) + } else { + result[i] = binary.BigEndian.Uint32(fieldData[i*4:]) + } + } + return result, nil + + case 0x05: // Sint32 (0x85 masked) + if fieldDef.Size == 4 { + if littleEndian { + return int32(binary.LittleEndian.Uint32(fieldData)), nil + } + return int32(binary.BigEndian.Uint32(fieldData)), nil + } + // Array of int32 + count := fieldDef.Size / 4 + result := make([]int32, count) + for i := range result { + if littleEndian { + result[i] = int32(binary.LittleEndian.Uint32(fieldData[i*4:])) + } else { + result[i] = int32(binary.BigEndian.Uint32(fieldData[i*4:])) + } + } + return result, nil + + case 0x08: // Float32 (0x88 masked) + if fieldDef.Size == 4 { + var bits uint32 + if littleEndian { + bits = binary.LittleEndian.Uint32(fieldData) + } else { + bits = binary.BigEndian.Uint32(fieldData) + } + return math.Float32frombits(bits), nil + } + // Array of float32 + count := fieldDef.Size / 4 + result := make([]float32, count) + for i := range result { + var bits uint32 + if littleEndian { + bits = binary.LittleEndian.Uint32(fieldData[i*4:]) + } else { + bits = binary.BigEndian.Uint32(fieldData[i*4:]) + } + result[i] = math.Float32frombits(bits) + } + return result, nil + + case 0x09: // Float64 (0x89 masked) + if fieldDef.Size == 8 { + var bits uint64 + if littleEndian { + bits = binary.LittleEndian.Uint64(fieldData) + } else { + bits = binary.BigEndian.Uint64(fieldData) + } + return math.Float64frombits(bits), nil + } + // Array of float64 + count := fieldDef.Size / 8 + result := make([]float64, count) + for i := range result { + var bits uint64 + if littleEndian { + bits = binary.LittleEndian.Uint64(fieldData[i*8:]) + } else { + bits = binary.BigEndian.Uint64(fieldData[i*8:]) + } + result[i] = math.Float64frombits(bits) + } + return result, nil + + case 0x0F, 0x10: // Uint64 (0x8F masked), Uint64z (0x90 masked) + if fieldDef.Size == 8 { + if littleEndian { + return binary.LittleEndian.Uint64(fieldData), nil + } + return binary.BigEndian.Uint64(fieldData), nil + } + // Array of uint64 + count := fieldDef.Size / 8 + result := make([]uint64, count) + for i := range result { + if littleEndian { + result[i] = binary.LittleEndian.Uint64(fieldData[i*8:]) + } else { + result[i] = binary.BigEndian.Uint64(fieldData[i*8:]) + } + } + return result, nil + + case 0x0E: // Sint64 (0x8E masked) + if fieldDef.Size == 8 { + if littleEndian { + return int64(binary.LittleEndian.Uint64(fieldData)), nil + } + return int64(binary.BigEndian.Uint64(fieldData)), nil + } + // Array of int64 + count := fieldDef.Size / 8 + result := make([]int64, count) + for i := range result { + if littleEndian { + result[i] = int64(binary.LittleEndian.Uint64(fieldData[i*8:])) + } else { + result[i] = int64(binary.BigEndian.Uint64(fieldData[i*8:])) + } + } + return result, nil + + case 0x07: // String + // String field - find null terminator + end := len(fieldData) + for i, b := range fieldData { + if b == 0 { + end = i + break + } + } + return string(fieldData[:end]), nil + + default: + return nil, fmt.Errorf("unsupported base type: 0x%02X (num: 0x%02X)", fieldDef.BaseType, baseTypeNum) + } +} diff --git a/fitparser/profile.go b/fitparser/profile.go new file mode 100644 index 0000000..03c2cb1 --- /dev/null +++ b/fitparser/profile.go @@ -0,0 +1,346 @@ +package fitparser + +// Common message numbers +const ( + MesgNumFileId = 0 + MesgNumCapabilities = 1 + MesgNumDeviceSettings = 2 + MesgNumUserProfile = 3 + MesgNumHrmProfile = 4 + MesgNumSdmProfile = 5 + MesgNumBikeProfile = 6 + MesgNumZonesTarget = 7 + MesgNumHrZone = 8 + MesgNumPowerZone = 9 + MesgNumMetZone = 10 + MesgNumSport = 12 + MesgNumGoal = 15 + MesgNumSession = 18 + MesgNumLap = 19 + MesgNumRecord = 20 + MesgNumEvent = 21 + MesgNumDeviceInfo = 23 + MesgNumWorkout = 26 + MesgNumWorkoutStep = 27 + MesgNumSchedule = 28 + MesgNumActivity = 34 + MesgNumSoftware = 35 + MesgNumFileCapabilities = 37 + MesgNumMesgCapabilities = 38 + MesgNumFieldCapabilities = 39 + MesgNumFileCreator = 49 + MesgNumBloodPressure = 51 + MesgNumSpeedZone = 53 + MesgNumMonitoring = 55 + MesgNumHrv = 78 + MesgNumLength = 101 + MesgNumMonitoringInfo = 103 + MesgNumPad = 105 + MesgNumSegmentLap = 142 +) + +// Common field numbers for FileId message +const ( + FieldFileIdType = 0 + FieldFileIdManufacturer = 1 + FieldFileIdProduct = 2 + FieldFileIdSerialNumber = 3 + FieldFileIdTimeCreated = 4 + FieldFileIdNumber = 5 +) + +// Common field numbers for Record message +const ( + FieldRecordTimestamp = 253 + FieldRecordPositionLat = 0 + FieldRecordPositionLong = 1 + FieldRecordAltitude = 2 + FieldRecordHeartRate = 3 + FieldRecordCadence = 4 + FieldRecordDistance = 5 + FieldRecordSpeed = 6 + FieldRecordPower = 7 + FieldRecordCompressedSpeedDistance = 8 + FieldRecordGrade = 9 + FieldRecordResistance = 10 + FieldRecordTimeFromCourse = 11 + FieldRecordCycleLength = 12 + FieldRecordTemperature = 13 +) + +// Common field numbers for Lap message +const ( + FieldLapTimestamp = 253 + FieldLapEvent = 0 + FieldLapEventType = 1 + FieldLapStartTime = 2 + FieldLapStartPositionLat = 3 + FieldLapStartPositionLong = 4 + FieldLapEndPositionLat = 5 + FieldLapEndPositionLong = 6 + FieldLapTotalElapsedTime = 7 + FieldLapTotalTimerTime = 8 + FieldLapTotalDistance = 9 + FieldLapTotalCycles = 10 + FieldLapTotalCalories = 11 + FieldLapTotalFatCalories = 12 + FieldLapAvgSpeed = 13 + FieldLapMaxSpeed = 14 + FieldLapAvgHeartRate = 15 + FieldLapMaxHeartRate = 16 + FieldLapAvgCadence = 17 + FieldLapMaxCadence = 18 + FieldLapAvgPower = 19 + FieldLapMaxPower = 20 +) + +// Common field numbers for Session message +const ( + FieldSessionTimestamp = 253 + FieldSessionEvent = 0 + FieldSessionEventType = 1 + FieldSessionStartTime = 2 + FieldSessionStartPositionLat = 3 + FieldSessionStartPositionLong = 4 + FieldSessionSport = 5 + FieldSessionSubSport = 6 + FieldSessionTotalElapsedTime = 7 + FieldSessionTotalTimerTime = 8 + FieldSessionTotalDistance = 9 + FieldSessionTotalCycles = 10 + FieldSessionTotalCalories = 11 + FieldSessionTotalFatCalories = 13 + FieldSessionAvgSpeed = 14 + FieldSessionMaxSpeed = 15 + FieldSessionAvgHeartRate = 16 + FieldSessionMaxHeartRate = 17 + FieldSessionAvgCadence = 18 + FieldSessionMaxCadence = 19 + FieldSessionAvgPower = 20 + FieldSessionMaxPower = 21 +) + +// Common field numbers for Activity message +const ( + FieldActivityTimestamp = 253 + FieldActivityTotalTimerTime = 0 + FieldActivityNumSessions = 1 + FieldActivityType = 2 + FieldActivityEvent = 3 + FieldActivityEventType = 4 + FieldActivityLocalTimestamp = 5 + FieldActivityEventGroup = 6 +) + +// File types +const ( + FileTypeDevice = 1 + FileTypeSettings = 2 + FileTypeSport = 3 + FileTypeActivity = 4 + FileTypeWorkout = 5 + FileTypeCourse = 6 + FileTypeSchedules = 7 + FileTypeWeight = 9 + FileTypeTotals = 10 + FileTypeGoals = 11 + FileTypeBloodPressure = 14 + FileTypeMonitoringA = 15 + FileTypeActivitySummary = 20 + FileTypeMonitoringDaily = 28 + FileTypeMonitoringB = 32 + FileTypeSegment = 34 + FileTypeSegmentList = 35 +) + +// Sport types +const ( + SportGeneric = 0 + SportRunning = 1 + SportCycling = 2 + SportTransition = 3 + SportFitnessEquipment = 4 + SportSwimming = 5 + SportBasketball = 6 + SportSoccer = 7 + SportTennis = 8 + SportAmericanFootball = 9 + SportTraining = 10 + SportWalking = 11 + SportCrossCountrySkiing = 12 + SportAlpineSkiing = 13 + SportSnowboarding = 14 + SportRowing = 15 + SportMountaineering = 16 + SportHiking = 17 + SportMultisport = 18 + SportPaddling = 19 +) + +// GetMessageName returns a human-readable name for a message number +func GetMessageName(mesgNum uint16) string { + switch mesgNum { + case MesgNumFileId: + return "FileId" + case MesgNumCapabilities: + return "Capabilities" + case MesgNumDeviceSettings: + return "DeviceSettings" + case MesgNumUserProfile: + return "UserProfile" + case MesgNumHrmProfile: + return "HrmProfile" + case MesgNumSdmProfile: + return "SdmProfile" + case MesgNumBikeProfile: + return "BikeProfile" + case MesgNumZonesTarget: + return "ZonesTarget" + case MesgNumHrZone: + return "HrZone" + case MesgNumPowerZone: + return "PowerZone" + case MesgNumMetZone: + return "MetZone" + case MesgNumSport: + return "Sport" + case MesgNumGoal: + return "Goal" + case MesgNumSession: + return "Session" + case MesgNumLap: + return "Lap" + case MesgNumRecord: + return "Record" + case MesgNumEvent: + return "Event" + case MesgNumDeviceInfo: + return "DeviceInfo" + case MesgNumActivity: + return "Activity" + case MesgNumSoftware: + return "Software" + case MesgNumFileCapabilities: + return "FileCapabilities" + case MesgNumMesgCapabilities: + return "MesgCapabilities" + case MesgNumFieldCapabilities: + return "FieldCapabilities" + case MesgNumFileCreator: + return "FileCreator" + case MesgNumBloodPressure: + return "BloodPressure" + case MesgNumSpeedZone: + return "SpeedZone" + case MesgNumMonitoring: + return "Monitoring" + case MesgNumHrv: + return "Hrv" + case MesgNumLength: + return "Length" + case MesgNumMonitoringInfo: + return "MonitoringInfo" + case MesgNumPad: + return "Pad" + case MesgNumSegmentLap: + return "SegmentLap" + default: + return "Unknown" + } +} + +// GetFileTypeName returns a human-readable name for a file type +func GetFileTypeName(fileType uint8) string { + switch fileType { + case FileTypeDevice: + return "Device" + case FileTypeSettings: + return "Settings" + case FileTypeSport: + return "Sport" + case FileTypeActivity: + return "Activity" + case FileTypeWorkout: + return "Workout" + case FileTypeCourse: + return "Course" + case FileTypeSchedules: + return "Schedules" + case FileTypeWeight: + return "Weight" + case FileTypeTotals: + return "Totals" + case FileTypeGoals: + return "Goals" + case FileTypeBloodPressure: + return "BloodPressure" + case FileTypeMonitoringA: + return "MonitoringA" + case FileTypeActivitySummary: + return "ActivitySummary" + case FileTypeMonitoringDaily: + return "MonitoringDaily" + case FileTypeMonitoringB: + return "MonitoringB" + case FileTypeSegment: + return "Segment" + case FileTypeSegmentList: + return "SegmentList" + default: + return "Unknown" + } +} + +// GetSportName returns a human-readable name for a sport type +func GetSportName(sport uint8) string { + switch sport { + case SportGeneric: + return "Generic" + case SportRunning: + return "Running" + case SportCycling: + return "Cycling" + case SportTransition: + return "Transition" + case SportFitnessEquipment: + return "FitnessEquipment" + case SportSwimming: + return "Swimming" + case SportBasketball: + return "Basketball" + case SportSoccer: + return "Soccer" + case SportTennis: + return "Tennis" + case SportAmericanFootball: + return "AmericanFootball" + case SportTraining: + return "Training" + case SportWalking: + return "Walking" + case SportCrossCountrySkiing: + return "CrossCountrySkiing" + case SportAlpineSkiing: + return "AlpineSkiing" + case SportSnowboarding: + return "Snowboarding" + case SportRowing: + return "Rowing" + case SportMountaineering: + return "Mountaineering" + case SportHiking: + return "Hiking" + case SportMultisport: + return "Multisport" + case SportPaddling: + return "Paddling" + default: + return "Unknown" + } +} + +// ConvertSemicirclesToDegrees converts a position in semicircles to degrees +// FIT files store lat/long as semicircles (2^31 semicircles = 180 degrees) +func ConvertSemicirclesToDegrees(semicircles int32) float64 { + return float64(semicircles) * (180.0 / 2147483648.0) +} diff --git a/fitparser/types.go b/fitparser/types.go new file mode 100644 index 0000000..a462e39 --- /dev/null +++ b/fitparser/types.go @@ -0,0 +1,182 @@ +// Package fitparser provides functionality to decode FIT (Flexible and Interoperable Data Transfer) files. +// FIT is a binary file format used by Garmin and other fitness device manufacturers. +package fitparser + +import "time" + +// Protocol and Profile versions +const ( + ProtocolVersionMajorShift = 4 + ProtocolVersionMajorMask = 0xF0 + ProtocolVersionMinorMask = 0x0F + ProtocolVersion10 = 0x10 + ProtocolVersion20 = 0x20 + ProfileVersionMajor = 21 + ProfileVersionMinor = 188 +) + +// Base type definitions +const ( + BaseTypeEnum = 0x00 + BaseTypeSint8 = 0x01 + BaseTypeUint8 = 0x02 + BaseTypeSint16 = 0x83 + BaseTypeUint16 = 0x84 + BaseTypeSint32 = 0x85 + BaseTypeUint32 = 0x86 + BaseTypeString = 0x07 + BaseTypeFloat32 = 0x88 + BaseTypeFloat64 = 0x89 + BaseTypeUint8z = 0x0A + BaseTypeUint16z = 0x8B + BaseTypeUint32z = 0x8C + BaseTypeByte = 0x0D + BaseTypeSint64 = 0x8E + BaseTypeUint64 = 0x8F + BaseTypeUint64z = 0x90 +) + +// Base type flags +const ( + BaseTypeEndianFlag = 0x80 + BaseTypeNumMask = 0x1F +) + +// Invalid values for each type +const ( + EnumInvalid = 0xFF + Sint8Invalid = 0x7F + Uint8Invalid = 0xFF + Sint16Invalid = 0x7FFF + Uint16Invalid = 0xFFFF + Sint32Invalid = 0x7FFFFFFF + Uint32Invalid = 0xFFFFFFFF + StringInvalid = 0x00 + Float32Invalid = 0xFFFFFFFF + Float64Invalid = 0xFFFFFFFFFFFFFFFF + Uint8zInvalid = 0x00 + Uint16zInvalid = 0x0000 + Uint32zInvalid = 0x00000000 + ByteInvalid = 0xFF + Sint64Invalid = 0x7FFFFFFFFFFFFFFF + Uint64Invalid = 0xFFFFFFFFFFFFFFFF + Uint64zInvalid = 0x0000000000000000 +) + +// Header sizes +const ( + HeaderWithCRCSize = 14 + HeaderWithoutCRCSize = 12 + HeaderMinSize = 12 + CRCSize = 2 +) + +// Record header bits +const ( + RecordHeaderMask = 0x80 + RecordHeaderNormal = 0x00 + RecordHeaderCompressedMask = 0x80 + RecordHeaderLocalMesgNumMask = 0x0F + RecordHeaderTimeMask = 0x1F +) + +// Message definition bits +const ( + MesgDefinitionMask = 0x40 + MesgHeaderMask = 0xF0 + LocalMesgNum = 0x0F + MesgDefinitionReserved = 0x00 + DevDataMask = 0x20 +) + +// FIT file signature +var FITSignature = [4]byte{'.', 'F', 'I', 'T'} + +// FIT Epoch - Number of seconds between Unix Epoch and FIT Epoch (Dec 31, 1989 00:00:00 UTC) +const FITEpochSeconds = 631065600 + +// ConvertFITTimestamp converts a FIT timestamp to a Go time.Time +func ConvertFITTimestamp(fitTime uint32) time.Time { + if fitTime == Uint32Invalid { + return time.Time{} + } + return time.Unix(int64(fitTime)+FITEpochSeconds, 0).UTC() +} + +// BaseTypeSize returns the size in bytes of the given base type +func BaseTypeSize(baseType byte) int { + switch baseType & BaseTypeNumMask { + case BaseTypeEnum, BaseTypeSint8, BaseTypeUint8, BaseTypeByte, BaseTypeUint8z: + return 1 + case BaseTypeSint16, BaseTypeUint16, BaseTypeUint16z: + return 2 + case BaseTypeSint32, BaseTypeUint32, BaseTypeUint32z, BaseTypeFloat32: + return 4 + case BaseTypeSint64, BaseTypeUint64, BaseTypeUint64z, BaseTypeFloat64: + return 8 + case BaseTypeString: + return 1 // Variable length, but each character is 1 byte + default: + return 0 + } +} + +// IsEndianType returns true if the base type requires endian conversion +func IsEndianType(baseType byte) bool { + return (baseType & BaseTypeEndianFlag) != 0 +} + +// IsInvalidValue checks if a value is invalid for its type +func IsInvalidValue(baseType byte, value interface{}) bool { + switch baseType & BaseTypeNumMask { + case BaseTypeEnum, BaseTypeUint8: + if v, ok := value.(uint8); ok { + return v == Uint8Invalid + } + case BaseTypeSint8: + if v, ok := value.(int8); ok { + return v == Sint8Invalid + } + case BaseTypeUint16: + if v, ok := value.(uint16); ok { + return v == Uint16Invalid + } + case BaseTypeSint16: + if v, ok := value.(int16); ok { + return v == Sint16Invalid + } + case BaseTypeUint32: + if v, ok := value.(uint32); ok { + return v == Uint32Invalid + } + case BaseTypeSint32: + if v, ok := value.(int32); ok { + return v == Sint32Invalid + } + case BaseTypeUint64: + if v, ok := value.(uint64); ok { + return v == Uint64Invalid + } + case BaseTypeSint64: + if v, ok := value.(int64); ok { + return v == Sint64Invalid + } + case BaseTypeUint8z: + if v, ok := value.(uint8); ok { + return v == Uint8zInvalid + } + case BaseTypeUint16z: + if v, ok := value.(uint16); ok { + return v == Uint16zInvalid + } + case BaseTypeUint32z: + if v, ok := value.(uint32); ok { + return v == Uint32zInvalid + } + case BaseTypeUint64z: + if v, ok := value.(uint64); ok { + return v == Uint64zInvalid + } + } + return false +}