fit-file-parser
Version:
Parse your .FIT files easily, directly from JS (Garmin, Polar, Suunto)
468 lines (467 loc) • 17.7 kB
JavaScript
import { Buffer } from 'buffer';
import { FIT } from './fit.js';
import { getFitMessage, getFitMessageBaseType } from './messages.js';
const CompressedLocalMsgNumMask = 0x60;
const CompressedHeaderMask = 0x80;
const GarminTimeOffset = 631065600000;
let monitoring_timestamp = 0;
export function addEndian(littleEndian, bytes) {
let result = 0;
if (!littleEndian)
bytes.reverse();
for (let i = 0; i < bytes.length; i++) {
result += (bytes[i] << (i << 3)) >>> 0;
}
return result;
}
function readData(blob, fDef, startIndex, options) {
if (fDef.type === 'uint8_array') {
const array8 = [];
for (let i = 0; i < fDef.size; i++) {
array8.push(blob[startIndex + i]);
}
return array8;
}
if (fDef.endianAbility) {
const temp = [];
for (let i = 0; i < fDef.size; i++) {
temp.push(blob[startIndex + i]);
}
const { buffer } = new Uint8Array(temp);
const dataView = new DataView(buffer);
try {
switch (fDef.type) {
case 'sint16':
return dataView.getInt16(0, fDef.littleEndian);
case 'uint16':
case 'uint16z':
return dataView.getUint16(0, fDef.littleEndian);
case 'sint32':
return dataView.getInt32(0, fDef.littleEndian);
case 'uint32':
case 'uint32z':
return dataView.getUint32(0, fDef.littleEndian);
case 'float32':
return dataView.getFloat32(0, fDef.littleEndian);
case 'float64':
return dataView.getFloat64(0, fDef.littleEndian);
case 'uint32_array': {
const array32 = [];
for (let i = 0; i < fDef.size; i += 4) {
array32.push(dataView.getUint32(i, fDef.littleEndian));
}
return array32;
}
case 'uint16_array': {
const array16 = [];
for (let i = 0; i < fDef.size; i += 2) {
array16.push(dataView.getUint16(i, fDef.littleEndian));
}
return array16;
}
}
}
catch (e) {
if (!options.force) {
throw e;
}
}
return addEndian(fDef.littleEndian, temp);
}
if (fDef.type === 'string') {
const temp = [];
for (let i = 0; i < fDef.size; i++) {
if (blob[startIndex + i]) {
temp.push(blob[startIndex + i]);
}
}
return Buffer.from(temp).toString('utf-8');
}
if (fDef.type === 'byte_array') {
const temp = [];
for (let i = 0; i < fDef.size; i++) {
temp.push(blob[startIndex + i]);
}
return temp;
}
if (fDef.type === 'sint8') {
const val = blob[startIndex];
return val > 127 ? val - 256 : val;
}
return blob[startIndex];
}
function formatByType(data, type, scale, offset) {
switch (type) {
case 'date_time':
case 'local_date_time':
return new Date(data * 1000 + GarminTimeOffset);
case 'sint32':
return data * FIT.scConst;
case 'uint8':
case 'sint16':
case 'uint32':
case 'uint16':
return scale ? data / scale + offset : data;
case 'uint32_array':
case 'uint16_array':
case 'uint8_array':
if (Array.isArray(data)) {
const baseType = type.replace('_array', '');
return data.map((dataItem) => {
if (isInvalidValue(dataItem, baseType)) {
return null;
}
return scale ? dataItem / scale + offset : dataItem;
});
}
return scale ? data / scale + offset : data;
default:
{
if (!FIT.types[type]) {
return data;
}
// Quick check for a mask
const values = [];
for (const key in FIT.types[type]) {
if (key in FIT.types[type]) {
values.push(String(FIT.types[type][key]));
}
}
if (!values.includes('mask')) {
const typeMap = FIT.types[type];
const mapped = typeMap[String(data)];
return mapped === undefined ? data : mapped;
}
const dataItem = {};
for (const key in FIT.types[type]) {
if (key in FIT.types[type]) {
if (FIT.types[type][key] === 'mask') {
dataItem.value = data & Number(key);
}
else {
dataItem[FIT.types[type][key]] = !!((data & Number(key)) >> 7); // Not sure if we need the >> 7 and casting to boolean but from all the masked props of fields so far this seems to be the case
}
}
}
return dataItem;
}
}
}
function isInvalidValue(data, type) {
switch (type) {
case 'enum':
return data === 0xFF;
case 'sint8':
return data === 0x7F;
case 'uint8':
return data === 0xFF;
case 'sint16':
return data === 0x7FFF;
case 'uint16':
return data === 0xFFFF;
case 'sint32':
return data === 0x7FFFFFFF;
case 'uint32':
return data === 0xFFFFFFFF;
case 'string':
return data === 0x00;
case 'float32':
return data === 0xFFFFFFFF;
case 'float64':
// eslint-disable-next-line no-loss-of-precision
return data === 0xFFFFFFFFFFFFFFFF;
case 'uint8z':
return data === 0x00;
case 'uint16z':
return data === 0x0000;
case 'uint32z':
return data === 0x000000;
case 'byte':
return data === 0xFF;
case 'sint64':
// eslint-disable-next-line no-loss-of-precision
return data === 0x7FFFFFFFFFFFFFFF;
case 'uint64':
// eslint-disable-next-line no-loss-of-precision
return data === 0xFFFFFFFFFFFFFFFF;
case 'uint64z':
return data === 0x0000000000000000;
default:
return false;
}
}
function isInvalidBaseTypeValue(data, baseTypeNo) {
if (Array.isArray(data)) {
return false;
}
const baseType = FIT.types.fit_base_type[baseTypeNo];
return typeof baseType === 'string' ? isInvalidValue(data, baseType) : false;
}
function convertTo(data, unitsList, unitName) {
const options = FIT.options[unitsList];
const unit = options[unitName];
return unit ? data * unit.multiplier + unit.offset : data;
}
function applyOptions(data, field, options, fields) {
switch (field) {
case 'device_type': {
const isLocal = fields.source_type === 'local' || fields.source_type === 5;
const isBLE = fields.source_type === 'bluetooth_low_energy' || fields.source_type === 3 || fields.source_type === 'bluetooth' || fields.source_type === 2;
const isANT = fields.source_type === 'antplus' || fields.source_type === 1 || fields.source_type === 'ant' || fields.source_type === 0;
if (isLocal) {
return FIT.types.local_device_type[data] || data;
}
if (isBLE) {
return FIT.types.ble_device_type[data] || data;
}
if (isANT) {
return FIT.types.antplus_device_type[data] || data;
}
return data;
}
case 'speed':
case 'enhanced_speed':
case 'vertical_speed':
case 'avg_speed':
case 'max_speed':
case 'speed_1s':
case 'ball_speed':
case 'enhanced_avg_speed':
case 'enhanced_max_speed':
case 'avg_pos_vertical_speed':
case 'max_pos_vertical_speed':
case 'avg_neg_vertical_speed':
case 'max_neg_vertical_speed':
return convertTo(data, 'speedUnits', options.speedUnit);
case 'distance':
case 'total_distance':
case 'enhanced_avg_altitude':
case 'enhanced_min_altitude':
case 'enhanced_max_altitude':
case 'enhanced_altitude':
case 'height':
case 'odometer':
case 'avg_stroke_distance':
case 'min_altitude':
case 'avg_altitude':
case 'max_altitude':
case 'total_ascent':
case 'total_descent':
case 'altitude':
case 'cycle_length':
case 'auto_wheelsize':
case 'custom_wheelsize':
case 'gps_accuracy':
return convertTo(data, 'lengthUnits', options.lengthUnit);
case 'temperature':
case 'avg_temperature':
case 'max_temperature':
return convertTo(data, 'temperatureUnits', options.temperatureUnit);
case 'pressure':
case 'start_pressure':
case 'end_pressure':
return convertTo(data, 'pressureUnits', options.pressureUnit);
case 'ant_id': {
const n1 = (data >>> 28) & 0xF;
const n2 = (data >>> 24) & 0xF;
const n3 = (data >>> 16) & 0xFF;
const n4 = data & 0xFFFF;
return `${n1.toString(16).toUpperCase()}-${n2.toString(16).toUpperCase()}-${n3.toString(16).toUpperCase().padStart(2, '0')}-${n4.toString(16).toUpperCase().padStart(4, '0')}`;
}
default:
return data;
}
}
export function readRecord(blob, messageTypes, developerFields, startIndex, options, startDate, pausedTime) {
var _a, _b;
const recordHeader = blob[startIndex];
let localMessageType = recordHeader & 15;
if ((recordHeader & CompressedHeaderMask) === CompressedHeaderMask) {
// compressed timestamp
localMessageType = (recordHeader & CompressedLocalMsgNumMask) >> 5;
}
else if ((recordHeader & 64) === 64) {
// is definition message
// startIndex + 1 is reserved
const hasDeveloperData = (recordHeader & 32) === 32;
const lEnd = blob[startIndex + 2] === 0;
const numberOfFields = blob[startIndex + 5];
const numberOfDeveloperDataFields = hasDeveloperData
? blob[startIndex + 5 + numberOfFields * 3 + 1]
: 0;
const mTypeDef = {
littleEndian: lEnd,
globalMessageNumber: addEndian(lEnd, [
blob[startIndex + 3],
blob[startIndex + 4],
]),
numberOfFields: numberOfFields + numberOfDeveloperDataFields,
fieldDefs: [],
};
const message = getFitMessage(mTypeDef.globalMessageNumber);
for (let i = 0; i < numberOfFields; i++) {
const fDefIndex = startIndex + 6 + i * 3;
const baseType = blob[fDefIndex + 2];
const { field, type } = message.getAttributes(blob[fDefIndex]);
const fDef = {
type,
fDefNo: blob[fDefIndex],
size: blob[fDefIndex + 1],
endianAbility: (baseType & 128) === 128,
littleEndian: lEnd,
baseTypeNo: baseType,
name: field,
dataType: getFitMessageBaseType(baseType & 15),
};
mTypeDef.fieldDefs.push(fDef);
}
// numberOfDeveloperDataFields = 0 so it wont crash here and wont loop
for (let i = 0; i < numberOfDeveloperDataFields; i++) {
// If we fail to parse then try catch
try {
const fDefIndex = startIndex + 6 + numberOfFields * 3 + 1 + i * 3;
const fieldNum = blob[fDefIndex];
const size = blob[fDefIndex + 1];
const devDataIndex = blob[fDefIndex + 2];
const devDef = developerFields[devDataIndex][fieldNum];
const baseType = devDef.fit_base_type_id;
const fDef = {
type: FIT.types.fit_base_type[baseType],
fDefNo: fieldNum,
size,
endianAbility: (baseType & 128) === 128,
littleEndian: lEnd,
baseTypeNo: baseType,
name: devDef.field_name,
dataType: getFitMessageBaseType(baseType & 15),
scale: devDef.scale || 1,
offset: devDef.offset || 0,
developerDataIndex: devDataIndex,
isDeveloperField: true,
};
mTypeDef.fieldDefs.push(fDef);
}
catch (e) {
if (options.force) {
continue;
}
throw e;
}
}
messageTypes[localMessageType] = mTypeDef;
const nextIndex = startIndex + 6 + mTypeDef.numberOfFields * 3;
const nextIndexWithDeveloperData = nextIndex + 1;
return {
messageType: 'definition',
nextIndex: hasDeveloperData ? nextIndexWithDeveloperData : nextIndex,
};
}
const messageType = messageTypes[localMessageType] || messageTypes[0];
// TODO: handle compressed header ((recordHeader & 128) == 128)
// uncompressed header
let messageSize = 0;
let readDataFromIndex = startIndex + 1;
const fields = {};
const message = getFitMessage(messageType.globalMessageNumber);
const rawFields = [];
for (let i = 0; i < messageType.fieldDefs.length; i++) {
const fDef = messageType.fieldDefs[i];
const data = readData(blob, fDef, readDataFromIndex, options);
if (!isInvalidValue(data, fDef.type) && !isInvalidBaseTypeValue(data, fDef.baseTypeNo)) {
rawFields.push({ fDef, data });
}
readDataFromIndex += fDef.size;
messageSize += fDef.size;
}
for (const { fDef, data } of rawFields) {
const { field } = fDef.isDeveloperField ? { field: fDef.name } : message.getAttributes(fDef.fDefNo);
if (field !== 'unknown' && field !== '' && field !== undefined) {
fields[field] = data;
}
}
for (const { fDef, data } of rawFields) {
if (fDef.isDeveloperField) {
const field = fDef.name;
const { type } = fDef;
const scale = (_a = fDef.scale) !== null && _a !== void 0 ? _a : null;
const offset = (_b = fDef.offset) !== null && _b !== void 0 ? _b : 0;
fields[fDef.name] = applyOptions(formatByType(data, type, scale, offset), field, options, fields);
}
else {
const { field, type, scale, offset } = message.getAttributes(fDef.fDefNo);
if (field !== 'unknown' && field !== '' && field !== undefined) {
fields[field] = applyOptions(formatByType(data, type, scale, offset), field, options, fields);
}
}
if (message.name === 'record' && options.elapsedRecordField) {
fields.elapsed_time = (fields.timestamp - (startDate || 0)) / 1000;
fields.timer_time = fields.elapsed_time - pausedTime;
}
}
if (message.name === 'field_description') {
developerFields[fields.developer_data_index]
= developerFields[fields.developer_data_index] || [];
developerFields[fields.developer_data_index][fields.field_definition_number] = fields;
}
if (message.name === 'monitoring') {
// TODO weirdly uses global variables?
// we need to keep the raw timestamp value so we can calculate subsequent timestamp16 fields
if (fields.timestamp) {
monitoring_timestamp = fields.timestamp;
fields.timestamp = new Date(fields.timestamp * 1000 + GarminTimeOffset);
}
if (fields.timestamp16 && !fields.timestamp) {
monitoring_timestamp
+= (fields.timestamp16 - (monitoring_timestamp & 0xFFFF)) & 0xFFFF;
// fields.timestamp = monitoring_timestamp;
fields.timestamp = new Date(monitoring_timestamp * 1000 + GarminTimeOffset);
}
}
return {
messageType: message.name,
nextIndex: startIndex + messageSize + 1,
message: fields,
};
}
export function getArrayBuffer(buffer) {
if (buffer instanceof ArrayBuffer) {
return buffer;
}
const ab = new ArrayBuffer(buffer.length);
const view = new Uint8Array(ab);
for (let i = 0; i < buffer.length; ++i) {
view[i] = buffer[i];
}
return ab;
}
export function calculateCRC(blob, start, end) {
const crcTable = [
0x0000,
0xCC01,
0xD801,
0x1400,
0xF001,
0x3C00,
0x2800,
0xE401,
0xA001,
0x6C00,
0x7800,
0xB401,
0x5000,
0x9C01,
0x8801,
0x4400,
];
let crc = 0;
for (let i = start; i < end; i++) {
const byteVal = blob[i];
let tmp = crcTable[crc & 0xF];
crc = (crc >> 4) & 0x0FFF;
crc = crc ^ tmp ^ crcTable[byteVal & 0xF];
tmp = crcTable[crc & 0xF];
crc = (crc >> 4) & 0x0FFF;
crc = crc ^ tmp ^ crcTable[(byteVal >> 4) & 0xF];
}
return crc;
}