43
fitparser/crc.go
Normal file
43
fitparser/crc.go
Normal file
@@ -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
|
||||
}
|
||||
332
fitparser/decoder.go
Normal file
332
fitparser/decoder.go
Normal file
@@ -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
|
||||
}
|
||||
246
fitparser/decoder_test.go
Normal file
246
fitparser/decoder_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
fitparser/header.go
Normal file
110
fitparser/header.go
Normal file
@@ -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)
|
||||
}
|
||||
308
fitparser/message.go
Normal file
308
fitparser/message.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
346
fitparser/profile.go
Normal file
346
fitparser/profile.go
Normal file
@@ -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)
|
||||
}
|
||||
182
fitparser/types.go
Normal file
182
fitparser/types.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user