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 GPX from "$lib/models/gpx/gpx";
|
||||||
import { Trail } from "$lib/models/trail";
|
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";
|
import cryptoRandomString from "crypto-random-string";
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import Track from "$lib/models/gpx/track";
|
import Track from "$lib/models/gpx/track";
|
||||||
import TrackSegment from "$lib/models/gpx/track-segment";
|
import TrackSegment from "$lib/models/gpx/track-segment";
|
||||||
import GPXWaypoint from "$lib/models/gpx/waypoint";
|
import GPXWaypoint from "$lib/models/gpx/waypoint";
|
||||||
import EasyFit from "$lib/vendor/easy-fit/easy-fit";
|
import { Waypoint } from "$lib/models/waypoint";
|
||||||
import type { Feature, FeatureCollection, GeoJSON, GeoJsonProperties, Position } from 'geojson';
|
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 JSZip from "jszip";
|
||||||
import type { AuthRecord } from "pocketbase";
|
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 { 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) {
|
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) {
|
export async function fromFIT(fitData: ArrayBuffer) {
|
||||||
const easyFit = new EasyFit();
|
const fitParser = new FitParser();
|
||||||
return new Promise<string>((resolve, reject) => easyFit.parse(fitData, function (error, data) {
|
return new Promise<string>((resolve, reject) => fitParser.parse(fitData, function (error, data) {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -204,13 +203,13 @@ export async function fromFIT(fitData: ArrayBuffer) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
name: "",
|
name: "",
|
||||||
desc: "",
|
desc: "",
|
||||||
time: data.activity?.timestamp ?? new Date(),
|
time: data?.activity?.timestamp ?? new Date(),
|
||||||
keywords: "wanderer"
|
keywords: "wanderer"
|
||||||
},
|
},
|
||||||
trk: [
|
trk: [
|
||||||
new Track({
|
new Track({
|
||||||
trkseg: new TrackSegment({
|
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({
|
return (d.position_lat && d.position_long) ? new GPXWaypoint({
|
||||||
$: { lat: d.position_lat, lon: d.position_long },
|
$: { lat: d.position_lat, lon: d.position_long },
|
||||||
time: d.timestamp,
|
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