FIT Files Structure

This document provides a comprehensive guide to the FIT (Flexible and Interoperable data Transfer) file structure generated by the ActivityWriter class in the UNA SDK. FIT is a binary file format developed by Garmin for storing fitness data, enabling interoperability between devices and applications. This guide is designed for new developers, covering everything from basic concepts to advanced implementation details, with a focus on practical code usage.

The UNA SDK leverages the FIT protocol to serialize activity data (running, cycling, hiking, heart rate monitoring) into .fit files. These files are compatible with tools like Garmin Connect, Strava, and third-party FIT parsers. The document emphasizes code examples from ActivityWriter.cpp implementations across different apps, helping developers understand how to create, modify, and extend FIT file generation.

How to Read This Document

For New Developers: Getting Started Guide

If you’re new to FIT files and the UNA SDK, follow this reading path to build your understanding progressively:

Phase 1: Foundations (Essential - Read First)

  1. Introduction to FIT - Understand what FIT is and why it’s used

  2. FIT File Format Basics - Learn binary format, data types, and scaling

  3. ActivityWriter Class Overview - Get familiar with the main class

  4. Data Structures in ActivityWriter - Understand the data you work with

Phase 2: Implementation (Core Development)

  1. SDK::Fit Encoder Deep Dive - Learn the encoder that does the work

  2. Step-by-Step FIT File Creation - Follow the file creation process

  3. Code Usage Examples and Walkthroughs - Study real code examples

Phase 3: Customization (When You Need to Modify)

  1. Activity-Specific Variations - See how different activities are implemented

  2. Developer Fields Implementation - Add custom data fields

  3. Extending ActivityWriter for New Activities - Create new activity types

Phase 4: Advanced Topics (As Needed)

  1. Advanced Topics and Best Practices - Performance and edge cases

  2. Troubleshooting Common Issues - Debug problems

  3. FIT File Parsing and Validation - Test and validate files

Key Sections for Different Tasks

Task

Primary Sections to Read

Why

Understanding FIT Basics

Introduction, Format Basics

Learn the protocol

Using Existing ActivityWriter

Class Overview, Data Structures, Code Examples

Integrate into your app

Adding Custom Fields

Developer Fields, Extending ActivityWriter

Customize data

Creating New Activity Types

Activity Variations, Extending ActivityWriter

Build new sports

Debugging FIT Issues

Troubleshooting, Validation

Fix problems

Performance Optimization

Advanced Topics, Best Practices

Improve efficiency

Essential Headers and Includes

When working with FIT files in UNA SDK, include these headers in your source files:

Core FIT Functionality

// Main ActivityWriter class
#include "ActivityWriter.hpp"

// Native SDK::Fit streaming encoder
#include "SDK/Fit/FitWriter.hpp"

// FIT profile constants (message/field numbers, enums)
#include "SDK/Fit/FitProfile.hpp"

// Kernel for file system access
#include "SDK/Kernel/Kernel.hpp"

// File system interface
#include "SDK/Interfaces/IFileSystem.hpp"

Optional SDK::Fit Helpers

// Base-type identifiers, sizes and invalid sentinels
#include "SDK/Fit/FitBaseType.hpp"

// FIT CRC-16 (used internally by FitWriter; rarely needed directly)
#include "SDK/Fit/FitCrc.hpp"

// Cadence / step_length record-field encoders (running/treadmill)
#include "SDK/Fit/FitRecordCadence.hpp"

Standard Library Headers

#include <cstdint>     // Fixed-width integer types
#include <cstdbool>   // Boolean type
#include <cstring>    // String manipulation
#include <cassert>    // Assertions
#include <memory>     // Smart pointers
#include <string>     // String class

Quick Start Checklist

Before starting development:

  • [ ] Read “Introduction to FIT” and “FIT File Format Basics”

  • [ ] Understand your activity type (see “Activity-Specific Variations”)

  • [ ] Review “Data Structures in ActivityWriter” for data formats

  • [ ] Look at code examples in existing apps (Running, Cycling, etc.)

When implementing:

  • [ ] Include the headers listed above

  • [ ] Follow the constructor pattern from examples

  • [ ] Use the method call sequence: start() → addRecord()* → addLap()* → stop()

  • [ ] Handle errors and validate data

For testing:

  • [ ] Use FIT SDK validator on generated files

  • [ ] Check with Garmin Connect or similar apps

  • [ ] Review logs for errors

🚀 Getting Started: Where to Find ActivityWriter

In just 1 minute, here’s how to get the ActivityWriter code:

Location in UNA SDK

The ActivityWriter class is located in the example apps:

Examples/Apps/[ActivityType]/Software/Libs/
├── Header/ActivityWriter.hpp      # Class declaration
└── Sources/ActivityWriter.cpp     # Implementation

Available Implementations

  • Running: Examples/Apps/Running/Software/Libs/

  • Cycling: Examples/Apps/Cycling/Software/Libs/

  • Hiking: Examples/Apps/Hiking/Software/Libs/

  • HRMonitor: Examples/Apps/HRMonitor/Software/Libs/

Quick Copy Steps

  1. Choose your activity type (Running, Cycling, Hiking, or HRMonitor)

  2. Navigate to: Examples/Apps/[YourChoice]/Software/Libs/

  3. Copy both files:

    • Header/ActivityWriter.hpp

    • Sources/ActivityWriter.cpp

  4. Paste into your app’s Software/Libs/ directory

  5. Include in your code: #include "ActivityWriter.hpp"

Basic Usage (3 lines of code)

// 1. Create instance
ActivityWriter writer(kernel, "/path/to/fit/files");

// 2. Start activity
AppInfo info = {timestamp, version, devID, appID};
writer.start(info);

// 3. Add data and stop
writer.addRecord(recordData);
writer.stop(trackData);

Need Help?

  • All implementations are nearly identical - start with Running as the base

  • Check existing app implementations for integration examples

  • See “Code Usage Examples” section for detailed walkthroughs

Pro Tip: The ActivityWriter is ready-to-use - just copy the files and call the methods!


Table of Contents

Introduction to FIT

What is FIT?

FIT (Flexible and Interoperable data Transfer) is a binary file format and protocol developed by Garmin for storing fitness and activity data. It’s designed to be compact, extensible, and platform-independent, allowing seamless data exchange between fitness devices, apps, and services like Garmin Connect, Strava, and TrainingPeaks.

Key characteristics:

  • Binary Format: Efficient storage with minimal overhead.

  • Self-Describing: Files include metadata about their structure.

  • Extensible: Supports standard fields and custom developer fields.

  • Versioned: Protocol and profile versions ensure compatibility.

In the UNA SDK, FIT files are used to export activity data from wearable devices, making it compatible with the broader fitness ecosystem.

Key Concepts for Beginners

Understanding FIT requires grasping a few core concepts:

  • Messages: The basic units of data in a FIT file. Each message represents a specific type of information, such as a GPS coordinate (Record message) or activity summary (Session message).

  • Definitions: Metadata that describes the structure of messages. Before writing data messages, you must write a definition message that specifies which fields are included and their data types.

  • Fields: Individual data elements within messages. For example, a Record message might include fields for timestamp, latitude, longitude, heart rate, etc.

  • Developer Fields: Custom fields not defined in the standard FIT profile. These allow apps to add proprietary data while maintaining FIT compatibility.

  • CRC (Cyclic Redundancy Check): A checksum appended to the file for data integrity verification.

  • Local Message Numbers: Identifiers (0-15) used within a FIT file to reference message types, allowing efficient compression.

FIT Protocol Versions

The UNA SDK uses specific FIT versions to ensure compatibility:

  • Protocol Version: 2.0 (SDK::Fit::kProtocolVersion20, the byte 0x20) - Defines the basic file structure and message encoding.

  • Profile Version: Passed to FitWriter::begin(profileVersion). The UNA apps pass 0 (profile-agnostic) because the native encoder supplies the message/field numbers itself; the value is only advisory metadata in the header.

These versions are set in the file header and ensure that FIT parsers can correctly interpret the file.

Why FIT in UNA SDK?

  • Interoperability: Export data to Garmin devices and third-party apps.

  • Standards Compliance: Follows established fitness data standards.

  • Extensibility: Add custom fields for UNA-specific features.

  • Efficiency: Binary format is ideal for resource-constrained embedded devices.

Prerequisites for Understanding

Before diving deeper, ensure you understand:

  • Basic C++ programming

  • File I/O operations

  • Bit manipulation and binary data handling

  • The UNA SDK architecture (Kernel, Interfaces, etc.)

FIT File Format Basics

File Structure Overview

A FIT file consists of three main parts:

  1. File Header (14 bytes): Contains metadata about the file.

  2. Data Records: A sequence of definition and data messages.

  3. CRC (2 bytes): Integrity check.

The header is written first, then data records are appended, and finally the CRC is calculated and written.

Byte Order and Endianness

FIT uses little-endian byte order for multi-byte values. When writing data, ensure proper byte ordering, especially on big-endian systems.

Message Encoding

Messages are encoded as:

  • Definition Messages: Describe message structure (field numbers, types, sizes).

  • Data Messages: Contain the actual data values.

Each message starts with a header byte indicating the message type and local message number.

Data Types in FIT

FIT defines several base types. The native encoder models them with the SDK::Fit::BaseType enum (SDK/Fit/FitBaseType.hpp); each value is the on-wire base-type identifier byte (bit 7 marks multi-byte / endian-sensitive types):

  • BaseType::UInt8 (1 byte)

  • BaseType::SInt8 (1 byte)

  • BaseType::UInt16 (2 bytes)

  • BaseType::SInt16 (2 bytes)

  • BaseType::UInt32 (4 bytes)

  • BaseType::SInt32 (4 bytes)

  • BaseType::String (variable)

  • BaseType::Float32 (4 bytes)

  • BaseType::Float64 (8 bytes)

  • BaseType::UInt8z (1 byte, unsigned with invalid value)

  • BaseType::UInt16z (2 bytes)

  • BaseType::UInt32z (4 bytes)

  • BaseType::Byte (1 byte array)

  • BaseType::Enum (1 byte enumeration)

  • BaseType::SInt64 / BaseType::UInt64 / BaseType::UInt64z (8 bytes)

Three free helpers operate on these: baseTypeId(t) (the identifier byte), baseTypeSize(t) (element size in bytes) and baseTypeInvalid(t) (the canonical “invalid” sentinel to write for an absent value).

Scaling and Units

Many FIT fields use scaled integers to represent floating-point values efficiently:

  • Speed: Stored as uint16 in mm/s (divide by 1000 for m/s)

  • Distance: Stored as uint32 in cm (divide by 100 for meters)

  • Altitude: Stored as uint32 in mm with offset (formula: (altitude + 500) * 5)

  • Time: Stored as uint32 in ms

Always refer to the FIT profile documentation for exact scaling factors.

ActivityWriter Class Overview

Class Purpose and Architecture

The ActivityWriter class is responsible for serializing activity data into FIT files. It encapsulates the complexity of FIT message creation, ensuring proper sequencing and data integrity.

Key responsibilities:

  • Own a single SDK::Fit::FitWriter encoder over the activity file

  • Create and manage FIT files

  • Emit message definitions and data records

  • Handle developer fields

  • Stream data to disk; back-patch the header and append the CRC on finish()

Constructor and Initialization

ActivityWriter::ActivityWriter(const SDK::Kernel& kernel, const char* pathToDir)
    : mKernel(kernel), mPath(pathToDir)
{
    assert(pathToDir != nullptr);
}
  • Takes a reference to the UNA Kernel for file system access

  • Stores the base path for FIT file storage

  • Does no FIT work itself: the encoder is profile-agnostic, so there are no per-message handler objects to set up. The FitWriter is constructed in start() once the file is open.

Instead of constructing many helper objects, the class declares a small set of local message types (FIT record-header values, 0-15) and the developer field numbers it uses, then writes the definitions in start():

// In ActivityWriter.hpp
enum Local : uint8_t {
    L_FILE_ID = 0, L_DEV_ID, L_FIELD_DESC, L_EVENT,
    L_RECORD, L_RECORD_G, L_RECORD_B, L_RECORD_GB,
    L_LAP, L_SESSION, L_ACTIVITY, L_WORKOUT, L_WORKOUT_STEP,
};

enum DevField : uint8_t {
    DF_BATTERY_LEVEL = 2, DF_BATTERY_VOLTAGE = 3,
    DF_HR_SOURCE = 4, DF_HR_OPTICAL = 5, DF_HR_EXTERNAL = 6,
};

std::unique_ptr<SDK::Interface::IFile> mFile;
std::unique_ptr<SDK::Fit::FitWriter>   mFit;   // constructed in start()

In start(), each message type is associated with a global message number and an ordered field list via defineMessage(...) – for example the Lap message:

mFit->defineMessage(L_LAP, fit::mesgNum(fit::MesgNum::Lap),
    {fit::field::Lap::Timestamp, fit::field::Lap::StartTime,
     fit::field::Lap::TotalElapsedTime, fit::field::Lap::TotalTimerTime,
     fit::field::Lap::TotalDistance, fit::field::Lap::MessageIndex,
     fit::field::Lap::AvgSpeed, fit::field::Lap::MaxSpeed,
     fit::field::Lap::TotalAscent, fit::field::Lap::TotalDescent,
     fit::field::Lap::AvgHeartRate, fit::field::Lap::MaxHeartRate,
     fit::field::Lap::WktStepIndex});

Public Interface Methods

  • start(const AppInfo&): Begins FIT file creation

  • addRecord(const RecordData&): Adds GPS/HR data points

  • addLap(const LapData&): Adds lap summaries

  • pause/resume(std::time_t): Handles activity pauses

  • stop(const TrackData&): Finalizes the FIT file

  • discard(): Aborts file creation

Data Structures in ActivityWriter

AppInfo Struct

struct AppInfo {
    std::time_t timestamp;  // UTC start time
    uint32_t appVersion;    // Version as 4-byte LE
    std::string devID;      // Developer ID (max 16 chars)
    std::string appID;      // Application ID (max 16 chars)
};

Used to populate File ID and Developer Data ID messages.

RecordData Struct

struct RecordData {
    enum class Field : uint8_t {
        COORDS = 1u << 0,   // lat/long valid
        SPEED = 1u << 1,
        ALTITUDE = 1u << 2,
        HEART_RATE = 1u << 3,
        BATTERY = 1u << 4,
    };

    // Methods to set/clear/check fields
    void set(Field f) { mFlags |= mask(f); }
    bool has(Field f) const { return (mFlags & mask(f)) != 0; }

    // Data members
    std::time_t timestamp;
    float latitude, longitude;
    float speed;  // m/s
    float altitude;  // m
    float heartRate;  // bpm
    uint8_t batteryLevel;  // %
    uint16_t batteryVoltage;  // mV

private:
    uint8_t mFlags = 0;
};

Uses bit flags for efficient field presence checking.

LapData and TrackData Structs

Similar structures for lap and session data, containing timing, distance, speed, and physiological metrics.

SDK::Fit Encoder Deep Dive

What is SDK::Fit?

SDK::Fit is the SDK’s native, dependency-free FIT-format encoder. It lives in Libs/Header/SDK/Fit/ and has two parts:

  • FitWriter (SDK/Fit/FitWriter.hpp) – the streaming engine. It writes the 14-byte header, a sequence of definition and data records keyed by local message type, and the trailing little-endian file CRC. The engine is profile-agnostic: the caller supplies global message numbers, field-definition numbers and base types, so one engine serves every message.

  • FitProfile (SDK/Fit/FitProfile.hpp) – the data dictionary. It maps message/field names to the public FIT interoperability constants (MesgNum, the field::<Message>::<Field> definitions, and the enum value sets such as Sport, Event, EventType).

Records are written as they are produced, so RAM use is constant and a partially recorded activity survives on disk if recording is interrupted.

FitWriter Lifecycle

A FitWriter wraps an already-open IFile and is driven through three phases:

SDK::Fit::FitWriter writer(file);   // file is an open IFile&

writer.begin(/*profileVersion=*/0); // 1. write header placeholder
// ... defineMessage(...) / data(...).write() ...
writer.finish();                    // 3. back-patch size + append CRC
  • begin(profileVersion, protocolVersion = kProtocolVersion20) writes the header placeholder (header CRC left 0x0000, data size back-patched later).

  • finish() back-patches the header data size, sets the header CRC, and appends the trailing file CRC (computed by reopening the file read-only).

  • ok() reports whether all IO and size checks have succeeded so far.

Defining Messages

Before any data record you must declare its layout. defineMessage associates a local type (0-15) with a global message number and an ordered field list, optionally followed by developer fields:

bool defineMessage(uint8_t localType, uint16_t globalMesgNum,
                   std::initializer_list<Field>    fields,
                   std::initializer_list<DevField> devFields = {});
  • Field is { uint8_t fieldDefNum; BaseType baseType; uint8_t count = 1; }. The field::* constants in FitProfile.hpp are ready-made Field values. For variable-length string/array fields the profile exposes the field number only (e.g. field::FieldDescription::kFieldNameNum) so the caller can size the field at encode time: {kFieldNameNum, BaseType::String, nameLen}.

  • DevField is { uint8_t fieldNum; uint8_t sizeBytes; uint8_t devDataIndex; }. Supplying any developer fields sets the developer-data flag in the record header.

A local type may be redefined later (e.g. the field_description slot is redefined for each developer field so the name/units strings are sized exactly).

Writing Data Records

data(localType) returns a FitWriter::Data builder. Chain typed appenders in definition order, then call write():

mFit->data(L_RECORD)
    .u32(unixToFitTimestamp(rec.timestamp))  // timestamp
    .u8(rec.heartRate)                        // heart_rate
    .u32(rec.steps)                           // steps (developer field)
    .write();

Available appenders: .u8/.i8/.u16/.i16/.u32/.i32/.u64/.i64/.f32/.f64, .str(s, fieldBytes) (null-padded to exactly fieldBytes), .bytes(p, n) (raw byte array), and .invalid(BaseType, count = 1) (the canonical sentinel for a declared-but-absent value). write() fails if the assembled payload size does not match the active definition for that local type, which catches field/value mismatches early.

For an absent scalar you can also write the sentinel explicitly, e.g. .u8(static_cast<uint8_t>(fit::baseTypeInvalid(fit::BaseType::UInt8))).

FitProfile Constants

FitProfile.hpp provides everything needed to address messages and fields:

  • enum class MesgNum and mesgNum(MesgNum) – global message numbers (FileId, Session, Lap, Record, Event, Workout, WorkoutStep, Activity, FieldDescription, DeveloperDataId).

  • namespace field::<Message> – per-field FitWriter::Field constants (e.g. field::Record::HeartRate, field::Session::Sport).

  • Enum value sets for enum fields: File, Sport, SubSport, Event, EventType, ActivityType, Intensity, Manufacturer, WktStepDuration, WktStepTarget, plus the kMessageIndexInvalid sentinel.

Only the messages/fields the UNA apps actually write are included; add new ones to FitProfile.hpp rather than hard-coding numbers at the call site.

Internal Mechanics

  • Tracks, per local type, the expected payload size and whether it is defined, validating each write() against it.

  • Writes all multi-byte values little-endian.

  • Computes the FIT CRC-16 (SDK/Fit/FitCrc.hpp) over the whole file in finish().

  • Holds only the current record’s payload in a small buffer – no whole-file buffering.

Step-by-Step FIT File Creation

1. Initialization Phase

ActivityWriter writer(kernel, "/path/to/fit/files");

2. Start Activity

AppInfo info = {timestamp, version, devID, appID};
writer.start(info);
  • Creates/opens the FIT file with a timestamp-based name

  • Constructs the FitWriter and calls begin() (writes the header placeholder)

  • Defines + writes the File ID message

  • Defines + writes the Developer Data ID message

  • Defines + writes the Field Description messages for developer fields

  • Defines all remaining message types (Event, Record variants, Lap, Session, Activity)

  • Writes the initial Event (START) data record

3. Record Data Points

RecordData record = {/* populate data */};
record.set(RecordData::Field::COORDS);
record.set(RecordData::Field::HEART_RATE);
// ...
writer.addRecord(record);
  • Selects the appropriate record local type based on available fields (GPS, battery, etc.)

  • Scales/converts the data and writes it field-by-field with data(local).uXX(...).write()

  • Appends any developer fields (battery, HR source) in definition order

4. Add Laps

LapData lap = {/* lap summary data */};
writer.addLap(lap);
  • Writes the Lap data record with scaled values via data(L_LAP)...write()

5. Handle Pauses/Resumes

writer.pause(timestamp);
// ... paused activity
writer.resume(timestamp);
  • Writes Event messages for STOP/START

6. Stop and Finalize

TrackData track = {/* final summary */};
writer.stop(track);
  • Writes the final Event (STOP) data record (where applicable)

  • Writes the Session message (with any developer fields)

  • Writes the Activity message

  • Calls mFit->finish(), which back-patches the header data size + CRC and appends the file CRC

  • Closes the file and saves a JSON summary file

File Naming Convention

Files are named: activity_YYYYMMDDTHHMMSS.fit

  • Based on activity start time in local timezone

  • Stored in year/month subdirectories: path/YYYY/MM/

Message Types and Fields in Detail

File ID Message

Purpose: Identifies the file and creator device/app.

Fields (field::FileId::*, MesgNum::FileId):

  • type: Sport/File::Activity (4)

  • manufacturer: Manufacturer::Development (255)

  • product: 0 (development product)

  • serial_number: 0

  • time_created: FIT timestamp of activity start

Developer Data ID Message

Purpose: Registers developer and app for custom fields.

Fields (field::DeveloperDataId::*, MesgNum::DeveloperDataId):

  • application_id: Application identifier byte array (16 bytes, from APP_ID)

  • developer_data_index: 0

Field Description Messages

Purpose: Define custom developer fields.

Common Structure (field::FieldDescription::*, MesgNum::FieldDescription):

  • developer_data_index: 0

  • field_definition_number: Unique field ID

  • fit_base_type_id: Base-type id, e.g. baseTypeId(BaseType::UInt8)

  • field_name (kFieldNameNum): Field name string (e.g., “batteryLevel”), sized at encode time

  • units (kUnitsNum): Units string (e.g., “%”, “mV”), sized at encode time

Record Messages

Purpose: Individual data points during activity.

Base Fields (field::Record::*, the common tail every variant shares):

  • timestamp: FIT timestamp

  • enhanced_altitude: Altitude, scale 5 + offset 500, m

  • enhanced_speed: Speed, scale 1000, m/s

  • heart_rate: BPM

  • cadence / fractional_cadence: rpm (encoded by FitRecordCadence)

  • step_length: scale 10, mm

Optional Fields (GPS variants):

  • position_lat: Latitude in semicircles

  • position_long: Longitude in semicircles

Developer Fields (conditional, attached to the chosen variant):

  • HR source / optical bpm / external bpm

  • Battery level (%) and voltage (mV) on the battery variants

Lap Messages

Purpose: Summarize data for each lap segment.

Fields (field::Lap::*, MesgNum::Lap):

  • message_index: Always 0

  • timestamp: Lap end time (FIT timestamp)

  • start_time: Lap start time (FIT timestamp)

  • total_elapsed_time: Time including pauses (ms)

  • total_timer_time: Active time (ms)

  • total_distance: Distance (cm)

  • avg_speed: Average speed (mm/s)

  • max_speed: Max speed (mm/s)

  • avg_heart_rate: Average HR (bpm)

  • max_heart_rate: Max HR (bpm)

  • total_ascent: Ascent (m)

  • total_descent: Descent (m)

Developer Fields: Defined per-app via defineMessage(...) developer fields (e.g. the Running writer attaches hr_source/hr_optical/hr_external, and battery level/voltage on the battery record variants).

Session Messages

Purpose: Overall activity summary.

Fields (field::Session::*, MesgNum::Session): Similar to Lap, plus:

  • sport: Activity type (e.g., Sport::Running)

  • sub_sport: Sub-type (e.g., SubSport::Generic)

  • num_laps: Number of laps

Event Messages

Purpose: Mark activity state changes.

Fields (field::Event::*, MesgNum::Event):

  • timestamp: Event time (FIT timestamp)

  • event: Event::Timer (0)

  • event_type: EventType::Start (0) or EventType::Stop (1)

Activity Messages

Purpose: Top-level activity metadata.

Fields:

  • timestamp: Activity end time (FIT timestamp)

  • local_timestamp: End time in local timezone (FIT timestamp)

  • total_timer_time: Total active time (ms)

  • num_sessions: Always 1

Message Types and Fields

File ID Message

  • Purpose: Identifies the file type and creator.

  • Fields (field::FileId::*):

    • type: File::Activity

    • manufacturer: Manufacturer::Development

    • product: 0

    • serial_number: 0

    • time_created: Unix timestamp converted to FIT time

Developer Data ID Message

  • Purpose: Registers the developer and app for custom fields.

  • Fields (field::DeveloperDataId::*):

    • application_id: 16-byte application identifier (from APP_ID)

    • developer_data_index: 0

Field Description Messages

  • Purpose: Describe custom developer fields.

  • Fields (per field, field::FieldDescription::*):

    • developer_data_index: 0

    • field_definition_number: Unique ID for the field

    • fit_base_type_id: Base-type id (e.g. baseTypeId(BaseType::UInt8))

    • field_name (kFieldNameNum): e.g., “batteryLevel”, “steps”

    • units (kUnitsNum): e.g., “%”, “mV”

Record Messages

  • Purpose: Individual data points during the activity (e.g., every second).

  • Base Fields (field::Record::*, the common tail):

    • timestamp: FIT timestamp

    • enhanced_altitude: Altitude, scale 5 + offset 500, m

    • enhanced_speed: Speed, scale 1000, m/s

    • heart_rate: BPM

    • cadence / fractional_cadence, step_length

  • Variants (selected by available data; one local type each):

    • Basic Record (L_RECORD): No GPS or battery

    • Record with GPS (L_RECORD_G): Adds position_lat, position_long (semicircles)

    • Record with Battery (L_RECORD_B): Adds developer fields for battery level and voltage

    • Combined (L_RECORD_GB): GPS + Battery

Lap Messages

  • Purpose: Summarize data for each lap.

  • Fields:

    • message_index: 0

    • timestamp: End time

    • start_time: Start time

    • total_elapsed_time: Time including pauses (ms)

    • total_timer_time: Active time (ms)

    • total_distance: Distance (cm)

    • avg_speed: Average speed (mm/s)

    • max_speed: Max speed (mm/s)

    • avg_heart_rate: Average HR

    • max_heart_rate: Max HR

    • total_ascent: Ascent (m)

    • total_descent: Descent (m)

  • Developer Fields: Defined per app via defineMessage(...) developer fields

Session Messages

  • Purpose: Overall activity summary.

  • Fields: Similar to Lap, but for the entire session.

  • Additional: sport (Sport::*), sub_sport (SubSport::*), num_laps

Event Messages

  • Purpose: Mark start/stop/pause/resume events.

  • Fields (field::Event::*):

    • timestamp: Event time

    • event: Event::Timer

    • event_type: EventType::Start or EventType::Stop

Activity Messages

  • Purpose: Top-level activity info.

  • Fields:

    • timestamp: Activity end time

    • local_timestamp: Local time

    • total_timer_time: Total active time (ms)

    • num_sessions: 1

Activity-Specific Variations

Running (Examples/Apps/Running/)

Sport Type: Sport::Running

Local Message Types (enum Local):

  • L_FILE_ID, L_DEV_ID, L_FIELD_DESC, L_EVENT, L_RECORD, L_RECORD_G, L_RECORD_B, L_RECORD_GB, L_LAP, L_SESSION, L_ACTIVITY, L_WORKOUT, L_WORKOUT_STEP

Record Variants (each a separate local type sharing the global Record number):

  • Basic (L_RECORD): timestamp, altitude, speed, HR, cadence, step_length

  • With GPS (L_RECORD_G): + lat/long

  • With Battery (L_RECORD_B): + battery level/voltage developer fields

  • Combined (L_RECORD_GB): GPS + Battery

Session Fields: Full set (distance, speed, elevation, HR)

Developer Fields (enum DevField):

  • DF_BATTERY_LEVEL (2, uint8, %), DF_BATTERY_VOLTAGE (3, uint16, mV)

  • DF_HR_SOURCE (4), DF_HR_OPTICAL (5), DF_HR_EXTERNAL (6) – all uint8

Code Example:

mFit->data(L_SESSION)
    // ... preceding fields ...
    .u8(static_cast<uint8_t>(fit::Sport::Running))
    .u8(static_cast<uint8_t>(fit::SubSport::Generic))
    // ...
    .write();

Cycling (Examples/Apps/Cycling/)

Sport Type: Sport::Cycling

Identical to Running except sport type. All record variants, battery fields, and session fields are the same.

Code Difference:

.u8(static_cast<uint8_t>(fit::Sport::Cycling))

Hiking (Examples/Apps/Hiking/)

Sport Type: Sport::Hiking

Structurally the same as Running – it shares the Record variants and the same developer-field approach. Any extra metrics are added as additional developer fields on the Lap/Session definitions (see “Developer Fields Implementation”), declared in the defineMessage(...) developer-field list and written in definition order at the tail of the data(...) builder.

.u8(static_cast<uint8_t>(fit::Sport::Hiking))

HRMonitor (Examples/Apps/HRMonitor/)

Sport Type: Sport::Generic

Simplified Structure:

  • Minimal record: only timestamp and heart_rate

  • No GPS, speed, altitude, distance fields

  • One custom developer field: hr_trust_level (DF_HR_TRUST_LEVEL, uint8, “percents”)

Record Definition (with the trust-level developer field):

mFit->defineMessage(L_RECORD, fit::mesgNum(fit::MesgNum::Record),
    {fit::field::Record::Timestamp, fit::field::Record::HeartRate},
    {{DF_HR_TRUST_LEVEL, 1, 0}});

Field Description + data write:

writeFieldDescription(DF_HR_TRUST_LEVEL, "hr_trust_level", "percents",
                      fit::BaseType::UInt8);

mFit->data(L_RECORD)
    .u32(unixToFitTimestamp(record.timestamp))
    .u8(record.heartRate)
    .u8(record.trustLevel)   // developer field hr_trust_level
    .write();

Session Fields: Reduced set, no distance/speed/elevation.

Developer Fields Implementation

Overview

Developer fields allow adding custom data to standard FIT messages while maintaining compatibility. With the native encoder the process involves:

  1. Developer Data ID: Register the developer/app (MesgNum::DeveloperDataId)

  2. Field Descriptions: Define each custom field (MesgNum::FieldDescription)

  3. Declare the developer fields: Pass a DevField list (field number, size in bytes, developer-data index) as the third argument to defineMessage(...) on the message that carries them. This sets the developer-data flag in that record’s header.

  4. Data Writing: Append the developer-field values, in declared order, at the tail of the same data(...) builder, after the standard fields.

The DevField is { uint8_t fieldNum; uint8_t sizeBytes; uint8_t devDataIndex; }.

Battery Fields (Running, Cycling, Hiking)

Field Descriptions (one redefine of the field_description slot per field):

writeFieldDescription(DF_BATTERY_LEVEL,   "batteryLevel", "%",  fit::BaseType::UInt8);
writeFieldDescription(DF_BATTERY_VOLTAGE, "battVoltage",  "mV", fit::BaseType::UInt16);

where writeFieldDescription redefines L_FIELD_DESC to size the name/units strings exactly, then writes the data record:

mFit->defineMessage(L_FIELD_DESC, fit::mesgNum(fit::MesgNum::FieldDescription),
    {fit::field::FieldDescription::DeveloperDataIndex,
     fit::field::FieldDescription::FieldDefinitionNumber,
     fit::field::FieldDescription::FitBaseTypeId,
     {fit::field::FieldDescription::kFieldNameNum, fit::BaseType::String, nameLen},
     {fit::field::FieldDescription::kUnitsNum,     fit::BaseType::String, unitsLen}});
mFit->data(L_FIELD_DESC)
    .u8(0)                              // developer_data_index
    .u8(devFieldNum)                    // field_definition_number
    .u8(fit::baseTypeId(baseType))      // fit_base_type_id
    .str(name, nameLen)
    .str(units, unitsLen)
    .write();

Declaration on the battery record variants (third defineMessage argument):

const DevFieldDef batt5[] = {
    {DF_BATTERY_LEVEL, 1, 0}, {DF_BATTERY_VOLTAGE, 2, 0},
    {DF_HR_SOURCE, 1, 0}, {DF_HR_OPTICAL, 1, 0}, {DF_HR_EXTERNAL, 1, 0},
};
mFit->defineMessage(L_RECORD_B, fit::mesgNum(fit::MesgNum::Record),
    { /* standard record fields */ }, {batt5[0], batt5[1], batt5[2], batt5[3], batt5[4]});

Writing Data (developer fields at the tail, in declared order):

const uint8_t local = batt ? (gps ? L_RECORD_GB : L_RECORD_B)
                           : (gps ? L_RECORD_G  : L_RECORD);
fit::FitWriter::Data d = mFit->data(local);
// ... standard record fields appended first ...
if (batt) {
    d.u8(record.batteryLevel).u16(record.batteryVoltage);
}
d.u8(record.hrSource).u8(record.hrOpticalBpm).u8(record.hrExternalBpm);
d.write();

Developer Fields on Lap / Session

Lap- and session-level custom metrics follow the same pattern: declare the DevField list on the L_LAP / L_SESSION definition, write a field description for each, then append the values at the tail of the corresponding data(L_LAP) / data(L_SESSION) builder before write().

Trust Level (HRMonitor)

Setup – a single uint8 developer field on the record:

writeFieldDescription(DF_HR_TRUST_LEVEL, "hr_trust_level", "percents",
                      fit::BaseType::UInt8);

mFit->defineMessage(L_RECORD, fit::mesgNum(fit::MesgNum::Record),
    {fit::field::Record::Timestamp, fit::field::Record::HeartRate},
    {{DF_HR_TRUST_LEVEL, 1, 0}});

mFit->data(L_RECORD)
    .u32(unixToFitTimestamp(record.timestamp))
    .u8(record.heartRate)
    .u8(record.trustLevel)   // developer field, written last
    .write();

Best Practices for Developer Fields

  • Use descriptive field names and units

  • Choose appropriate data types to minimize space

  • Document field meanings for parsers

  • Test with FIT validation tools

  • Consider backward compatibility when changing fields

Visual Representations and Diagrams

Complete FIT File Structure

FIT File Binary Layout:
┌─────────────────┐
│   File Header   │ 14 bytes
│                 │ - header_size: 14
│                 │ - protocol_version: 32 (2.0)
│                 │ - profile_version: XXX
│                 │ - data_size: XXX (updated later)
│                 │ - data_type: ".FIT"
│                 │ - crc: XXX (calculated later)
└─────────────────┘
┌─────────────────┐
│   Data Section  │ Variable size
│                 │
│ ┌─────────────┐ │
│ │ File ID Msg │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Dev Data ID │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Field Desc  │ │ (battery, hr_source, etc.)
│ │ Messages    │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Definitions │ │ (Record, Lap, Session, etc.)
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Event START │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Record 1    │ │
│ ├─────────────┤ │
│ │ Record 2    │ │
│ ├─────────────┤ │
│ │   ...       │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Lap 1       │ │
│ ├─────────────┤ │
│ │ Lap 2       │ │
│ ├─────────────┤ │
│ │   ...       │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Event STOP  │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Session     │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Activity    │ │
│ └─────────────┘ │
└─────────────────┘
┌─────────────────┐
│      CRC        │ 2 bytes
└─────────────────┘

Message Definition Structure

Each definition message contains:

Definition Message:
├── Header Byte (bit field)
│   ├── Local Message Number (0-15)
│   ├── Message Type (0 = Definition)
│   └── Reserved
├── Reserved Byte
├── Architecture (0 = little-endian)
├── Global Message Number (e.g., 20 for Record)
├── Number of Fields
└── Field Definitions (repeated)
    ├── Field Number
    ├── Size (bytes)
    ├── Base Type
    └── Reserved

Record Message Variants Tree

Record Messages  (each a distinct local type; same global Record number 20)
├── Basic Record (L_RECORD)
│   ├── timestamp (uint32)
│   ├── enhanced_altitude (uint32, scaled)
│   ├── enhanced_speed (uint32, scaled)
│   ├── heart_rate (uint8)
│   ├── cadence / fractional_cadence (uint8)
│   ├── step_length (uint16, scaled)
│   └── + dev fields: hr_source, hr_optical, hr_external (uint8)
├── Record + GPS (L_RECORD_G)
│   ├── (Basic fields)
│   └── position_lat / position_long (sint32, semicircles)
├── Record + Battery (L_RECORD_B)
│   ├── (Basic fields)
│   └── + dev fields: batteryLevel (uint8, #2), battVoltage (uint16, #3),
│                      hr_source/optical/external
└── Record + GPS + Battery (L_RECORD_GB)
    ├── (Basic + GPS fields)
    └── + battery + hr dev fields

ActivityWriter Method Flow

ActivityWriter Lifecycle:

Constructor
└── Store kernel + path (no FIT work yet)

start(AppInfo)
├── createAndOpenFile()
├── construct FitWriter over the open file
├── mFit->begin()  [header placeholder]
├── defineMessage + data().write() for File ID
├── defineMessage + data().write() for Developer Data ID
├── writeFieldDescription() for each developer field
├── defineMessage for Event / Record variants / Lap / Session / Activity
└── addMessageEvent(START)

addRecord(RecordData)
├── Select record local type (GPS/battery)
├── Append standard fields (scaled/converted) to data(local)
└── Append developer fields at the tail, then write()

addLap(LapData)
└── data(L_LAP) with scaled values, then write()

pause/resume(time_t)
└── addMessageEvent(STOP/START)

stop(TrackData)
├── data(L_SESSION).write()  (+ dev fields if any)
├── data(L_ACTIVITY).write()
├── mFit->finish()  [back-patch size + header CRC, append file CRC]
├── close file
└── saveSummary() - create JSON file

discard()
├── mFit.reset()
└── close + remove the file

Code Usage Examples and Walkthroughs

Constructor Deep Dive

The constructor is trivial: it stores the kernel reference and base path. All FIT work happens once the file is open, in start().

ActivityWriter::ActivityWriter(const SDK::Kernel& kernel, const char* pathToDir)
    : mKernel(kernel), mPath(pathToDir)
{
    assert(pathToDir != nullptr);
}

The message layout lives in two places instead: the Local / DevField enums in the header (see “Constructor and Initialization”), and the defineMessage(...) calls in start().

start() Method Walkthrough

namespace fit = SDK::Fit;

void ActivityWriter::start(const AppInfo& info)
{
    mLapCounter = 0;

    if (!createAndOpenFile(info.timestamp)) {
        return;
    }

    // 1. Construct the encoder over the open file and write the header placeholder.
    mFit = std::make_unique<fit::FitWriter>(*mFile);
    mFit->begin(/*profileVersion=*/0);

    // 2. file_id: define, then write one data record.
    mFit->defineMessage(L_FILE_ID, fit::mesgNum(fit::MesgNum::FileId),
        {fit::field::FileId::Type, fit::field::FileId::Manufacturer,
         fit::field::FileId::Product, fit::field::FileId::SerialNumber,
         fit::field::FileId::TimeCreated});
    mFit->data(L_FILE_ID)
        .u8(static_cast<uint8_t>(fit::File::Activity))
        .u16(static_cast<uint16_t>(fit::Manufacturer::Development))
        .u16(0)  // product
        .u32(0)  // serial_number
        .u32(unixToFitTimestamp(info.timestamp))
        .write();

    // 3. developer_data_id: registers the app for custom fields.
    mFit->defineMessage(L_DEV_ID, fit::mesgNum(fit::MesgNum::DeveloperDataId),
        {fit::field::DeveloperDataId::ApplicationId,
         fit::field::DeveloperDataId::DeveloperDataIndex});
    {
        uint8_t appId[16] = {};
        std::strncpy(reinterpret_cast<char*>(appId), info.appID.c_str(), sizeof(appId));
        mFit->data(L_DEV_ID).bytes(appId, sizeof(appId)).u8(0).write();
    }

    // 4. field_description for each developer field (label/units survive any profile).
    writeFieldDescription(DF_BATTERY_LEVEL,   "batteryLevel", "%",   fit::BaseType::UInt8);
    writeFieldDescription(DF_BATTERY_VOLTAGE, "battVoltage",  "mV",  fit::BaseType::UInt16);
    writeFieldDescription(DF_HR_SOURCE,       "hr_source",    nullptr, fit::BaseType::UInt8);
    writeFieldDescription(DF_HR_OPTICAL,      "hr_optical",   "bpm", fit::BaseType::UInt8);
    writeFieldDescription(DF_HR_EXTERNAL,     "hr_external",  "bpm", fit::BaseType::UInt8);

    // 5. Define the remaining message types up front.
    mFit->defineMessage(L_EVENT, fit::mesgNum(fit::MesgNum::Event),
        {fit::field::Event::Timestamp, fit::field::Event::EventField,
         fit::field::Event::EventType});
    defineRecordMessages();   // L_RECORD / _G / _B / _GB (see below)
    // ... L_LAP, L_SESSION, L_ACTIVITY defined here too ...

    // 6. Start the activity with an Event (START) data record.
    addMessageEvent(info.timestamp, fit::EventType::Start);
}

defineRecordMessages() - Record Variants

Each variant is a distinct local type that reuses the global Record message number; the differences are the field list and the attached developer fields.

void ActivityWriter::defineRecordMessages()
{
    const DevFieldDef hr3[] = {
        {DF_HR_SOURCE, 1, 0}, {DF_HR_OPTICAL, 1, 0}, {DF_HR_EXTERNAL, 1, 0},
    };
    const DevFieldDef batt5[] = {
        {DF_BATTERY_LEVEL, 1, 0}, {DF_BATTERY_VOLTAGE, 2, 0},
        {DF_HR_SOURCE, 1, 0}, {DF_HR_OPTICAL, 1, 0}, {DF_HR_EXTERNAL, 1, 0},
    };

    // Plain record (HR/cadence) + 3 HR developer fields.
    mFit->defineMessage(L_RECORD, fit::mesgNum(fit::MesgNum::Record),
        {fit::field::Record::Timestamp, fit::field::Record::EnhancedAltitude,
         fit::field::Record::EnhancedSpeed, fit::field::Record::HeartRate,
         fit::field::Record::Cadence, fit::field::Record::FractionalCadence,
         fit::field::Record::StepLength},
        {hr3[0], hr3[1], hr3[2]});

    // + GPS (adds position_lat/long), + battery (5 dev fields), + GPS + battery
    // are defined the same way with L_RECORD_G / L_RECORD_B / L_RECORD_GB.
}

addRecord() - Variant Selection and Data Write

void ActivityWriter::addRecord(const RecordData& record)
{
    if (!mFit) return;

    const bool gps  = record.has(RecordData::Field::COORDS);
    const bool batt = record.has(RecordData::Field::BATTERY);
    const uint8_t local = batt ? (gps ? L_RECORD_GB : L_RECORD_B)
                               : (gps ? L_RECORD_G  : L_RECORD);

    fit::FitWriter::Data d = mFit->data(local);

    d.u32(unixToFitTimestamp(record.timestamp));
    if (gps) {
        d.i32(degreesToSemicircles(record.latitude))
         .i32(degreesToSemicircles(record.longitude));
    }
    // enhanced_altitude (5 * m + 500), enhanced_speed (1000 * m/s); write the
    // canonical invalid sentinel when a field is absent.
    d.u32(record.has(RecordData::Field::ALTITUDE)
              ? static_cast<uint32_t>((record.altitude + 500.0f) * 5.0f)
              : static_cast<uint32_t>(fit::baseTypeInvalid(fit::BaseType::UInt32)));
    d.u32(record.has(RecordData::Field::SPEED)
              ? static_cast<uint32_t>(record.speed * 1000.0f)
              : static_cast<uint32_t>(fit::baseTypeInvalid(fit::BaseType::UInt32)));
    d.u8(record.has(RecordData::Field::HEART_RATE)
             ? static_cast<uint8_t>(record.heartRate)
             : static_cast<uint8_t>(fit::baseTypeInvalid(fit::BaseType::UInt8)));

    // cadence / fractional_cadence, step_length (see FitRecordCadence) ...

    // Developer fields, in definition order (battery first if present, then HR).
    if (batt) {
        d.u8(record.batteryLevel).u16(record.batteryVoltage);
    }
    d.u8(record.hrSource).u8(record.hrOpticalBpm).u8(record.hrExternalBpm);

    d.write();
}

stop() Method - Finalization

void ActivityWriter::stop(const TrackData& track)
{
    if (!mFit) return;

    // Session summary.
    mFit->data(L_SESSION)
        .u32(unixToFitTimestamp(track.timestamp))
        .u32(unixToFitTimestamp(track.timeStart))
        .u32(static_cast<uint32_t>(track.elapsed * 1000))   // total_elapsed_time
        .u32(static_cast<uint32_t>(track.duration * 1000))  // total_timer_time
        .u32(static_cast<uint32_t>(track.distance * 100))   // total_distance
        .u16(0)                                             // message_index
        .u16(static_cast<uint16_t>(track.speedAvg * 1000))
        .u16(static_cast<uint16_t>(track.speedMax * 1000))
        .u16(static_cast<uint16_t>(track.ascent))
        .u16(static_cast<uint16_t>(track.descent))
        .u16(mLapCounter)                                   // num_laps
        .u8(static_cast<uint8_t>(fit::Sport::Running))      // Activity-specific
        .u8(static_cast<uint8_t>(fit::SubSport::Generic))
        .u8(static_cast<uint8_t>(track.hrAvg))
        .u8(static_cast<uint8_t>(track.hrMax))
        .write();

    // Activity summary.
    mFit->data(L_ACTIVITY)
        .u32(unixToFitTimestamp(track.timestamp))
        .u32(static_cast<uint32_t>(track.duration * 1000))
        .u32(unixToFitTimestamp(epochToLocal(track.timestamp)))
        .u16(1)  // num_sessions
        .write();

    // Back-patch header data size + CRC and append the trailing file CRC.
    mFit->finish();
    mFit.reset();

    if (mFile) {
        mFile->flush();
        mFile->close();
    }

    saveSummary(track);  // create JSON summary file
}

Advanced Topics and Best Practices

FIT Timestamp Handling

FIT uses a different epoch than Unix. The FIT epoch starts on December 31, 1989, at 00:00:00 UTC.

Conversion Function:

uint32_t ActivityWriter::unixToFitTimestamp(std::time_t unixTimestamp)
{
    const std::time_t FIT_EPOCH_OFFSET = 631065600;  // 1989-12-31 00:00:00 UTC
    return static_cast<uint32_t>(unixTimestamp - FIT_EPOCH_OFFSET);
}

Key Points:

  • FIT timestamps are uint32, representing seconds since FIT epoch

  • Unix epoch is 1970-01-01, FIT epoch is 1989-12-31

  • Difference is exactly 631065600 seconds

  • Always use this conversion for timestamp fields

Data Scaling and Precision

FIT uses integer types for efficiency. Floating-point values are scaled to integers.

Scaling Formulas:

// Speed: float m/s -> uint32 mm/s (1000 * m/s + 0)
uint32_t scaled_speed = static_cast<uint32_t>(speed_mps * 1000);

// Distance: float m -> uint32 cm (100 * m + 0)
uint32_t scaled_distance = static_cast<uint32_t>(distance_m * 100);

// Altitude: float m -> uint32, scale 5 + offset 500: (m + 500) * 5
uint32_t scaled_altitude = static_cast<uint32_t>((altitude_m + 500) * 5);

// Time: float s -> uint32 ms (1000 * s + 0)
uint32_t scaled_time = static_cast<uint32_t>(time_s * 1000);

Reverse Scaling for Reading:

float speed_mps = scaled_speed / 1000.0f;
float distance_m = scaled_distance / 100.0f;
float altitude_m = (scaled_altitude / 5.0f) - 500.0f;
float time_s = scaled_time / 1000.0f;

GPS Coordinate Conversion

GPS coordinates are stored as semicircles (1/2^31 degrees) for precision.

Conversion Function:

int32_t ActivityWriter::degreesToSemicircles(float degrees)
{
    return static_cast<int32_t>(degrees * (2147483648.0 / 180.0));
}

Range and Precision:

  • Valid range: -180 to +180 degrees for longitude, -90 to +90 for latitude

  • 1 semicircle = 180 / 2^31 degrees ≈ 8.38e-8 degrees

  • Precision: ~1 cm at equator

CRC Calculation and Validation

FIT files include a 16-bit CRC for data integrity. You don’t write it yourselfFitWriter::finish() computes and appends it. Internally it uses the standard FIT nibble-table CRC-16 from SDK/Fit/FitCrc.hpp:

uint16_t fitCrcByte(uint16_t crc, uint8_t byte);          // fold one byte
uint16_t fitCrcUpdate(uint16_t crc, const void* d, size_t n);  // fold a buffer

In finish() the encoder reopens the file read-only, folds every byte through fitCrcUpdate (seeded with 0), and appends the resulting CRC little-endian as the final two bytes.

CRC Properties:

  • Calculated over the entire file (header + data), excluding the trailing CRC bytes

  • Standard FIT CRC-16

  • Ensures data integrity during transfer/storage

File Header Management

The 14-byte header is also managed by FitWriter, not by the app:

  • begin(profileVersion, protocolVersion) writes a header placeholder: the data size is left at 0 and the header CRC at 0x0000 (permitted by the spec).

  • finish() back-patches the header with the actual data size (file size minus the 14-byte header) and recomputes the header CRC.

This streaming approach keeps RAM use constant and means a partially recorded activity is still a valid prefix on disk if recording is interrupted before finish() runs (only the final size/CRC patch is missing).

Memory Management and Performance

FitWriter Memory Usage:

  • One small per-record payload buffer; no whole-file buffering

  • Records are streamed to the IFile as they are produced

  • Per local type, only an expected-size value and a “defined” flag are tracked

File I/O Optimization:

  • Messages written sequentially as definition/data records

  • The only re-read of the file is the single read-only CRC pass in finish()

Best Practices:

  • Construct one FitWriter per file, after the file is open

  • Define each local type once (redefine only for variable-size fields like strings)

  • Append fields in definition order; write() validates the payload size

  • Handle file errors gracefully (FitWriter::ok() reports IO failures)

Troubleshooting Common Issues

File Creation Failures

Symptom: createAndOpenFile() returns false

Possible Causes:

  • Invalid path or permissions

  • Insufficient storage space

  • Directory creation failure

Debug Steps:

// Check path validity
LOG_DEBUG("Path: %s", mPath);

// Verify directory creation
int len = snprintf(buff, sizeof(buff), "%s/%04u%02u/", mPath, year, month);
if (len <= 0 || !mKernel.fs.mkdir(buff)) {
    LOG_ERROR("Failed to create dir [%s]", buff);
    return false;
}

// Check file opening
mFile = mKernel.fs.file(buff);
if (!mFile || !mFile->open(true, true)) {
    LOG_ERROR("Failed to create file [%s]", buff);
    return false;
}

Invalid FIT Files

Symptom: FIT parsers reject the file

Common Issues:

  • Incorrect message sequencing

  • Missing definitions

  • Invalid field values

  • Wrong CRC

Validation Tools:

  • Use Garmin FIT SDK validator

  • Check with third-party FIT parsers

  • Verify message definitions match data

Data Scaling Errors

Symptom: Incorrect values in parsed files

Check Scaling:

// Verify scaling constants
assert(FIT_EPOCH_OFFSET == 631065600);

// Check conversions
float original_speed = 5.5f; // m/s
uint32_t scaled = original_speed * 1000; // 5500
float restored = scaled / 1000.0f; // 5.5
assert(abs(original_speed - restored) < 0.001f);

Developer Field Problems

Symptom: Custom fields not appearing in parsers

Verify Setup:

  • Developer Data ID written before field descriptions

  • Field definition numbers are unique

  • Base types match data types

  • Fields attached to correct message types

Payload / Definition Mismatch

Symptom: data(local).write() returns false; the field count or sizes in a data record do not match its definition.

Check:

  • Append values in the same order as the field list passed to defineMessage.

  • Use the typed appender that matches each field’s base type/size (.u8 for UInt8, .i32 for SInt32, .u16 for UInt16, etc.).

  • Append all declared developer fields too, at the tail, in declared order.

  • For string fields, .str(s, fieldBytes) must use the same fieldBytes the definition declared.

Memory Issues

Symptom: Crashes or undefined behavior

Check Bounds:

// Size fixed byte-array fields exactly; strncpy null-pads the buffer.
uint8_t appId[16] = {};
strncpy(reinterpret_cast<char*>(appId), info.appID.c_str(), sizeof(appId));
mFit->data(L_DEV_ID).bytes(appId, sizeof(appId)).u8(0).write();

Extending ActivityWriter for New Activities

Adding a New Sport Type

  1. Use the desired Sport enum value when writing the Session message. If the value you need isn’t yet in FitProfile.hpp, add it to enum class Sport / enum class SubSport there (use the public FIT profile number – don’t invent one):

    // In SDK/Fit/FitProfile.hpp
    enum class Sport : uint8_t { Generic = 0, Running = 1, Cycling = 2, Hiking = 17 };
    
  2. Write it in the Session data record:

    mFit->data(L_SESSION)
        // ... preceding fields ...
        .u8(static_cast<uint8_t>(fit::Sport::Hiking))
        .u8(static_cast<uint8_t>(fit::SubSport::Generic))
        // ...
        .write();
    

Adding New Developer Fields

  1. Assign a developer field number (in your app’s enum DevField):

    enum DevField : uint8_t { /* ... */ DF_NEW_FIELD = 7 };
    
  2. Write a field_description in start() (sizes name/units exactly):

    writeFieldDescription(DF_NEW_FIELD, "newField", "units", fit::BaseType::UInt16);
    
  3. Declare it on the carrying message in its defineMessage(...) developer-field list:

    mFit->defineMessage(L_RECORD, fit::mesgNum(fit::MesgNum::Record),
        { /* standard fields */ },
        {{DF_NEW_FIELD, fit::baseTypeSize(fit::BaseType::UInt16), 0}});
    
  4. Append the value at the tail of the matching data(...) builder, in declared order:

    mFit->data(L_RECORD)
        // ... standard fields ...
        .u16(calculateNewValue())
        .write();
    

Creating New Record Variants

  1. Add a local message type to your enum Local (must be 0-15):

    enum Local : uint8_t { /* ... */ L_RECORD_NEW = 14 };
    
  2. Define it in start() (reuse the global Record number, choose its fields):

    mFit->defineMessage(L_RECORD_NEW, fit::mesgNum(fit::MesgNum::Record),
        {fit::field::Record::Timestamp, fit::field::Record::HeartRate,
         /* add your fields */});
    
  3. Select and write it in addRecord():

    if (hasNewCondition()) {
        mFit->data(L_RECORD_NEW)
            .u32(unixToFitTimestamp(record.timestamp))
            .u8(static_cast<uint8_t>(record.heartRate))
            /* ... your fields, then any developer fields ... */
            .write();
    }
    

Best Practices for Extensions

  • Follow FIT profile guidelines

  • Use appropriate data types and scaling

  • Document custom fields clearly

  • Test with multiple FIT parsers

  • Maintain backward compatibility

  • Validate field ranges and units

FIT File Parsing and Validation

Using Garmin FIT SDK

The official FIT SDK provides parsing capabilities.

Basic Parsing Example:

#include "fit_decode.hpp"
#include "fit_mesg_listener.hpp"

class MyListener : public fit::MesgListener {
public:
    void OnMesg(fit::Mesg& mesg) override {
        if (mesg.GetNum() == FIT_MESG_NUM_RECORD) {
            // Handle record message
            FIT_UINT32 timestamp = mesg.GetFieldUINT32Value(253); // timestamp field
            // Process other fields...
        }
    }
};

fit::Decode decode;
MyListener listener;
decode.AddListener(listener);

std::ifstream file("activity.fit", std::ios::binary);
decode.Read(file);

Third-Party Libraries

  • Python: fitparse library

  • JavaScript: fit-file-parser

  • Java: FitSDK

Validation Tools

  • FIT SDK Validator: Command-line tool from Garmin

  • Online Validators: Various web-based FIT file checkers

  • Garmin Connect: Upload and check for errors

Common Parsing Errors

  • Invalid Header: Check protocol/profile versions

  • Missing Definitions: Ensure all messages have definitions

  • Field Mismatches: Verify field numbers and types

  • CRC Errors: File corrupted during transfer

Debugging FIT Files

  1. Use FIT SDK Dump Tool:

    fitdump activity.fit
    
  2. Check Message Sequence:

    • File ID first

    • Developer Data ID before field descriptions

    • Definitions before data messages

  3. Validate Field Values:

    • Check ranges (e.g., HR 0-255)

    • Verify scaling

    • Confirm timestamps are reasonable

References and Resources

Official Documentation

  • FIT SDK: https://developer.garmin.com/fit/

  • FIT Profile: https://developer.garmin.com/fit/overview/

  • FIT Protocol: https://developer.garmin.com/fit/protocol/

Key FIT Documents

  • FIT Protocol Description

  • FIT Profile XLS (spreadsheet of all message types and fields)

  • FIT SDK Examples

Community Resources

  • FIT File Tools: https://www.fitfiletools.com/

  • FIT Developer Forum: Garmin developer forums

  • Open Source Projects: Various FIT parsing libraries on GitHub

UNA SDK Specific

  • SDK Documentation: Docs/ directory

  • Example Apps: Examples/Apps/ (Running, Cycling, Hiking, HRMonitor, …)

  • Native FIT encoder: Libs/Header/SDK/Fit/ (FitWriter.hpp, FitProfile.hpp, FitBaseType.hpp, FitCrc.hpp, FitRecordCadence.hpp)

  • Worked tutorial: Docs/Tutorials/FitFiles/ (Service.cpp / Service.hpp)

Books and Tutorials

  • “FIT Protocol Guide” (Garmin documentation)

  • Online tutorials for custom FIT file creation

Version History

  • FIT Protocol 2.0: Current version used in UNA SDK

  • Profile Versions: Updated periodically with new fields/messages

This comprehensive guide should provide new developers with everything needed to understand, use, and extend the FIT file generation in the UNA SDK. Remember to always validate your FIT files with official tools before deployment.