Switch to better FIT parser (#884)

* initial commit

* Fix undefined Buffer for clientside upload

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2026-03-20 19:04:50 +01:00
committed by GitHub
parent 369e71fcec
commit 04818b9329
11 changed files with 10413 additions and 2892 deletions

View File

@@ -1,21 +1,20 @@
import GPX from "$lib/models/gpx/gpx";
import { Trail } from "$lib/models/trail";
import { gpx, kml, tcx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import { kml, tcx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import cryptoRandomString from "crypto-random-string";
//@ts-ignore
import { browser } from "$app/environment";
import Track from "$lib/models/gpx/track";
import TrackSegment from "$lib/models/gpx/track-segment";
import GPXWaypoint from "$lib/models/gpx/waypoint";
import EasyFit from "$lib/vendor/easy-fit/easy-fit";
import type { Feature, FeatureCollection, GeoJSON, GeoJsonProperties, Position } from 'geojson';
import { Waypoint } from "$lib/models/waypoint";
import { trails_show } from "$lib/stores/trail_store";
import FitParser from "$lib/vendor/fit-parser/fit_parser";
import { DOMParser as XMLDOMParser } from "@xmldom/xmldom";
import type { Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson';
import JSZip from "jszip";
import type { AuthRecord } from "pocketbase";
import { DOMParser as XMLDOMParser } from "@xmldom/xmldom";
import { bbox, splitMultiLineStringToLineStrings } from "./geojson_util";
import { trails_show } from "$lib/stores/trail_store";
import { handleFromRecordWithIRI } from "./activitypub_util";
import { Waypoint } from "$lib/models/waypoint";
export async function gpx2trail(gpxString: string, fallbackName?: string, correctElevation: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
@@ -192,8 +191,8 @@ export function fromTCX(tcxData: string) {
}
export async function fromFIT(fitData: ArrayBuffer) {
const easyFit = new EasyFit();
return new Promise<string>((resolve, reject) => easyFit.parse(fitData, function (error, data) {
const fitParser = new FitParser();
return new Promise<string>((resolve, reject) => fitParser.parse(fitData, function (error, data) {
if (error) {
console.error(error);
@@ -204,13 +203,13 @@ export async function fromFIT(fitData: ArrayBuffer) {
metadata: {
name: "",
desc: "",
time: data.activity?.timestamp ?? new Date(),
time: data?.activity?.timestamp ?? new Date(),
keywords: "wanderer"
},
trk: [
new Track({
trkseg: new TrackSegment({
trkpt: data.records.flatMap(((d: { position_lat: any; position_long: any; timestamp: any; altitude: any; enhanced_altitude: any }) => {
trkpt: data?.records?.flatMap(((d) => {
return (d.position_lat && d.position_long) ? new GPXWaypoint({
$: { lat: d.position_lat, lon: d.position_long },
time: d.timestamp,

View File

@@ -1,269 +0,0 @@
import { FIT } from "./fit";
import { getFitMessage, getFitMessageBaseType } from "./messages";
export { addEndian, readRecord, getArrayBuffer, calculateCRC };
function addEndian(littleEndian: boolean, bytes: any[]) {
var result = 0;
if (!littleEndian) bytes.reverse();
for (var i = 0; i < bytes.length; i++) {
result += bytes[i] << (i << 3) >>> 0;
}
return result;
}
function readData(blob: Uint8Array, fDef: { endianAbility: boolean; size: number; littleEndian: boolean; type: string; }, startIndex: number) {
if (fDef.endianAbility === true) {
var temp = [];
for (var i = 0; i < fDef.size; i++) {
temp.push(blob[startIndex + i]);
}
var uint32Rep = addEndian(fDef.littleEndian, temp);
if (fDef.type === 'sint32') {
return uint32Rep >> 0;
}
return uint32Rep;
}
return blob[startIndex];
}
function formatByType(data: number, type: string | number, scale: number, offset: number) {
switch (type) {
case 'date_time':
return new Date(data * 1000 + 631065600000);
case 'sint32':
case 'sint16':
return data * FIT.scConst;
case 'uint32':
case 'uint16':
return scale ? data / scale + offset : data;
default: {
const types = FIT.types as Record<string, Record<string, any>>;
if (types[type]) {
return types[type][String(data)];
}
return data;
}
}
}
function isInvalidValue(data: number, type: string) {
switch (type) {
case 'enum':
return data === 0xFF;
case 'sint8':
return data === 0x7F;
case 'uint8':
return data === 0xFF;
case 'sint16':
return data === 0x7FFF;
case 'unit16':
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':
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':
return data === 0x7FFFFFFFFFFFFFFF;
case 'uint64':
return data === 0xFFFFFFFFFFFFFFFF;
case 'uint64z':
return data === 0x0000000000000000;
default:
return false;
}
}
function convertTo(data: number, unitsList: string, speedUnit: string | number) {
const options = FIT.options as Record<string, Record<string, { multiplier: number; offset: number }>>;
var unitObj = options[unitsList]?.[String(speedUnit)];
return unitObj ? data * unitObj.multiplier + unitObj.offset : data;
}
function applyOptions(data: number, field: any, options: { speedUnit?: string | number; lengthUnit?: string | number; temperatureUnit?: string | number; }) {
switch (field) {
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 ?? 0);
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 ?? 0);
case 'temperature':
case 'avg_temperature':
case 'max_temperature':
return convertTo(data, 'temperatureUnits', options.temperatureUnit ?? 0);
default:
return data;
}
}
function readRecord(blob: Uint8Array, messageTypes: Record<number, any>, startIndex: number, options: { elapsedRecordField?: any; speedUnit?: string | number; lengthUnit?: string | number; temperatureUnit?: string | number; }, startDate: number | undefined) {
var recordHeader = blob[startIndex];
var localMessageType = recordHeader & 15;
if ((recordHeader & 64) === 64) {
// is definition message
// startIndex + 1 is reserved
var lEnd = blob[startIndex + 2] === 0;
var mTypeDef: { littleEndian: boolean, globalMessageNumber: number, numberOfFields: number, fieldDefs: any[] } = {
littleEndian: lEnd,
globalMessageNumber: addEndian(lEnd, [blob[startIndex + 3], blob[startIndex + 4]]),
numberOfFields: blob[startIndex + 5],
fieldDefs: []
};
var _message = getFitMessage(mTypeDef.globalMessageNumber);
for (var i = 0; i < mTypeDef.numberOfFields; i++) {
var fDefIndex = startIndex + 6 + i * 3;
var baseType = blob[fDefIndex + 2];
var _message$getAttribute = _message.getAttributes(blob[fDefIndex]),
field = _message$getAttribute.field,
type = _message$getAttribute.type;
var fDef = {
type: type,
fDefNo: blob[fDefIndex],
size: blob[fDefIndex + 1],
endianAbility: (baseType & 128) === 128,
littleEndian: lEnd,
baseTypeNo: baseType & 15,
name: field,
dataType: getFitMessageBaseType(baseType & 15)
};
mTypeDef.fieldDefs.push(fDef);
}
messageTypes[localMessageType] = mTypeDef;
return {
messageType: 'fieldDescription',
nextIndex: startIndex + 6 + mTypeDef.numberOfFields * 3
};
}
var messageType: { littleEndian: boolean, globalMessageNumber: number, numberOfFields: number, fieldDefs: any[] } | undefined = void 0;
if (messageTypes[localMessageType]) {
messageType = messageTypes[localMessageType];
} else {
messageType = messageTypes[0];
}
// TODO: handle compressed header ((recordHeader & 128) == 128)
// uncompressed header
var messageSize = 0;
var readDataFromIndex = startIndex + 1;
var fields: any = {};
var message = getFitMessage(messageType?.globalMessageNumber);
for (var _i = 0; _i < (messageType?.fieldDefs.length ?? 0); _i++) {
var _fDef = messageType?.fieldDefs[_i];
var data = readData(blob, _fDef, readDataFromIndex);
if (!isInvalidValue(data, _fDef.type)) {
var _message$getAttribute2 = message.getAttributes(_fDef.fDefNo),
field = _message$getAttribute2.field,
type = _message$getAttribute2.type,
scale = _message$getAttribute2.scale,
offset = _message$getAttribute2.offset;
if (field !== 'unknown' && field !== '' && field !== undefined) {
fields[field] = applyOptions(formatByType(data, type, scale, offset), field, options);
}
if (message.name === 'record' && options.elapsedRecordField) {
fields.elapsed_time = (fields.timestamp - (startDate ?? 0)) / 1000;
}
}
readDataFromIndex += _fDef.size;
messageSize += _fDef.size;
}
var result = {
messageType: message.name,
nextIndex: startIndex + messageSize + 1,
message: fields
};
return result;
}
function getArrayBuffer(buffer: ArrayBuffer | number[]) {
if (buffer instanceof ArrayBuffer) {
return buffer;
}
var ab = new ArrayBuffer(buffer.length);
var view = new Uint8Array(ab);
for (var i = 0; i < buffer.length; ++i) {
view[i] = buffer[i];
}
return ab;
}
function calculateCRC(blob: Uint8Array, start: number, end: number) {
var crcTable = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400];
var crc = 0;
for (var i = start; i < end; i++) {
var byte = blob[i];
var tmp = crcTable[crc & 0xF];
crc = crc >> 4 & 0x0FFF;
crc = crc ^ tmp ^ crcTable[byte & 0xF];
tmp = crcTable[crc & 0xF];
crc = crc >> 4 & 0x0FFF;
crc = crc ^ tmp ^ crcTable[byte >> 4 & 0xF];
}
return crc;
}

View File

@@ -1,156 +0,0 @@
import { calculateCRC, getArrayBuffer, readRecord } from "./binary";
type Options = {
force: boolean,
speedUnit: 'm/s' | 'km/h' | 'mph',
lengthUnit: 'm' | 'km' | 'mi',
temperatureUnit: 'celsius' | 'fahrenheit',
elapsedRecordField: boolean,
mode: 'cascade' | 'list' | 'both'
}
class EasyFit {
options: Options;
constructor(
options?: Options
) {
this.options = options || {
force: true,
speedUnit: 'm/s',
lengthUnit: 'm',
temperatureUnit: 'celsius',
elapsedRecordField: false,
mode: 'list'
};
}
parse(content: ArrayBuffer, callback: { (error: string, data: any): void; (arg0: string | null, arg1: {}): void; }) {
var blob = new Uint8Array(getArrayBuffer(content));
if (blob.length < 12) {
callback('File to small to be a FIT file', {});
if (!this.options.force) {
return;
}
}
var headerLength = blob[0];
if (headerLength !== 14 && headerLength !== 12) {
callback('Incorrect header size', {});
if (!this.options.force) {
return;
}
}
var fileTypeString = '';
for (var i = 8; i < 12; i++) {
fileTypeString += String.fromCharCode(blob[i]);
}
if (fileTypeString !== '.FIT') {
callback('Missing \'.FIT\' in header', {});
if (!this.options.force) {
return;
}
}
if (headerLength === 14) {
var crcHeader = blob[12] + (blob[13] << 8);
var crcHeaderCalc = calculateCRC(blob, 0, 12);
if (crcHeader !== crcHeaderCalc) {
// callback('Header CRC mismatch', {});
// TODO: fix Header CRC check
if (!this.options.force) {
return;
}
}
}
var dataLength = blob[4] + (blob[5] << 8) + (blob[6] << 16) + (blob[7] << 24);
var crcStart = dataLength + headerLength;
var crcFile = blob[crcStart] + (blob[crcStart + 1] << 8);
var crcFileCalc = calculateCRC(blob, headerLength === 12 ? 0 : headerLength, crcStart);
if (crcFile !== crcFileCalc) {
// callback('File CRC mismatch', {});
// TODO: fix File CRC check
if (!this.options.force) {
return;
}
}
var fitObj: Record<string, any> = {};
var sessions = [];
var laps = [];
var records = [];
var events = [];
var tempLaps = [];
var tempRecords = [];
var loopIndex = headerLength;
var messageTypes: Record<number, any> = [];
var isModeCascade = this.options.mode === 'cascade';
var isCascadeNeeded = isModeCascade || this.options.mode === 'both';
var startDate = void 0;
while (loopIndex < crcStart) {
var _readRecord = readRecord(blob, messageTypes, loopIndex, this.options, startDate),
nextIndex = _readRecord.nextIndex,
messageType = _readRecord.messageType,
message = "message" in _readRecord ? _readRecord.message : undefined;
loopIndex = nextIndex;
switch (messageType) {
case 'lap':
if (isCascadeNeeded) {
message.records = tempRecords;
tempRecords = [];
tempLaps.push(message);
}
laps.push(message);
break;
case 'session':
if (isCascadeNeeded) {
message.laps = tempLaps;
tempLaps = [];
}
sessions.push(message);
break;
case 'event':
events.push(message);
break;
case 'record':
if (!startDate) {
startDate = message.timestamp;
message.elapsed_time = 0;
}
records.push(message);
if (isCascadeNeeded) {
tempRecords.push(message);
}
break;
default:
if (messageType !== '') {
fitObj[messageType] = message;
}
break;
}
}
if (isCascadeNeeded) {
fitObj.activity.sessions = sessions;
fitObj.activity.events = events;
}
if (!isModeCascade) {
fitObj.sessions = sessions;
fitObj.laps = laps;
fitObj.records = records;
fitObj.events = events;
}
callback(null, fitObj);
}
}
export default EasyFit

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
export { getFitMessage, getFitMessageBaseType }
import { getFieldObject, getMessageName } from "./fit"
function getFitMessage(messageNum: any) {
return {
name: getMessageName(messageNum),
getAttributes: function getAttributes(fieldNum: any) {
return getFieldObject(fieldNum, messageNum);
}
};
}
function getFitMessageBaseType(foo: any) {
return foo;
}

564
web/src/lib/vendor/fit-parser/binary.ts vendored Normal file
View File

@@ -0,0 +1,564 @@
import type { FitParserOptions } from './fit_parser.js'
import type {
FieldDefinition,
} from './fit.js'
import type { FitOptions, LengthUnits, MesgNum, PressureUnits, SpeedUnits, TemperatureUnits, Unit } from './fit_types.js'
import { FIT } from './fit.js'
import { getFitMessage, getFitMessageBaseType } from './messages.js'
const CompressedLocalMsgNumMask = 0x60 as const
const CompressedHeaderMask = 0x80 as const
const GarminTimeOffset = 631065600000 as const
let monitoring_timestamp = 0
export function addEndian(littleEndian: boolean, bytes: number[]): number {
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: Uint8Array,
fDef: FieldDefinition,
startIndex: number,
options: FitParserOptions,
): any {
if (fDef.type === 'uint8_array') {
const array8: number[] = []
for (let i = 0; i < fDef.size; i++) {
array8.push(blob[startIndex + i])
}
return array8
}
if (fDef.endianAbility) {
const temp: number[] = []
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: number[] = []
for (let i = 0; i < fDef.size; i += 4) {
array32.push(dataView.getUint32(i, fDef.littleEndian))
}
return array32
}
case 'uint16_array': {
const array16: number[] = []
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: number[] = []
for (let i = 0; i < fDef.size; i++) {
if (blob[startIndex + i]) {
temp.push(blob[startIndex + i])
}
}
return new TextDecoder().decode(new Uint8Array(temp));
}
if (fDef.type === 'byte_array') {
const temp: number[] = []
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: any,
type: string | number,
scale: number | null,
offset: number,
): any {
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: number) => {
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: string[] = []
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] as Record<string, any>
const mapped = typeMap[String(data)]
return mapped === undefined ? data : mapped
}
const dataItem: any = {}
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: any, type: string): boolean {
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 convertTo<T extends string>(
data: number,
unitsList: keyof FitOptions,
unitName: T,
): number {
const options = FIT.options[unitsList] as Unit<T>
const unit = options[unitName]
return unit ? data * unit.multiplier + unit.offset : data
}
function applyOptions(data: any, field: string, options: any, fields: any): any {
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<SpeedUnits>(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<LengthUnits>(data, 'lengthUnits', options.lengthUnit)
case 'temperature':
case 'avg_temperature':
case 'max_temperature':
return convertTo<TemperatureUnits>(data, 'temperatureUnits', options.temperatureUnit)
case 'pressure':
case 'start_pressure':
case 'end_pressure':
return convertTo<PressureUnits>(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: Uint8Array,
messageTypes: any[],
developerFields: any[],
startIndex: number,
options: FitParserOptions,
startDate: number | undefined,
pausedTime: number,
): {
messageType: MesgNum | ''
nextIndex: number
message?: any
} {
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: any = {
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: FieldDefinition = {
type,
fDefNo: blob[fDefIndex],
size: blob[fDefIndex + 1],
endianAbility: (baseType & 128) === 128,
littleEndian: lEnd,
baseTypeNo: baseType & 15,
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: FieldDefinition = {
type: FIT.types.fit_base_type[baseType],
fDefNo: fieldNum,
size,
endianAbility: (baseType & 128) === 128,
littleEndian: lEnd,
baseTypeNo: baseType & 15,
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: any = {}
const message = getFitMessage(messageType.globalMessageNumber)
const rawFields: { fDef: FieldDefinition, data: any }[] = []
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)) {
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 } = fDef
const { offset } = fDef
fields[fDef.name] = applyOptions(
formatByType(data, type, scale ?? null, offset ?? 0),
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 as any) - (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: ArrayBuffer | Buffer): ArrayBuffer {
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: Uint8Array,
start: number,
end: number,
): number {
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
}

8340
web/src/lib/vendor/fit-parser/fit.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
import type { Buffer } from 'buffer'
import type {
ParsedActivity,
ParsedActivityMetrics,
ParsedCoursePoint,
ParsedDeveloperDataId,
ParsedDeviceInfo,
ParsedDiveGas,
ParsedEvent,
ParsedFieldDescription,
ParsedFileId,
ParsedFit,
ParsedHrv,
ParsedHrZone,
ParsedJump,
ParsedLap,
ParsedLength,
ParsedMonitoring,
ParsedMonitoringInfo,
ParsedPowerZone,
ParsedRecord,
ParsedSession,
ParsedSport,
ParsedStressLevel,
ParsedTankSummary,
ParsedTankUpdate,
ParsedTimeInZone,
} from './fit_types'
import { calculateCRC, getArrayBuffer, readRecord } from './binary.js'
import { mapDataIntoLap, mapDataIntoSession } from './helper'
export interface FitParserOptions {
force?: boolean
speedUnit?: string
lengthUnit?: string
temperatureUnit?: string
elapsedRecordField?: boolean
pressureUnit?: string
mode?: 'list' | 'cascade' | 'both'
}
type FitParserCallback = (error: string | undefined, data: ParsedFit | undefined) => void
export default class FitParser {
private readonly options: FitParserOptions
constructor(options: FitParserOptions = {}) {
this.options = {
force: options.force != null ? options.force : true,
speedUnit: options.speedUnit || 'm/s',
lengthUnit: options.lengthUnit || 'm',
temperatureUnit: options.temperatureUnit || 'celsius',
elapsedRecordField: options.elapsedRecordField || false,
pressureUnit: options.pressureUnit || 'bar',
mode: options.mode || 'list',
}
}
parseAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit> {
return new Promise<ParsedFit>((resolve, reject) => {
this.parse(content, (error, data) => {
if (error) {
reject(error)
}
else if (data) {
resolve(data)
}
})
})
}
parse(content: ArrayBuffer | Buffer<ArrayBuffer>, callback: FitParserCallback): void {
const blob = new Uint8Array(getArrayBuffer(content))
if (blob.length < 12) {
callback('File to small to be a FIT file', undefined)
if (!this.options.force) {
return
}
}
const headerLength: number = blob[0]
if (headerLength !== 14 && headerLength !== 12) {
callback('Incorrect header size', undefined)
if (!this.options.force) {
return
}
}
let fileTypeString = ''
for (let i = 8; i < 12; i++) {
fileTypeString += String.fromCharCode(blob[i])
}
if (fileTypeString !== '.FIT') {
callback('Missing \'.FIT\' in header', undefined)
if (!this.options.force) {
return
}
}
if (headerLength === 14) {
const crcHeader = blob[12] + (blob[13] << 8)
const crcHeaderCalc = calculateCRC(blob, 0, 12)
if (crcHeader !== crcHeaderCalc) {
// callback('Header CRC mismatch', {});
// TODO: fix Header CRC check
if (!this.options.force) {
return
}
}
}
const protocolVersion: number = blob[1]
const profileVersion: number = blob[2] + (blob[3] << 8)
const dataLength: number
= blob[4] + (blob[5] << 8) + (blob[6] << 16) + (blob[7] << 24)
const crcStart = dataLength + headerLength
const crcFile = blob[crcStart] + (blob[crcStart + 1] << 8)
const crcFileCalc = calculateCRC(
blob,
headerLength === 12 ? 0 : headerLength,
crcStart,
)
if (crcFile !== crcFileCalc) {
// callback('File CRC mismatch', {});
// TODO: fix File CRC check
if (!this.options.force) {
return
}
}
const fitObj: Partial<ParsedFit> = {
profileVersion,
protocolVersion,
}
let sessions: ParsedSession[] = []
let laps: ParsedLap[] = []
const records: ParsedRecord[] = []
const events: ParsedEvent[] = []
const hr_zone: ParsedHrZone[] = []
const power_zone: ParsedPowerZone[] = []
const hrv: ParsedHrv[] = []
const device_infos: ParsedDeviceInfo[] = []
const applications: ParsedDeveloperDataId[] = []
const fieldDescriptions: ParsedFieldDescription[] = []
const dive_gases: ParsedDiveGas[] = []
const course_points: ParsedCoursePoint[] = []
const sports: ParsedSport[] = []
const monitors: ParsedMonitoring[] = []
const stress: ParsedStressLevel[] = []
const definitions: unknown[] = []
const file_ids: ParsedFileId[] = []
const monitor_info: ParsedMonitoringInfo[] = []
const lengths: ParsedLength[] = []
const tank_updates: ParsedTankUpdate[] = []
const tank_summaries: ParsedTankSummary[] = []
const jumps: ParsedJump[] = []
const time_in_zone: ParsedTimeInZone[] = []
const activity_metrics: ParsedActivityMetrics[] = []
let loopIndex = headerLength
const messageTypes: any[] = []
const developerFields: any[] = []
const isModeCascade = this.options.mode === 'cascade'
const isCascadeNeeded = isModeCascade || this.options.mode === 'both'
let startDate: number | undefined
let lastStopTimestamp: number | undefined
let pausedTime = 0
while (loopIndex < crcStart) {
const { nextIndex, messageType, message } = readRecord(
blob,
messageTypes,
developerFields,
loopIndex,
this.options,
startDate,
pausedTime,
)
loopIndex = nextIndex
switch (messageType) {
case 'lap':
laps.push(message)
break
case 'session':
sessions.push(message)
break
case 'event':
if (message.event === 'timer') {
if (message.event_type === 'stop_all') {
lastStopTimestamp = message.timestamp
}
else if (message.event_type === 'start' && lastStopTimestamp) {
pausedTime += (message.timestamp - lastStopTimestamp) / 1000
}
}
events.push(message)
break
case 'length':
lengths.push(message)
break
case 'hrv':
hrv.push(message)
break
case 'hr_zone':
hr_zone.push(message)
break
case 'power_zone':
power_zone.push(message)
break
case 'record':
if (!startDate) {
startDate = message.timestamp
message.elapsed_time = 0
message.timer_time = 0
}
records.push(message)
break
case 'field_description':
fieldDescriptions.push(message)
break
case 'device_info':
device_infos.push(message)
break
case 'developer_data_id':
applications.push(message)
break
case 'dive_gas':
dive_gases.push(message)
break
case 'course_point':
course_points.push(message)
break
case 'sport':
sports.push(message)
break
case 'file_id':
if (message) {
file_ids.push(message)
}
break
case 'definition':
if (message) {
definitions.push(message)
}
break
case 'monitoring':
monitors.push(message)
break
case 'monitoring_info':
monitor_info.push(message)
break
case 'stress_level':
stress.push(message)
break
case 'software':
fitObj.software = message
break
case 'tank_update':
tank_updates.push(message)
break
case 'tank_summary':
tank_summaries.push(message)
break
case 'jump':
jumps.push(message)
break
case 'time_in_zone':
time_in_zone.push(message)
break
case 'activity_metrics':
activity_metrics.push(message)
break
default:
if (messageType !== '') {
fitObj[messageType as keyof ParsedFit] = message
}
break
}
}
fitObj.hr_zone = hr_zone
fitObj.power_zone = power_zone
fitObj.dive_gases = dive_gases
fitObj.course_points = course_points
fitObj.sports = sports
fitObj.monitors = monitors
fitObj.stress = stress
fitObj.file_ids = file_ids
fitObj.monitor_info = monitor_info
fitObj.definitions = definitions
fitObj.tank_updates = tank_updates
fitObj.tank_summaries = tank_summaries
fitObj.jumps = jumps
fitObj.time_in_zone = time_in_zone
fitObj.activity_metrics = activity_metrics
if (isCascadeNeeded) {
laps = mapDataIntoLap(laps, 'records', records)
laps = mapDataIntoLap(laps, 'lengths', lengths)
sessions = mapDataIntoSession(sessions, laps)
fitObj.activity = {
...(fitObj.activity ?? {} as ParsedActivity), // ugly but we assume the activity was parsed correctly with all other members correctly
sessions,
events,
hrv,
device_infos,
developer_data_ids: applications,
field_descriptions: fieldDescriptions,
sports,
}
}
if (!isModeCascade) {
fitObj.sessions = sessions
fitObj.laps = laps
fitObj.lengths = lengths
fitObj.records = records
fitObj.events = events
fitObj.device_infos = device_infos
fitObj.developer_data_ids = applications
fitObj.field_descriptions = fieldDescriptions
fitObj.hrv = hrv
}
callback(undefined, fitObj as ParsedFit)
}
}

1059
web/src/lib/vendor/fit-parser/fit_types.ts vendored Normal file

File diff suppressed because it is too large Load Diff

75
web/src/lib/vendor/fit-parser/helper.ts vendored Normal file
View File

@@ -0,0 +1,75 @@
import type { ParsedLap, ParsedSession } from './fit_types'
export function mapDataIntoLap(
inputLaps: ParsedLap[],
lapKey: 'records' | 'lengths',
data: any[],
): ParsedLap[] {
const laps = [...inputLaps]
let index = 0
for (let i = 0; i < laps.length; i++) {
const nextLap = laps[i + 1]
const tempData = []
const nextLapStartTime = nextLap
? new Date(nextLap.start_time).getTime()
: null
for (let j = index; j < data.length; j++) {
const row = data[j]
if (nextLap && nextLapStartTime) {
const timestamp = new Date(row.timestamp || row.start_time).getTime()
if (nextLapStartTime > timestamp) {
tempData.push(row)
}
else if (nextLapStartTime <= timestamp) {
index = j
break
}
}
else {
tempData.push(row)
}
}
if (!laps[i][lapKey]) {
laps[i][lapKey] = tempData
}
}
return laps
}
export function mapDataIntoSession(
inputSessions: ParsedSession[],
laps: ParsedLap[],
): ParsedSession[] {
const sessions = [...inputSessions]
let lapIndex = 0
for (let i = 0; i < sessions.length; i++) {
const nextSession = sessions[i + 1]
const tempLaps = []
const nextSessionStartTime = nextSession
? new Date(nextSession.start_time).getTime()
: null
for (let j = lapIndex; j < laps.length; j++) {
const lap = laps[j]
if (nextSession && nextSessionStartTime) {
const lapStartTime = new Date(lap.start_time).getTime()
if (nextSessionStartTime > lapStartTime) {
tempLaps.push(lap)
}
else if (nextSessionStartTime <= lapStartTime) {
lapIndex = j
break
}
}
else {
tempLaps.push(lap)
}
}
if (!sessions[i].laps) {
sessions[i].laps = tempLaps
}
}
return sessions
}

View File

@@ -0,0 +1,31 @@
import type { MessageObject } from './fit.js'
import type { MesgNum } from './fit_types.js'
import { FIT } from './fit.js'
function getFieldObject(
fieldNum: number,
messageNum: number,
): MessageObject {
const message = FIT.messages[messageNum]
if (!message) {
return {} as MessageObject
}
return message[fieldNum] || {}
}
function getMessageName(messageNum: number): MesgNum | '' {
const message = FIT.messages[messageNum]
return message ? message.name : ''
}
export function getFitMessage(messageNum: number): { name: MesgNum | '', getAttributes: (fieldNum: number) => MessageObject } {
return {
name: getMessageName(messageNum),
getAttributes: (fieldNum: number) => getFieldObject(fieldNum, messageNum),
}
}
// TODO
export function getFitMessageBaseType(inp: any): any {
return inp
}