Initial commit

Signed-off-by: Thomas Klaehn <thomas.klaehn@perinet.io>
This commit is contained in:
Thomas Klaehn
2026-02-08 07:28:32 +00:00
commit 2945d90d24
25 changed files with 3177 additions and 0 deletions

43
fitparser/crc.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}