Switch to better FIT parser (#884)
* initial commit * Fix undefined Buffer for clientside upload --------- Co-authored-by: Christian Beutel <>
This commit is contained in:
@@ -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,
|
||||
|
||||
269
web/src/lib/vendor/easy-fit/binary.ts
vendored
269
web/src/lib/vendor/easy-fit/binary.ts
vendored
@@ -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;
|
||||
}
|
||||
156
web/src/lib/vendor/easy-fit/easy-fit.ts
vendored
156
web/src/lib/vendor/easy-fit/easy-fit.ts
vendored
@@ -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
|
||||
2440
web/src/lib/vendor/easy-fit/fit.ts
vendored
2440
web/src/lib/vendor/easy-fit/fit.ts
vendored
File diff suppressed because it is too large
Load Diff
16
web/src/lib/vendor/easy-fit/messages.ts
vendored
16
web/src/lib/vendor/easy-fit/messages.ts
vendored
@@ -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
564
web/src/lib/vendor/fit-parser/binary.ts
vendored
Normal 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
8340
web/src/lib/vendor/fit-parser/fit.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
334
web/src/lib/vendor/fit-parser/fit_parser.ts
vendored
Normal file
334
web/src/lib/vendor/fit-parser/fit_parser.ts
vendored
Normal 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
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
75
web/src/lib/vendor/fit-parser/helper.ts
vendored
Normal 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
|
||||
}
|
||||
31
web/src/lib/vendor/fit-parser/messages.ts
vendored
Normal file
31
web/src/lib/vendor/fit-parser/messages.ts
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user