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)
Introduction to FIT - Understand what FIT is and why it’s used
FIT File Format Basics - Learn binary format, data types, and scaling
ActivityWriter Class Overview - Get familiar with the main class
Data Structures in ActivityWriter - Understand the data you work with
Phase 2: Implementation (Core Development)
SDK::Fit Encoder Deep Dive - Learn the encoder that does the work
Step-by-Step FIT File Creation - Follow the file creation process
Code Usage Examples and Walkthroughs - Study real code examples
Phase 3: Customization (When You Need to Modify)
Activity-Specific Variations - See how different activities are implemented
Developer Fields Implementation - Add custom data fields
Extending ActivityWriter for New Activities - Create new activity types
Phase 4: Advanced Topics (As Needed)
Advanced Topics and Best Practices - Performance and edge cases
Troubleshooting Common Issues - Debug problems
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
UNA SDK Logger (Optional but Recommended)
#define LOG_MODULE_PRX "YourModule"
#define LOG_MODULE_LEVEL LOG_LEVEL_DEBUG
#include "SDK/UnaLogger/Logger.h"
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
Choose your activity type (Running, Cycling, Hiking, or HRMonitor)
Navigate to:
Examples/Apps/[YourChoice]/Software/Libs/Copy both files:
Header/ActivityWriter.hppSources/ActivityWriter.cpp
Paste into your app’s
Software/Libs/directoryInclude 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 byte0x20) - Defines the basic file structure and message encoding.Profile Version: Passed to
FitWriter::begin(profileVersion). The UNA apps pass0(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:
File Header (14 bytes): Contains metadata about the file.
Data Records: A sequence of definition and data messages.
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::FitWriterencoder over the activity fileCreate 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
FitWriteris constructed instart()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 creationaddRecord(const RecordData&): Adds GPS/HR data pointsaddLap(const LapData&): Adds lap summariespause/resume(std::time_t): Handles activity pausesstop(const TrackData&): Finalizes the FIT filediscard(): 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, thefield::<Message>::<Field>definitions, and the enum value sets such asSport,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 = {});
Fieldis{ uint8_t fieldDefNum; BaseType baseType; uint8_t count = 1; }. Thefield::*constants inFitProfile.hppare ready-madeFieldvalues. 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}.DevFieldis{ 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 MesgNumandmesgNum(MesgNum)– global message numbers (FileId, Session, Lap, Record, Event, Workout, WorkoutStep, Activity, FieldDescription, DeveloperDataId).namespace field::<Message>– per-fieldFitWriter::Fieldconstants (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 thekMessageIndexInvalidsentinel.
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 infinish().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
FitWriterand callsbegin()(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 CRCCloses 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: 0time_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, fromAPP_ID)developer_data_index: 0
Field Description Messages
Purpose: Define custom developer fields.
Common Structure (field::FieldDescription::*, MesgNum::FieldDescription):
developer_data_index: 0field_definition_number: Unique field IDfit_base_type_id: Base-type id, e.g.baseTypeId(BaseType::UInt8)field_name(kFieldNameNum): Field name string (e.g., “batteryLevel”), sized at encode timeunits(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 timestampenhanced_altitude: Altitude, scale 5 + offset 500, menhanced_speed: Speed, scale 1000, m/sheart_rate: BPMcadence/fractional_cadence: rpm (encoded byFitRecordCadence)step_length: scale 10, mm
Optional Fields (GPS variants):
position_lat: Latitude in semicirclesposition_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 0timestamp: 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) orEventType::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::Activitymanufacturer:Manufacturer::Developmentproduct: 0serial_number: 0time_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 (fromAPP_ID)developer_data_index: 0
Field Description Messages
Purpose: Describe custom developer fields.
Fields (per field,
field::FieldDescription::*):developer_data_index: 0field_definition_number: Unique ID for the fieldfit_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 timestampenhanced_altitude: Altitude, scale 5 + offset 500, menhanced_speed: Speed, scale 1000, m/sheart_rate: BPMcadence/fractional_cadence,step_length
Variants (selected by available data; one local type each):
Basic Record (
L_RECORD): No GPS or batteryRecord with GPS (
L_RECORD_G): Addsposition_lat,position_long(semicircles)Record with Battery (
L_RECORD_B): Adds developer fields for battery level and voltageCombined (
L_RECORD_GB): GPS + Battery
Lap Messages
Purpose: Summarize data for each lap.
Fields:
message_index: 0timestamp: End timestart_time: Start timetotal_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 HRmax_heart_rate: Max HRtotal_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 timeevent:Event::Timerevent_type:EventType::StartorEventType::Stop
Activity Messages
Purpose: Top-level activity info.
Fields:
timestamp: Activity end timelocal_timestamp: Local timetotal_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_lengthWith GPS (
L_RECORD_G): + lat/longWith Battery (
L_RECORD_B): + battery level/voltage developer fieldsCombined (
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
timestampandheart_rateNo 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:
Developer Data ID: Register the developer/app (
MesgNum::DeveloperDataId)Field Descriptions: Define each custom field (
MesgNum::FieldDescription)Declare the developer fields: Pass a
DevFieldlist (field number, size in bytes, developer-data index) as the third argument todefineMessage(...)on the message that carries them. This sets the developer-data flag in that record’s header.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
yourself – FitWriter::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 at0x0000(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
IFileas they are producedPer 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
FitWriterper file, after the file is openDefine each local type once (redefine only for variable-size fields like strings)
Append fields in definition order;
write()validates the payload sizeHandle 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 (
.u8forUInt8,.i32forSInt32,.u16forUInt16, etc.).Append all declared developer fields too, at the tail, in declared order.
For string fields,
.str(s, fieldBytes)must use the samefieldBytesthe 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
Use the desired
Sportenum value when writing the Session message. If the value you need isn’t yet inFitProfile.hpp, add it toenum class Sport/enum class SubSportthere (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 };
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
Assign a developer field number (in your app’s
enum DevField):enum DevField : uint8_t { /* ... */ DF_NEW_FIELD = 7 };
Write a field_description in
start()(sizes name/units exactly):writeFieldDescription(DF_NEW_FIELD, "newField", "units", fit::BaseType::UInt16);
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}});
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
Add a local message type to your
enum Local(must be 0-15):enum Local : uint8_t { /* ... */ L_RECORD_NEW = 14 };
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 */});
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:
fitparselibraryJavaScript:
fit-file-parserJava:
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
Use FIT SDK Dump Tool:
fitdump activity.fitCheck Message Sequence:
File ID first
Developer Data ID before field descriptions
Definitions before data messages
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/directoryExample 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.