fit-file-parser 6.1.2 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Change Log
2
2
 
3
+ ## 6.2.0 - 2026-10-10
4
+
5
+ ### Added
6
+
7
+ - Add `parseFilesAsync()` to return every complete FIT file in concatenated
8
+ input as a separate result, with independent decoder state and file order.
9
+ - Decode documented session and lap swimming summaries, segment and gear-shift
10
+ counts, lap bounds, metabolic calories, and grade-adjusted speed.
11
+ - Retain every battery sample in `batteries` and `messages.battery`, including
12
+ timestamp and capacity percentage when present.
13
+ - Add `--all-files` to the external corpus checker to validate appended files.
14
+
15
+ ### Fixed
16
+
17
+ - Preserve exact bytes for developer fields without usable names in opt-in
18
+ `unmapped_messages`, including fields whose descriptions have been cached.
19
+ - Recover unsigned-byte record temperatures from 0 through 126 with normal
20
+ unit conversion, while retaining their original bytes in opt-in output.
21
+ - Reject records and definitions extending beyond the declared data section
22
+ when using the complete-file `parseFilesAsync()` API.
23
+
24
+ ### Compatibility
25
+
26
+ - Existing `parseAsync()` and callback APIs retain their single-file output
27
+ and recovery behavior. Newly supported fields and battery collections are
28
+ additive.
29
+ - `parseFilesAsync()` returns a one-element array for ordinary FIT input and
30
+ rejects incomplete files or trailing non-FIT data, even with `force: true`.
31
+ CRC validation follows the caller's `force` option for every file.
32
+ - Unsigned temperature bytes from 127 through 255 and other undocumented or
33
+ incompatible fields remain undecoded and available through opt-in retention.
34
+
3
35
  ## 6.1.2 - 2026-09-22
4
36
 
5
37
  ### Changed
package/PROFILE.md CHANGED
@@ -31,6 +31,23 @@ complete raw representation of selected messages can continue to use
31
31
  `includeRawMessages`; `unmapped_messages` stores only the fields the maintained
32
32
  table cannot decode.
33
33
 
34
+ The unsigned-byte record-temperature interoperability recovery also retains
35
+ the original byte here, even when its unambiguous value is decoded.
36
+
37
+ ## Focused interoperability additions
38
+
39
+ Session fields 79, 80, 106, 107, 108, and 211; lap fields 27–30, 73, 90, 96,
40
+ 97, 155, and 161; and battery message 104 fields 2 and 253 were checked against
41
+ independently observed wire definitions in the external FIT corpus and the
42
+ redistributable interoperability definitions for
43
+ [session](https://github.com/muktihari/fit/blob/126e1a11233f87b7f139254d991bc3d2d952b62c/profile/mesgdef/session_gen.go),
44
+ [lap](https://github.com/muktihari/fit/blob/126e1a11233f87b7f139254d991bc3d2d952b62c/profile/mesgdef/lap_gen.go),
45
+ and [battery](https://github.com/muktihari/fit/blob/126e1a11233f87b7f139254d991bc3d2d952b62c/profile/mesgdef/battery_gen.go).
46
+ The focused mappings expose swimming summaries, segment and shift counts,
47
+ lap bounds, metabolic calories, grade-adjusted speed, and battery samples.
48
+ Synthetic regression tests pin identifiers, wire types, scales, units, invalid
49
+ sentinels, and collection behavior. Other battery fields remain unmapped.
50
+
34
51
  ## Updating the table
35
52
 
36
53
  Every mapping change requires focused regression coverage. Do not guess
@@ -45,7 +62,13 @@ npm run profile:check
45
62
  npm run check
46
63
  npm run compatibility:check -- /path/to/reference-package /path/to/fit-corpus
47
64
  npm run corpus:check -- ../FIT-test-files --allow-force-recovery
65
+ npm run corpus:check -- ../FIT-test-files --allow-force-recovery --all-files
48
66
  ```
49
67
 
50
68
  `src/fit_types.ts` is generated from the maintained table and must not be
51
69
  edited manually.
70
+
71
+ `--all-files` validates every appended FIT file through `parseFilesAsync()` and
72
+ reports both parsed FIT file counts and concatenated input counts. Recovery
73
+ counts apply to whole inputs; unmapped summaries count each parsed FIT file
74
+ separately in this mode.
package/README.md CHANGED
@@ -11,6 +11,7 @@ including developer-defined data.
11
11
  ## Features
12
12
 
13
13
  - Parse Node.js `Buffer` and standard `ArrayBuffer` inputs.
14
+ - Parse concatenated FIT files as separate results with `parseFilesAsync()`.
14
15
  - Choose flat lists, nested activity data, or both output shapes.
15
16
  - Convert speed, length, temperature, and pressure fields to preferred units.
16
17
  - Decode developer fields while preserving record alignment when descriptions
@@ -33,7 +34,7 @@ npm install fit-file-parser
33
34
 
34
35
  ## Profile coverage
35
36
 
36
- The static profile contains 126 messages, 1,444 fields, 200 types, and 4,403
37
+ The static profile contains 127 messages, 1,462 fields, 200 types, and 4,406
37
38
  mapped values. Pass `includeUnmappedMessages: true` to retain fields outside
38
39
  that maintained surface by number and exact bytes; applications that need all
39
40
  bytes for recognized messages can also opt into `raw_messages`.
@@ -62,6 +63,27 @@ console.log({
62
63
  })
63
64
  ```
64
65
 
66
+ ### Concatenated FIT files
67
+
68
+ Some exports append several complete FIT files into one input. Use
69
+ `parseFilesAsync()` to return a `ParsedFit[]` in file order:
70
+
71
+ ```javascript
72
+ const files = await new FitParser({ force: false }).parseFilesAsync(content)
73
+ for (const file of files) {
74
+ console.log(file.sessions?.length ?? 0)
75
+ }
76
+ ```
77
+
78
+ Each result has independent message definitions, developer descriptions,
79
+ timestamps, and activity collections. Results are never merged. A single FIT
80
+ file returns a one-element array. Empty input, incomplete files (including
81
+ missing file CRC bytes), and trailing non-FIT bytes reject the entire call,
82
+ even with `force: true`. Records and definitions extending beyond the declared
83
+ data section are also rejected. CRC validation follows the `force` option for every
84
+ file. Failures reject with an `Error`. The existing `parseAsync()` and callback
85
+ APIs continue to parse the first FIT file only.
86
+
65
87
  ### Callback API
66
88
 
67
89
  ```javascript
@@ -86,8 +108,9 @@ readFile('./activity.fit', (readError, content) => {
86
108
  })
87
109
  ```
88
110
 
89
- Parser errors are strings. `parseAsync()` rejects with the same value that the
90
- callback API receives as its first argument.
111
+ The callback reports validation errors as strings. `parseAsync()` rejects with
112
+ those values; decoder failures can reject with an `Error`. `parseFilesAsync()`
113
+ consistently rejects with an `Error`.
91
114
 
92
115
  ## Parser options
93
116
 
@@ -148,8 +171,8 @@ message numbers.
148
171
 
149
172
  Set `includeUnmappedMessages: true` to return fields that are absent from the
150
173
  maintained profile or belong to an unknown global message in
151
- `unmapped_messages`. Only unmapped fields are retained, so normal known fields
152
- are not duplicated:
174
+ `unmapped_messages`. Fields without usable developer names are also retained.
175
+ Normal known fields are not duplicated:
153
176
 
154
177
  ```javascript
155
178
  const data = await new FitParser({ includeUnmappedMessages: true })
@@ -169,6 +192,20 @@ Each entry uses the same wire-level field representation as `raw_messages`.
169
192
  This opt-in ensures a newer FIT field cannot disappear merely because it
170
193
  does not yet have a reviewed semantic mapping.
171
194
 
195
+ For record temperature encoded as one unsigned byte, values from 0 through
196
+ 126 can be decoded as Celsius before the selected temperature conversion.
197
+ Values from 127 through 255 remain undecoded. This narrow interoperability
198
+ recovery preserves the original unsigned byte in `unmapped_messages` when
199
+ enabled, including values that also receive a decoded temperature.
200
+
201
+ ### Battery samples
202
+
203
+ `batteries` retains every battery message in file order, including its
204
+ timestamp and capacity percentage when present. These samples are also
205
+ available in `messages.battery`. Undocumented battery fields remain available
206
+ through `includeUnmappedMessages`; the parser does not infer their device
207
+ association or calculate battery consumption.
208
+
172
209
  ### Lossless selected messages
173
210
 
174
211
  Set `includeRawMessages` when a consumer needs native FIT numeric codes rather
package/dist/binary.js CHANGED
@@ -305,6 +305,7 @@ function applyOptions(data, field, options, fields) {
305
305
  case 'enhanced_speed':
306
306
  case 'vertical_speed':
307
307
  case 'avg_speed':
308
+ case 'avg_grade_adjusted_speed':
308
309
  case 'max_speed':
309
310
  case 'speed1s':
310
311
  case 'ball_speed':
@@ -465,7 +466,12 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
465
466
  const fieldSize = blob[fDefIndex + 1];
466
467
  const wireType = FIT.types.fit_base_type[baseType];
467
468
  const { field, type, baseType: profileBaseType, array, scale, offset, units, } = message.getAttributes(blob[fDefIndex]);
468
- const profileCompatible = areProfileBaseTypesCompatible(profileBaseType, wireType);
469
+ // Narrow producer interoperability case: only the shared, non-sentinel
470
+ // temperature range is decoded below. All original bytes remain unmapped.
471
+ const unsignedTemperature = mTypeDef.globalMessageNumber === 20
472
+ && fieldNumber === 13 && fieldSize === 1
473
+ && profileBaseType === 'sint8' && wireType === 'uint8';
474
+ const profileCompatible = unsignedTemperature || areProfileBaseTypesCompatible(profileBaseType, wireType);
469
475
  const fDef = {
470
476
  type: profileCompatible ? type : wireType,
471
477
  rawType: wireType,
@@ -539,7 +545,11 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
539
545
  rawData[i] = InvalidFieldData;
540
546
  continue;
541
547
  }
542
- if ((rawFields || (unmappedFields && !isOutputFieldName(fDef.name)))
548
+ const unsignedTemperature = messageType.globalMessageNumber === 20
549
+ && fDef.fDefNo === 13 && fDef.size === 1
550
+ && fDef.type === 'sint8' && fDef.rawType === 'uint8';
551
+ const preserveNative = !isOutputFieldName(fDef.name) || unsignedTemperature;
552
+ if ((rawFields || (unmappedFields && preserveNative))
543
553
  && readDataFromIndex + fDef.size <= dataEnd) {
544
554
  const rawField = {
545
555
  fieldDefinitionNumber: fDef.fDefNo,
@@ -547,11 +557,13 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
547
557
  rawValue: Array.from(blob.subarray(readDataFromIndex, readDataFromIndex + fDef.size)),
548
558
  };
549
559
  rawFields === null || rawFields === void 0 ? void 0 : rawFields.push(rawField);
550
- if (!isOutputFieldName(fDef.name)) {
560
+ if (preserveNative) {
551
561
  unmappedFields === null || unmappedFields === void 0 ? void 0 : unmappedFields.push(rawField);
552
562
  }
553
563
  }
554
- const data = readData(blob, dataView, fDef, readDataFromIndex);
564
+ const data = unsignedTemperature && blob[readDataFromIndex] >= 127
565
+ ? InvalidFieldData
566
+ : readData(blob, dataView, fDef, readDataFromIndex);
555
567
  if (data !== InvalidFieldData
556
568
  && !isInvalidValue(data, fDef.type)
557
569
  && !isInvalidBaseTypeValue(data, fDef.baseTypeNo)) {
@@ -573,20 +585,21 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
573
585
  for (let i = 0; i < developerFieldDefs.length; i++) {
574
586
  const developerFieldDef = developerFieldDefs[i];
575
587
  const rawDataIndex = messageType.fieldDefs.length + i;
576
- let rawDeveloperField;
577
- if ((rawDeveloperFields
578
- || (unmappedDeveloperFields && developerFieldDef.resolvedFieldDef === undefined))
588
+ // Resolve before deciding whether raw retention is needed. A resolved
589
+ // description can still have no usable output name, including after cache use.
590
+ const fDef = resolveDeveloperFieldDefinition(developerFieldDef, messageType.littleEndian, developerFields, options);
591
+ const preserveDeveloper = !fDef || !isOutputFieldName(fDef.name);
592
+ if ((rawDeveloperFields || (unmappedDeveloperFields && preserveDeveloper))
579
593
  && readDataFromIndex + developerFieldDef.size <= dataEnd) {
580
- rawDeveloperField = {
594
+ const rawDeveloperField = {
581
595
  developerDataIndex: developerFieldDef.developerDataIndex,
582
596
  fieldDefinitionNumber: developerFieldDef.fieldDefinitionNumber,
583
597
  rawValue: Array.from(blob.subarray(readDataFromIndex, readDataFromIndex + developerFieldDef.size)),
584
598
  };
585
599
  rawDeveloperFields === null || rawDeveloperFields === void 0 ? void 0 : rawDeveloperFields.push(rawDeveloperField);
586
- }
587
- const fDef = resolveDeveloperFieldDefinition(developerFieldDef, messageType.littleEndian, developerFields, options);
588
- if (!fDef && rawDeveloperField) {
589
- unmappedDeveloperFields === null || unmappedDeveloperFields === void 0 ? void 0 : unmappedDeveloperFields.push(rawDeveloperField);
600
+ if (preserveDeveloper) {
601
+ unmappedDeveloperFields === null || unmappedDeveloperFields === void 0 ? void 0 : unmappedDeveloperFields.push(rawDeveloperField);
602
+ }
590
603
  }
591
604
  if (fDef) {
592
605
  const data = readData(blob, dataView, fDef, readDataFromIndex);
@@ -311,6 +311,7 @@ function applyOptions(data, field, options, fields) {
311
311
  case 'enhanced_speed':
312
312
  case 'vertical_speed':
313
313
  case 'avg_speed':
314
+ case 'avg_grade_adjusted_speed':
314
315
  case 'max_speed':
315
316
  case 'speed1s':
316
317
  case 'ball_speed':
@@ -471,7 +472,12 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
471
472
  const fieldSize = blob[fDefIndex + 1];
472
473
  const wireType = fit_js_1.FIT.types.fit_base_type[baseType];
473
474
  const { field, type, baseType: profileBaseType, array, scale, offset, units, } = message.getAttributes(blob[fDefIndex]);
474
- const profileCompatible = areProfileBaseTypesCompatible(profileBaseType, wireType);
475
+ // Narrow producer interoperability case: only the shared, non-sentinel
476
+ // temperature range is decoded below. All original bytes remain unmapped.
477
+ const unsignedTemperature = mTypeDef.globalMessageNumber === 20
478
+ && fieldNumber === 13 && fieldSize === 1
479
+ && profileBaseType === 'sint8' && wireType === 'uint8';
480
+ const profileCompatible = unsignedTemperature || areProfileBaseTypesCompatible(profileBaseType, wireType);
475
481
  const fDef = {
476
482
  type: profileCompatible ? type : wireType,
477
483
  rawType: wireType,
@@ -545,7 +551,11 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
545
551
  rawData[i] = InvalidFieldData;
546
552
  continue;
547
553
  }
548
- if ((rawFields || (unmappedFields && !isOutputFieldName(fDef.name)))
554
+ const unsignedTemperature = messageType.globalMessageNumber === 20
555
+ && fDef.fDefNo === 13 && fDef.size === 1
556
+ && fDef.type === 'sint8' && fDef.rawType === 'uint8';
557
+ const preserveNative = !isOutputFieldName(fDef.name) || unsignedTemperature;
558
+ if ((rawFields || (unmappedFields && preserveNative))
549
559
  && readDataFromIndex + fDef.size <= dataEnd) {
550
560
  const rawField = {
551
561
  fieldDefinitionNumber: fDef.fDefNo,
@@ -553,11 +563,13 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
553
563
  rawValue: Array.from(blob.subarray(readDataFromIndex, readDataFromIndex + fDef.size)),
554
564
  };
555
565
  rawFields === null || rawFields === void 0 ? void 0 : rawFields.push(rawField);
556
- if (!isOutputFieldName(fDef.name)) {
566
+ if (preserveNative) {
557
567
  unmappedFields === null || unmappedFields === void 0 ? void 0 : unmappedFields.push(rawField);
558
568
  }
559
569
  }
560
- const data = readData(blob, dataView, fDef, readDataFromIndex);
570
+ const data = unsignedTemperature && blob[readDataFromIndex] >= 127
571
+ ? InvalidFieldData
572
+ : readData(blob, dataView, fDef, readDataFromIndex);
561
573
  if (data !== InvalidFieldData
562
574
  && !isInvalidValue(data, fDef.type)
563
575
  && !isInvalidBaseTypeValue(data, fDef.baseTypeNo)) {
@@ -579,20 +591,21 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
579
591
  for (let i = 0; i < developerFieldDefs.length; i++) {
580
592
  const developerFieldDef = developerFieldDefs[i];
581
593
  const rawDataIndex = messageType.fieldDefs.length + i;
582
- let rawDeveloperField;
583
- if ((rawDeveloperFields
584
- || (unmappedDeveloperFields && developerFieldDef.resolvedFieldDef === undefined))
594
+ // Resolve before deciding whether raw retention is needed. A resolved
595
+ // description can still have no usable output name, including after cache use.
596
+ const fDef = resolveDeveloperFieldDefinition(developerFieldDef, messageType.littleEndian, developerFields, options);
597
+ const preserveDeveloper = !fDef || !isOutputFieldName(fDef.name);
598
+ if ((rawDeveloperFields || (unmappedDeveloperFields && preserveDeveloper))
585
599
  && readDataFromIndex + developerFieldDef.size <= dataEnd) {
586
- rawDeveloperField = {
600
+ const rawDeveloperField = {
587
601
  developerDataIndex: developerFieldDef.developerDataIndex,
588
602
  fieldDefinitionNumber: developerFieldDef.fieldDefinitionNumber,
589
603
  rawValue: Array.from(blob.subarray(readDataFromIndex, readDataFromIndex + developerFieldDef.size)),
590
604
  };
591
605
  rawDeveloperFields === null || rawDeveloperFields === void 0 ? void 0 : rawDeveloperFields.push(rawDeveloperField);
592
- }
593
- const fDef = resolveDeveloperFieldDefinition(developerFieldDef, messageType.littleEndian, developerFields, options);
594
- if (!fDef && rawDeveloperField) {
595
- unmappedDeveloperFields === null || unmappedDeveloperFields === void 0 ? void 0 : unmappedDeveloperFields.push(rawDeveloperField);
606
+ if (preserveDeveloper) {
607
+ unmappedDeveloperFields === null || unmappedDeveloperFields === void 0 ? void 0 : unmappedDeveloperFields.push(rawDeveloperField);
608
+ }
596
609
  }
597
610
  if (fDef) {
598
611
  const data = readData(blob, dataView, fDef, readDataFromIndex);
@@ -28,5 +28,13 @@ export default class FitParser {
28
28
  private readonly options;
29
29
  constructor(options?: FitParserOptions);
30
30
  parseAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit>;
31
+ /**
32
+ * Parses every complete FIT file in an input, preserving file order and
33
+ * independent decoder state. Rejects incomplete files and trailing bytes;
34
+ * each file uses this parser's options, including its CRC policy.
35
+ * Rejects with an Error when any file cannot be parsed.
36
+ */
37
+ parseFilesAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit[]>;
31
38
  parse(content: ArrayBuffer | Buffer<ArrayBuffer>, callback: FitParserCallback): void;
39
+ private parseFile;
32
40
  }
@@ -1,4 +1,13 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  Object.defineProperty(exports, "__esModule", { value: true });
3
12
  exports.readFitUnsignedField = exports.readFitStringField = exports.readFitMessages = exports.getFitBaseTypeId = exports.fitTimestampToUnixMilliseconds = exports.FitMessageReaderError = exports.getFitSubSportName = exports.getFitSubSportId = exports.getFitSportName = exports.getFitSportId = exports.getFitManufacturerName = exports.getFitGarminProductName = exports.getFitGarminProductDisplayName = exports.getFitCoursePointId = exports.FitEncoder = exports.FitBaseType = void 0;
4
13
  const binary_js_1 = require("./binary.js");
@@ -51,7 +60,71 @@ class FitParser {
51
60
  });
52
61
  });
53
62
  }
63
+ /**
64
+ * Parses every complete FIT file in an input, preserving file order and
65
+ * independent decoder state. Rejects incomplete files and trailing bytes;
66
+ * each file uses this parser's options, including its CRC policy.
67
+ * Rejects with an Error when any file cannot be parsed.
68
+ */
69
+ parseFilesAsync(content) {
70
+ return __awaiter(this, void 0, void 0, function* () {
71
+ const blob = content instanceof ArrayBuffer
72
+ ? new Uint8Array(content)
73
+ : new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
74
+ const results = [];
75
+ let start = 0;
76
+ do {
77
+ const remaining = blob.length - start;
78
+ if (remaining < 12) {
79
+ throw new Error('File to small to be a FIT file');
80
+ }
81
+ const headerLength = blob[start];
82
+ if (headerLength !== 12 && headerLength !== 14) {
83
+ throw new Error('Incorrect header size');
84
+ }
85
+ if (remaining < headerLength) {
86
+ throw new Error('File to small to be a FIT file');
87
+ }
88
+ if (blob[start + 8] !== 0x2E
89
+ || blob[start + 9] !== 0x46
90
+ || blob[start + 10] !== 0x49
91
+ || blob[start + 11] !== 0x54) {
92
+ throw new Error('Missing \'.FIT\' in header');
93
+ }
94
+ const dataLength = new DataView(blob.buffer, blob.byteOffset + start, remaining)
95
+ .getUint32(4, true);
96
+ const crcStart = start + headerLength + dataLength;
97
+ if (crcStart > blob.length) {
98
+ throw new Error('File data exceeds input length');
99
+ }
100
+ if (crcStart + 2 > blob.length) {
101
+ throw new Error('File CRC missing');
102
+ }
103
+ const end = crcStart + 2;
104
+ try {
105
+ results.push(yield new Promise((resolve, reject) => {
106
+ this.parseFile(blob.slice(start, end).buffer, (error, data) => {
107
+ if (error) {
108
+ reject(error);
109
+ }
110
+ else if (data) {
111
+ resolve(data);
112
+ }
113
+ }, true);
114
+ }));
115
+ }
116
+ catch (error) {
117
+ throw error instanceof Error ? error : new Error(String(error));
118
+ }
119
+ start = end;
120
+ } while (start < blob.length);
121
+ return results;
122
+ });
123
+ }
54
124
  parse(content, callback) {
125
+ this.parseFile(content, callback);
126
+ }
127
+ parseFile(content, callback, validateRecordBounds = false) {
55
128
  var _a, _b, _c;
56
129
  const blob = content instanceof ArrayBuffer
57
130
  ? new Uint8Array(content)
@@ -111,6 +184,7 @@ class FitParser {
111
184
  protocolVersion,
112
185
  };
113
186
  const messages = {};
187
+ const batteries = [];
114
188
  let sessions = [];
115
189
  let laps = [];
116
190
  const records = [];
@@ -160,6 +234,12 @@ class FitParser {
160
234
  let pausedTime = 0;
161
235
  while (loopIndex < crcStart) {
162
236
  const { globalMessageNumber, littleEndian, message, messageType, nextIndex, compressedTimestamp, rawFields: recordRawFields, rawDeveloperFields: recordRawDeveloperFields, unmappedFields: recordUnmappedFields, unmappedDeveloperFields: recordUnmappedDeveloperFields, } = (0, binary_js_1.readRecord)(blob, messageTypes, developerFields, loopIndex, this.options, startDate, pausedTime, dataView, decoderState, crcStart);
237
+ // Complete-file parsing also validates bounds without requiring raw
238
+ // retention. The single-file API keeps its existing recovery behavior.
239
+ if (validateRecordBounds
240
+ && (!Number.isInteger(nextIndex) || nextIndex <= loopIndex || nextIndex > crcStart)) {
241
+ throw new Error('Invalid FIT record bounds');
242
+ }
163
243
  loopIndex = nextIndex;
164
244
  if (globalMessageNumber !== undefined) {
165
245
  const messageIndex = (_a = messageCountsByGlobalNumber.get(globalMessageNumber)) !== null && _a !== void 0 ? _a : 0;
@@ -210,6 +290,9 @@ class FitParser {
210
290
  ((_b = messages[messageType]) !== null && _b !== void 0 ? _b : (messages[messageType] = [])).push(message);
211
291
  }
212
292
  switch (messageType) {
293
+ case 'battery':
294
+ batteries.push(message);
295
+ break;
213
296
  case 'lap':
214
297
  laps.push(message);
215
298
  break;
@@ -354,6 +437,7 @@ class FitParser {
354
437
  fitObj.time_in_zone = time_in_zone;
355
438
  fitObj.activity_metrics = activity_metrics;
356
439
  fitObj.user_metrics = user_metrics;
440
+ fitObj.batteries = batteries;
357
441
  fitObj.messages = messages;
358
442
  if (rawDeveloperFields) {
359
443
  fitObj.raw_developer_fields = rawDeveloperFields;
@@ -156,7 +156,7 @@ export type MaxMetCategory = 'generic' | 'cycling' | number;
156
156
  export type MaxMetHeartRateSource = 'whr' | 'hrm' | number;
157
157
  export type MaxMetSpeedSource = 'onboard_gps' | 'connected_gps' | 'cadence' | number;
158
158
  export type MesgCount = 'num_per_file' | 'max_per_file' | 'max_per_file_type' | number;
159
- export type MesgNum = 'file_id' | 'capabilities' | 'device_settings' | 'user_profile' | 'hrm_profile' | 'sdm_profile' | 'bike_profile' | 'zones_target' | 'hr_zone' | 'power_zone' | 'met_zone' | 'sport' | 'training_settings' | 'goal' | 'session' | 'lap' | 'record' | 'event' | 'device_info' | 'workout' | 'workout_step' | 'schedule' | 'weight_scale' | 'course' | 'course_point' | 'totals' | 'activity' | 'software' | 'file_capabilities' | 'mesg_capabilities' | 'field_capabilities' | 'file_creator' | 'blood_pressure' | 'speed_zone' | 'monitoring' | 'training_file' | 'hrv' | 'user_metrics' | 'ant_rx' | 'ant_tx' | 'ant_channel_id' | 'length' | 'monitoring_info' | 'pad' | 'slave_device' | 'connectivity' | 'weather_conditions' | 'weather_alert' | 'cadence_zone' | 'hr' | 'activity_metrics' | 'segment_lap' | 'memo_glob' | 'segment_id' | 'segment_leaderboard_entry' | 'segment_point' | 'segment_file' | 'workout_session' | 'watchface_settings' | 'gps_metadata' | 'camera_event' | 'timestamp_correlation' | 'gyroscope_data' | 'accelerometer_data' | 'three_d_sensor_calibration' | 'video_frame' | 'obdii_data' | 'nmea_sentence' | 'aviation_attitude' | 'video' | 'video_title' | 'video_description' | 'video_clip' | 'ohr_settings' | 'exd_screen_configuration' | 'exd_data_field_configuration' | 'exd_data_concept_configuration' | 'field_description' | 'developer_data_id' | 'magnetometer_data' | 'barometer_data' | 'one_d_sensor_calibration' | 'monitoring_hr_data' | 'time_in_zone' | 'set' | 'stress_level' | 'max_met_data' | 'dive_settings' | 'dive_gas' | 'dive_alarm' | 'exercise_title' | 'dive_summary' | 'spo2_data' | 'sleep_level' | 'jump' | 'aad_accel_features' | 'beat_intervals' | 'respiration_rate' | 'hsa_accelerometer_data' | 'hsa_step_data' | 'hsa_spo2_data' | 'hsa_stress_data' | 'hsa_respiration_data' | 'hsa_heart_rate_data' | 'split' | 'split_summary' | 'hsa_body_battery_data' | 'hsa_event' | 'climb_pro' | 'tank_update' | 'tank_summary' | 'sleep_assessment' | 'hrv_status_summary' | 'hrv_value' | 'raw_bbi' | 'device_aux_battery_info' | 'hsa_gyroscope_data' | 'chrono_shot_session' | 'chrono_shot_data' | 'hsa_configuration_data' | 'dive_apnea_alarm' | 'skin_temp_overnight' | 'hsa_wrist_temperature_data' | 'nap_event' | 'sleep_disruption_severity_period' | 'sleep_disruption_overnight_severity' | 'mfg_range_min' | 'mfg_range_max' | 'definition' | number;
159
+ export type MesgNum = 'file_id' | 'capabilities' | 'device_settings' | 'user_profile' | 'hrm_profile' | 'sdm_profile' | 'bike_profile' | 'zones_target' | 'hr_zone' | 'power_zone' | 'met_zone' | 'sport' | 'training_settings' | 'goal' | 'session' | 'lap' | 'record' | 'event' | 'device_info' | 'workout' | 'workout_step' | 'schedule' | 'weight_scale' | 'course' | 'course_point' | 'totals' | 'activity' | 'software' | 'file_capabilities' | 'mesg_capabilities' | 'field_capabilities' | 'file_creator' | 'blood_pressure' | 'speed_zone' | 'monitoring' | 'training_file' | 'hrv' | 'user_metrics' | 'ant_rx' | 'ant_tx' | 'ant_channel_id' | 'length' | 'monitoring_info' | 'battery' | 'pad' | 'slave_device' | 'connectivity' | 'weather_conditions' | 'weather_alert' | 'cadence_zone' | 'hr' | 'activity_metrics' | 'segment_lap' | 'memo_glob' | 'segment_id' | 'segment_leaderboard_entry' | 'segment_point' | 'segment_file' | 'workout_session' | 'watchface_settings' | 'gps_metadata' | 'camera_event' | 'timestamp_correlation' | 'gyroscope_data' | 'accelerometer_data' | 'three_d_sensor_calibration' | 'video_frame' | 'obdii_data' | 'nmea_sentence' | 'aviation_attitude' | 'video' | 'video_title' | 'video_description' | 'video_clip' | 'ohr_settings' | 'exd_screen_configuration' | 'exd_data_field_configuration' | 'exd_data_concept_configuration' | 'field_description' | 'developer_data_id' | 'magnetometer_data' | 'barometer_data' | 'one_d_sensor_calibration' | 'monitoring_hr_data' | 'time_in_zone' | 'set' | 'stress_level' | 'max_met_data' | 'dive_settings' | 'dive_gas' | 'dive_alarm' | 'exercise_title' | 'dive_summary' | 'spo2_data' | 'sleep_level' | 'jump' | 'aad_accel_features' | 'beat_intervals' | 'respiration_rate' | 'hsa_accelerometer_data' | 'hsa_step_data' | 'hsa_spo2_data' | 'hsa_stress_data' | 'hsa_respiration_data' | 'hsa_heart_rate_data' | 'split' | 'split_summary' | 'hsa_body_battery_data' | 'hsa_event' | 'climb_pro' | 'tank_update' | 'tank_summary' | 'sleep_assessment' | 'hrv_status_summary' | 'hrv_value' | 'raw_bbi' | 'device_aux_battery_info' | 'hsa_gyroscope_data' | 'chrono_shot_session' | 'chrono_shot_data' | 'hsa_configuration_data' | 'dive_apnea_alarm' | 'skin_temp_overnight' | 'hsa_wrist_temperature_data' | 'nap_event' | 'sleep_disruption_severity_period' | 'sleep_disruption_overnight_severity' | 'mfg_range_min' | 'mfg_range_max' | 'definition' | number;
160
160
  export interface MessageIndex {
161
161
  value: number;
162
162
  reserved: boolean;
@@ -498,6 +498,8 @@ export interface ParsedSession {
498
498
  best_lap_index?: number;
499
499
  min_altitude?: number;
500
500
  active_time?: number;
501
+ avg_strokes_per_length?: number;
502
+ avg_swolf?: number;
501
503
  player_score?: number;
502
504
  opponent_score?: number;
503
505
  opponent_name?: string;
@@ -522,6 +524,9 @@ export interface ParsedSession {
522
524
  avg_left_pedal_smoothness?: number;
523
525
  avg_right_pedal_smoothness?: number;
524
526
  avg_combined_pedal_smoothness?: number;
527
+ num_segment_laps?: number;
528
+ front_gear_shift_count?: number;
529
+ rear_gear_shift_count?: number;
525
530
  sport_profile_name?: string;
526
531
  sport_index?: number;
527
532
  time_standing?: number;
@@ -588,6 +593,7 @@ export interface ParsedSession {
588
593
  avg_core_temperature?: number;
589
594
  min_core_temperature?: number;
590
595
  max_core_temperature?: number;
596
+ avg_grade_adjusted_speed?: number;
591
597
  timestamp?: Date;
592
598
  message_index?: MessageIndex;
593
599
  laps?: ParsedLap[];
@@ -620,6 +626,10 @@ export interface ParsedLap {
620
626
  lap_trigger?: LapTrigger;
621
627
  sport?: Sport;
622
628
  event_group?: number;
629
+ nec_lat?: number;
630
+ nec_long?: number;
631
+ swc_lat?: number;
632
+ swc_long?: number;
623
633
  num_lengths?: number;
624
634
  normalized_power?: number;
625
635
  left_right_balance?: LeftRightBalance100;
@@ -653,6 +663,7 @@ export interface ParsedLap {
653
663
  min_heart_rate?: number;
654
664
  active_time?: number;
655
665
  wkt_step_index?: MessageIndex;
666
+ avg_swolf?: number;
656
667
  opponent_score?: number;
657
668
  stroke_count?: (number | null)[];
658
669
  zone_count?: (number | null)[];
@@ -669,11 +680,14 @@ export interface ParsedLap {
669
680
  avg_saturated_hemoglobin_percent?: (number | null)[];
670
681
  min_saturated_hemoglobin_percent?: (number | null)[];
671
682
  max_saturated_hemoglobin_percent?: (number | null)[];
683
+ avg_strokes_per_length?: number;
672
684
  avg_left_torque_effectiveness?: number;
673
685
  avg_right_torque_effectiveness?: number;
674
686
  avg_left_pedal_smoothness?: number;
675
687
  avg_right_pedal_smoothness?: number;
676
688
  avg_combined_pedal_smoothness?: number;
689
+ front_gear_shift_count?: number;
690
+ rear_gear_shift_count?: number;
677
691
  time_standing?: number;
678
692
  stand_count?: number;
679
693
  avg_left_pco?: number;
@@ -710,11 +724,13 @@ export interface ParsedLap {
710
724
  jump_count?: number;
711
725
  avg_grit?: number;
712
726
  avg_flow?: number;
727
+ metabolic_calories?: number;
713
728
  total_fractional_ascent?: number;
714
729
  total_fractional_descent?: number;
715
730
  avg_core_temperature?: number;
716
731
  min_core_temperature?: number;
717
732
  max_core_temperature?: number;
733
+ avg_grade_adjusted_speed?: number;
718
734
  timestamp?: Date;
719
735
  message_index?: MessageIndex;
720
736
  records?: ParsedRecord[];
@@ -1119,6 +1135,10 @@ export interface ParsedMonitoringInfo {
1119
1135
  resting_metabolic_rate?: number;
1120
1136
  timestamp?: Date;
1121
1137
  }
1138
+ export interface ParsedBattery {
1139
+ capacity?: number;
1140
+ timestamp?: Date;
1141
+ }
1122
1142
  export interface ParsedPad {
1123
1143
  }
1124
1144
  export interface ParsedSlaveDevice {
@@ -2007,6 +2027,7 @@ export interface ParsedMessages {
2007
2027
  ant_channel_id?: ParsedAntChannelId[];
2008
2028
  length?: ParsedLength[];
2009
2029
  monitoring_info?: ParsedMonitoringInfo[];
2030
+ battery?: ParsedBattery[];
2010
2031
  pad?: ParsedPad[];
2011
2032
  slave_device?: ParsedSlaveDevice[];
2012
2033
  connectivity?: ParsedConnectivity[];
@@ -2134,6 +2155,7 @@ export interface ParsedFit {
2134
2155
  time_in_zone?: ParsedTimeInZone[];
2135
2156
  activity_metrics?: ParsedActivityMetrics[];
2136
2157
  user_metrics?: ParsedUserMetrics[];
2158
+ batteries?: ParsedBattery[];
2137
2159
  capabilities?: ParsedCapabilities;
2138
2160
  hrm_profile?: ParsedHrmProfile;
2139
2161
  sdm_profile?: ParsedSdmProfile;
@@ -1348,6 +1348,60 @@ exports.PROFILE_MESSAGES = {
1348
1348
  name: 'goal',
1349
1349
  },
1350
1350
  18: {
1351
+ 79: {
1352
+ field: 'avg_strokes_per_length',
1353
+ type: 'uint16',
1354
+ baseType: 'uint16',
1355
+ array: false,
1356
+ scale: 10,
1357
+ offset: 0,
1358
+ units: 'strokes/length',
1359
+ },
1360
+ 80: {
1361
+ field: 'avg_swolf',
1362
+ type: 'uint16',
1363
+ baseType: 'uint16',
1364
+ array: false,
1365
+ scale: 1,
1366
+ offset: 0,
1367
+ units: '',
1368
+ },
1369
+ 106: {
1370
+ field: 'num_segment_laps',
1371
+ type: 'uint16',
1372
+ baseType: 'uint16',
1373
+ array: false,
1374
+ scale: 1,
1375
+ offset: 0,
1376
+ units: '',
1377
+ },
1378
+ 107: {
1379
+ field: 'front_gear_shift_count',
1380
+ type: 'uint16',
1381
+ baseType: 'uint16',
1382
+ array: false,
1383
+ scale: 1,
1384
+ offset: 0,
1385
+ units: '',
1386
+ },
1387
+ 108: {
1388
+ field: 'rear_gear_shift_count',
1389
+ type: 'uint16',
1390
+ baseType: 'uint16',
1391
+ array: false,
1392
+ scale: 1,
1393
+ offset: 0,
1394
+ units: '',
1395
+ },
1396
+ 211: {
1397
+ field: 'avg_grade_adjusted_speed',
1398
+ type: 'uint32',
1399
+ baseType: 'uint32',
1400
+ array: false,
1401
+ scale: 1000,
1402
+ offset: 0,
1403
+ units: 'm/s',
1404
+ },
1351
1405
  0: {
1352
1406
  field: 'event',
1353
1407
  type: 'event',
@@ -2808,6 +2862,96 @@ exports.PROFILE_MESSAGES = {
2808
2862
  name: 'session',
2809
2863
  },
2810
2864
  19: {
2865
+ 27: {
2866
+ field: 'nec_lat',
2867
+ type: 'sint32',
2868
+ baseType: 'sint32',
2869
+ array: false,
2870
+ scale: 1,
2871
+ offset: 0,
2872
+ units: 'semicircles',
2873
+ },
2874
+ 28: {
2875
+ field: 'nec_long',
2876
+ type: 'sint32',
2877
+ baseType: 'sint32',
2878
+ array: false,
2879
+ scale: 1,
2880
+ offset: 0,
2881
+ units: 'semicircles',
2882
+ },
2883
+ 29: {
2884
+ field: 'swc_lat',
2885
+ type: 'sint32',
2886
+ baseType: 'sint32',
2887
+ array: false,
2888
+ scale: 1,
2889
+ offset: 0,
2890
+ units: 'semicircles',
2891
+ },
2892
+ 30: {
2893
+ field: 'swc_long',
2894
+ type: 'sint32',
2895
+ baseType: 'sint32',
2896
+ array: false,
2897
+ scale: 1,
2898
+ offset: 0,
2899
+ units: 'semicircles',
2900
+ },
2901
+ 73: {
2902
+ field: 'avg_swolf',
2903
+ type: 'uint16',
2904
+ baseType: 'uint16',
2905
+ array: false,
2906
+ scale: 1,
2907
+ offset: 0,
2908
+ units: '',
2909
+ },
2910
+ 90: {
2911
+ field: 'avg_strokes_per_length',
2912
+ type: 'uint16',
2913
+ baseType: 'uint16',
2914
+ array: false,
2915
+ scale: 10,
2916
+ offset: 0,
2917
+ units: 'strokes / length',
2918
+ },
2919
+ 96: {
2920
+ field: 'front_gear_shift_count',
2921
+ type: 'uint16',
2922
+ baseType: 'uint16',
2923
+ array: false,
2924
+ scale: 1,
2925
+ offset: 0,
2926
+ units: '',
2927
+ },
2928
+ 97: {
2929
+ field: 'rear_gear_shift_count',
2930
+ type: 'uint16',
2931
+ baseType: 'uint16',
2932
+ array: false,
2933
+ scale: 1,
2934
+ offset: 0,
2935
+ units: '',
2936
+ },
2937
+ 155: {
2938
+ field: 'metabolic_calories',
2939
+ type: 'uint16',
2940
+ baseType: 'uint16',
2941
+ array: false,
2942
+ scale: 1,
2943
+ offset: 0,
2944
+ units: 'kcal',
2945
+ },
2946
+ 161: {
2947
+ field: 'avg_grade_adjusted_speed',
2948
+ type: 'uint32',
2949
+ baseType: 'uint32',
2950
+ array: false,
2951
+ scale: 1000,
2952
+ offset: 0,
2953
+ units: 'm/s',
2954
+ },
2811
2955
  0: {
2812
2956
  field: 'event',
2813
2957
  type: 'event',
@@ -6986,6 +7130,27 @@ exports.PROFILE_MESSAGES = {
6986
7130
  },
6987
7131
  name: 'monitoring_info',
6988
7132
  },
7133
+ 104: {
7134
+ 2: {
7135
+ field: 'capacity',
7136
+ type: 'uint8',
7137
+ baseType: 'uint8',
7138
+ array: false,
7139
+ scale: 1,
7140
+ offset: 0,
7141
+ units: '%',
7142
+ },
7143
+ 253: {
7144
+ field: 'timestamp',
7145
+ type: 'date_time',
7146
+ baseType: 'uint32',
7147
+ array: false,
7148
+ scale: 1,
7149
+ offset: 0,
7150
+ units: '',
7151
+ },
7152
+ name: 'battery',
7153
+ },
6989
7154
  105: {
6990
7155
  name: 'pad',
6991
7156
  },
@@ -15266,6 +15431,7 @@ exports.PROFILE_TYPES = {
15266
15431
  82: 'ant_channel_id',
15267
15432
  101: 'length',
15268
15433
  103: 'monitoring_info',
15434
+ 104: 'battery',
15269
15435
  105: 'pad',
15270
15436
  106: 'slave_device',
15271
15437
  127: 'connectivity',
@@ -261,6 +261,7 @@ function generateFitType() {
261
261
  time_in_zone: 'parsed_time_in_zone',
262
262
  activity_metrics: 'parsed_activity_metrics',
263
263
  user_metrics: 'parsed_user_metrics',
264
+ batteries: 'parsed_battery',
264
265
  };
265
266
  const referenceProperties = {
266
267
  file_creator: 'parsed_file_creator',
@@ -28,5 +28,13 @@ export default class FitParser {
28
28
  private readonly options;
29
29
  constructor(options?: FitParserOptions);
30
30
  parseAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit>;
31
+ /**
32
+ * Parses every complete FIT file in an input, preserving file order and
33
+ * independent decoder state. Rejects incomplete files and trailing bytes;
34
+ * each file uses this parser's options, including its CRC policy.
35
+ * Rejects with an Error when any file cannot be parsed.
36
+ */
37
+ parseFilesAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit[]>;
31
38
  parse(content: ArrayBuffer | Buffer<ArrayBuffer>, callback: FitParserCallback): void;
39
+ private parseFile;
32
40
  }
@@ -1,3 +1,12 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
1
10
  import { calculateCRC, readRecord } from './binary.js';
2
11
  import { mapDataIntoLap, mapDataIntoSession } from './helper.js';
3
12
  export { FitBaseType, FitEncoder } from './fit-encoder.js';
@@ -32,7 +41,71 @@ export default class FitParser {
32
41
  });
33
42
  });
34
43
  }
44
+ /**
45
+ * Parses every complete FIT file in an input, preserving file order and
46
+ * independent decoder state. Rejects incomplete files and trailing bytes;
47
+ * each file uses this parser's options, including its CRC policy.
48
+ * Rejects with an Error when any file cannot be parsed.
49
+ */
50
+ parseFilesAsync(content) {
51
+ return __awaiter(this, void 0, void 0, function* () {
52
+ const blob = content instanceof ArrayBuffer
53
+ ? new Uint8Array(content)
54
+ : new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
55
+ const results = [];
56
+ let start = 0;
57
+ do {
58
+ const remaining = blob.length - start;
59
+ if (remaining < 12) {
60
+ throw new Error('File to small to be a FIT file');
61
+ }
62
+ const headerLength = blob[start];
63
+ if (headerLength !== 12 && headerLength !== 14) {
64
+ throw new Error('Incorrect header size');
65
+ }
66
+ if (remaining < headerLength) {
67
+ throw new Error('File to small to be a FIT file');
68
+ }
69
+ if (blob[start + 8] !== 0x2E
70
+ || blob[start + 9] !== 0x46
71
+ || blob[start + 10] !== 0x49
72
+ || blob[start + 11] !== 0x54) {
73
+ throw new Error('Missing \'.FIT\' in header');
74
+ }
75
+ const dataLength = new DataView(blob.buffer, blob.byteOffset + start, remaining)
76
+ .getUint32(4, true);
77
+ const crcStart = start + headerLength + dataLength;
78
+ if (crcStart > blob.length) {
79
+ throw new Error('File data exceeds input length');
80
+ }
81
+ if (crcStart + 2 > blob.length) {
82
+ throw new Error('File CRC missing');
83
+ }
84
+ const end = crcStart + 2;
85
+ try {
86
+ results.push(yield new Promise((resolve, reject) => {
87
+ this.parseFile(blob.slice(start, end).buffer, (error, data) => {
88
+ if (error) {
89
+ reject(error);
90
+ }
91
+ else if (data) {
92
+ resolve(data);
93
+ }
94
+ }, true);
95
+ }));
96
+ }
97
+ catch (error) {
98
+ throw error instanceof Error ? error : new Error(String(error));
99
+ }
100
+ start = end;
101
+ } while (start < blob.length);
102
+ return results;
103
+ });
104
+ }
35
105
  parse(content, callback) {
106
+ this.parseFile(content, callback);
107
+ }
108
+ parseFile(content, callback, validateRecordBounds = false) {
36
109
  var _a, _b, _c;
37
110
  const blob = content instanceof ArrayBuffer
38
111
  ? new Uint8Array(content)
@@ -92,6 +165,7 @@ export default class FitParser {
92
165
  protocolVersion,
93
166
  };
94
167
  const messages = {};
168
+ const batteries = [];
95
169
  let sessions = [];
96
170
  let laps = [];
97
171
  const records = [];
@@ -141,6 +215,12 @@ export default class FitParser {
141
215
  let pausedTime = 0;
142
216
  while (loopIndex < crcStart) {
143
217
  const { globalMessageNumber, littleEndian, message, messageType, nextIndex, compressedTimestamp, rawFields: recordRawFields, rawDeveloperFields: recordRawDeveloperFields, unmappedFields: recordUnmappedFields, unmappedDeveloperFields: recordUnmappedDeveloperFields, } = readRecord(blob, messageTypes, developerFields, loopIndex, this.options, startDate, pausedTime, dataView, decoderState, crcStart);
218
+ // Complete-file parsing also validates bounds without requiring raw
219
+ // retention. The single-file API keeps its existing recovery behavior.
220
+ if (validateRecordBounds
221
+ && (!Number.isInteger(nextIndex) || nextIndex <= loopIndex || nextIndex > crcStart)) {
222
+ throw new Error('Invalid FIT record bounds');
223
+ }
144
224
  loopIndex = nextIndex;
145
225
  if (globalMessageNumber !== undefined) {
146
226
  const messageIndex = (_a = messageCountsByGlobalNumber.get(globalMessageNumber)) !== null && _a !== void 0 ? _a : 0;
@@ -191,6 +271,9 @@ export default class FitParser {
191
271
  ((_b = messages[messageType]) !== null && _b !== void 0 ? _b : (messages[messageType] = [])).push(message);
192
272
  }
193
273
  switch (messageType) {
274
+ case 'battery':
275
+ batteries.push(message);
276
+ break;
194
277
  case 'lap':
195
278
  laps.push(message);
196
279
  break;
@@ -335,6 +418,7 @@ export default class FitParser {
335
418
  fitObj.time_in_zone = time_in_zone;
336
419
  fitObj.activity_metrics = activity_metrics;
337
420
  fitObj.user_metrics = user_metrics;
421
+ fitObj.batteries = batteries;
338
422
  fitObj.messages = messages;
339
423
  if (rawDeveloperFields) {
340
424
  fitObj.raw_developer_fields = rawDeveloperFields;
@@ -156,7 +156,7 @@ export type MaxMetCategory = 'generic' | 'cycling' | number;
156
156
  export type MaxMetHeartRateSource = 'whr' | 'hrm' | number;
157
157
  export type MaxMetSpeedSource = 'onboard_gps' | 'connected_gps' | 'cadence' | number;
158
158
  export type MesgCount = 'num_per_file' | 'max_per_file' | 'max_per_file_type' | number;
159
- export type MesgNum = 'file_id' | 'capabilities' | 'device_settings' | 'user_profile' | 'hrm_profile' | 'sdm_profile' | 'bike_profile' | 'zones_target' | 'hr_zone' | 'power_zone' | 'met_zone' | 'sport' | 'training_settings' | 'goal' | 'session' | 'lap' | 'record' | 'event' | 'device_info' | 'workout' | 'workout_step' | 'schedule' | 'weight_scale' | 'course' | 'course_point' | 'totals' | 'activity' | 'software' | 'file_capabilities' | 'mesg_capabilities' | 'field_capabilities' | 'file_creator' | 'blood_pressure' | 'speed_zone' | 'monitoring' | 'training_file' | 'hrv' | 'user_metrics' | 'ant_rx' | 'ant_tx' | 'ant_channel_id' | 'length' | 'monitoring_info' | 'pad' | 'slave_device' | 'connectivity' | 'weather_conditions' | 'weather_alert' | 'cadence_zone' | 'hr' | 'activity_metrics' | 'segment_lap' | 'memo_glob' | 'segment_id' | 'segment_leaderboard_entry' | 'segment_point' | 'segment_file' | 'workout_session' | 'watchface_settings' | 'gps_metadata' | 'camera_event' | 'timestamp_correlation' | 'gyroscope_data' | 'accelerometer_data' | 'three_d_sensor_calibration' | 'video_frame' | 'obdii_data' | 'nmea_sentence' | 'aviation_attitude' | 'video' | 'video_title' | 'video_description' | 'video_clip' | 'ohr_settings' | 'exd_screen_configuration' | 'exd_data_field_configuration' | 'exd_data_concept_configuration' | 'field_description' | 'developer_data_id' | 'magnetometer_data' | 'barometer_data' | 'one_d_sensor_calibration' | 'monitoring_hr_data' | 'time_in_zone' | 'set' | 'stress_level' | 'max_met_data' | 'dive_settings' | 'dive_gas' | 'dive_alarm' | 'exercise_title' | 'dive_summary' | 'spo2_data' | 'sleep_level' | 'jump' | 'aad_accel_features' | 'beat_intervals' | 'respiration_rate' | 'hsa_accelerometer_data' | 'hsa_step_data' | 'hsa_spo2_data' | 'hsa_stress_data' | 'hsa_respiration_data' | 'hsa_heart_rate_data' | 'split' | 'split_summary' | 'hsa_body_battery_data' | 'hsa_event' | 'climb_pro' | 'tank_update' | 'tank_summary' | 'sleep_assessment' | 'hrv_status_summary' | 'hrv_value' | 'raw_bbi' | 'device_aux_battery_info' | 'hsa_gyroscope_data' | 'chrono_shot_session' | 'chrono_shot_data' | 'hsa_configuration_data' | 'dive_apnea_alarm' | 'skin_temp_overnight' | 'hsa_wrist_temperature_data' | 'nap_event' | 'sleep_disruption_severity_period' | 'sleep_disruption_overnight_severity' | 'mfg_range_min' | 'mfg_range_max' | 'definition' | number;
159
+ export type MesgNum = 'file_id' | 'capabilities' | 'device_settings' | 'user_profile' | 'hrm_profile' | 'sdm_profile' | 'bike_profile' | 'zones_target' | 'hr_zone' | 'power_zone' | 'met_zone' | 'sport' | 'training_settings' | 'goal' | 'session' | 'lap' | 'record' | 'event' | 'device_info' | 'workout' | 'workout_step' | 'schedule' | 'weight_scale' | 'course' | 'course_point' | 'totals' | 'activity' | 'software' | 'file_capabilities' | 'mesg_capabilities' | 'field_capabilities' | 'file_creator' | 'blood_pressure' | 'speed_zone' | 'monitoring' | 'training_file' | 'hrv' | 'user_metrics' | 'ant_rx' | 'ant_tx' | 'ant_channel_id' | 'length' | 'monitoring_info' | 'battery' | 'pad' | 'slave_device' | 'connectivity' | 'weather_conditions' | 'weather_alert' | 'cadence_zone' | 'hr' | 'activity_metrics' | 'segment_lap' | 'memo_glob' | 'segment_id' | 'segment_leaderboard_entry' | 'segment_point' | 'segment_file' | 'workout_session' | 'watchface_settings' | 'gps_metadata' | 'camera_event' | 'timestamp_correlation' | 'gyroscope_data' | 'accelerometer_data' | 'three_d_sensor_calibration' | 'video_frame' | 'obdii_data' | 'nmea_sentence' | 'aviation_attitude' | 'video' | 'video_title' | 'video_description' | 'video_clip' | 'ohr_settings' | 'exd_screen_configuration' | 'exd_data_field_configuration' | 'exd_data_concept_configuration' | 'field_description' | 'developer_data_id' | 'magnetometer_data' | 'barometer_data' | 'one_d_sensor_calibration' | 'monitoring_hr_data' | 'time_in_zone' | 'set' | 'stress_level' | 'max_met_data' | 'dive_settings' | 'dive_gas' | 'dive_alarm' | 'exercise_title' | 'dive_summary' | 'spo2_data' | 'sleep_level' | 'jump' | 'aad_accel_features' | 'beat_intervals' | 'respiration_rate' | 'hsa_accelerometer_data' | 'hsa_step_data' | 'hsa_spo2_data' | 'hsa_stress_data' | 'hsa_respiration_data' | 'hsa_heart_rate_data' | 'split' | 'split_summary' | 'hsa_body_battery_data' | 'hsa_event' | 'climb_pro' | 'tank_update' | 'tank_summary' | 'sleep_assessment' | 'hrv_status_summary' | 'hrv_value' | 'raw_bbi' | 'device_aux_battery_info' | 'hsa_gyroscope_data' | 'chrono_shot_session' | 'chrono_shot_data' | 'hsa_configuration_data' | 'dive_apnea_alarm' | 'skin_temp_overnight' | 'hsa_wrist_temperature_data' | 'nap_event' | 'sleep_disruption_severity_period' | 'sleep_disruption_overnight_severity' | 'mfg_range_min' | 'mfg_range_max' | 'definition' | number;
160
160
  export interface MessageIndex {
161
161
  value: number;
162
162
  reserved: boolean;
@@ -498,6 +498,8 @@ export interface ParsedSession {
498
498
  best_lap_index?: number;
499
499
  min_altitude?: number;
500
500
  active_time?: number;
501
+ avg_strokes_per_length?: number;
502
+ avg_swolf?: number;
501
503
  player_score?: number;
502
504
  opponent_score?: number;
503
505
  opponent_name?: string;
@@ -522,6 +524,9 @@ export interface ParsedSession {
522
524
  avg_left_pedal_smoothness?: number;
523
525
  avg_right_pedal_smoothness?: number;
524
526
  avg_combined_pedal_smoothness?: number;
527
+ num_segment_laps?: number;
528
+ front_gear_shift_count?: number;
529
+ rear_gear_shift_count?: number;
525
530
  sport_profile_name?: string;
526
531
  sport_index?: number;
527
532
  time_standing?: number;
@@ -588,6 +593,7 @@ export interface ParsedSession {
588
593
  avg_core_temperature?: number;
589
594
  min_core_temperature?: number;
590
595
  max_core_temperature?: number;
596
+ avg_grade_adjusted_speed?: number;
591
597
  timestamp?: Date;
592
598
  message_index?: MessageIndex;
593
599
  laps?: ParsedLap[];
@@ -620,6 +626,10 @@ export interface ParsedLap {
620
626
  lap_trigger?: LapTrigger;
621
627
  sport?: Sport;
622
628
  event_group?: number;
629
+ nec_lat?: number;
630
+ nec_long?: number;
631
+ swc_lat?: number;
632
+ swc_long?: number;
623
633
  num_lengths?: number;
624
634
  normalized_power?: number;
625
635
  left_right_balance?: LeftRightBalance100;
@@ -653,6 +663,7 @@ export interface ParsedLap {
653
663
  min_heart_rate?: number;
654
664
  active_time?: number;
655
665
  wkt_step_index?: MessageIndex;
666
+ avg_swolf?: number;
656
667
  opponent_score?: number;
657
668
  stroke_count?: (number | null)[];
658
669
  zone_count?: (number | null)[];
@@ -669,11 +680,14 @@ export interface ParsedLap {
669
680
  avg_saturated_hemoglobin_percent?: (number | null)[];
670
681
  min_saturated_hemoglobin_percent?: (number | null)[];
671
682
  max_saturated_hemoglobin_percent?: (number | null)[];
683
+ avg_strokes_per_length?: number;
672
684
  avg_left_torque_effectiveness?: number;
673
685
  avg_right_torque_effectiveness?: number;
674
686
  avg_left_pedal_smoothness?: number;
675
687
  avg_right_pedal_smoothness?: number;
676
688
  avg_combined_pedal_smoothness?: number;
689
+ front_gear_shift_count?: number;
690
+ rear_gear_shift_count?: number;
677
691
  time_standing?: number;
678
692
  stand_count?: number;
679
693
  avg_left_pco?: number;
@@ -710,11 +724,13 @@ export interface ParsedLap {
710
724
  jump_count?: number;
711
725
  avg_grit?: number;
712
726
  avg_flow?: number;
727
+ metabolic_calories?: number;
713
728
  total_fractional_ascent?: number;
714
729
  total_fractional_descent?: number;
715
730
  avg_core_temperature?: number;
716
731
  min_core_temperature?: number;
717
732
  max_core_temperature?: number;
733
+ avg_grade_adjusted_speed?: number;
718
734
  timestamp?: Date;
719
735
  message_index?: MessageIndex;
720
736
  records?: ParsedRecord[];
@@ -1119,6 +1135,10 @@ export interface ParsedMonitoringInfo {
1119
1135
  resting_metabolic_rate?: number;
1120
1136
  timestamp?: Date;
1121
1137
  }
1138
+ export interface ParsedBattery {
1139
+ capacity?: number;
1140
+ timestamp?: Date;
1141
+ }
1122
1142
  export interface ParsedPad {
1123
1143
  }
1124
1144
  export interface ParsedSlaveDevice {
@@ -2007,6 +2027,7 @@ export interface ParsedMessages {
2007
2027
  ant_channel_id?: ParsedAntChannelId[];
2008
2028
  length?: ParsedLength[];
2009
2029
  monitoring_info?: ParsedMonitoringInfo[];
2030
+ battery?: ParsedBattery[];
2010
2031
  pad?: ParsedPad[];
2011
2032
  slave_device?: ParsedSlaveDevice[];
2012
2033
  connectivity?: ParsedConnectivity[];
@@ -2134,6 +2155,7 @@ export interface ParsedFit {
2134
2155
  time_in_zone?: ParsedTimeInZone[];
2135
2156
  activity_metrics?: ParsedActivityMetrics[];
2136
2157
  user_metrics?: ParsedUserMetrics[];
2158
+ batteries?: ParsedBattery[];
2137
2159
  capabilities?: ParsedCapabilities;
2138
2160
  hrm_profile?: ParsedHrmProfile;
2139
2161
  sdm_profile?: ParsedSdmProfile;
package/dist/profile.js CHANGED
@@ -1345,6 +1345,60 @@ export const PROFILE_MESSAGES = {
1345
1345
  name: 'goal',
1346
1346
  },
1347
1347
  18: {
1348
+ 79: {
1349
+ field: 'avg_strokes_per_length',
1350
+ type: 'uint16',
1351
+ baseType: 'uint16',
1352
+ array: false,
1353
+ scale: 10,
1354
+ offset: 0,
1355
+ units: 'strokes/length',
1356
+ },
1357
+ 80: {
1358
+ field: 'avg_swolf',
1359
+ type: 'uint16',
1360
+ baseType: 'uint16',
1361
+ array: false,
1362
+ scale: 1,
1363
+ offset: 0,
1364
+ units: '',
1365
+ },
1366
+ 106: {
1367
+ field: 'num_segment_laps',
1368
+ type: 'uint16',
1369
+ baseType: 'uint16',
1370
+ array: false,
1371
+ scale: 1,
1372
+ offset: 0,
1373
+ units: '',
1374
+ },
1375
+ 107: {
1376
+ field: 'front_gear_shift_count',
1377
+ type: 'uint16',
1378
+ baseType: 'uint16',
1379
+ array: false,
1380
+ scale: 1,
1381
+ offset: 0,
1382
+ units: '',
1383
+ },
1384
+ 108: {
1385
+ field: 'rear_gear_shift_count',
1386
+ type: 'uint16',
1387
+ baseType: 'uint16',
1388
+ array: false,
1389
+ scale: 1,
1390
+ offset: 0,
1391
+ units: '',
1392
+ },
1393
+ 211: {
1394
+ field: 'avg_grade_adjusted_speed',
1395
+ type: 'uint32',
1396
+ baseType: 'uint32',
1397
+ array: false,
1398
+ scale: 1000,
1399
+ offset: 0,
1400
+ units: 'm/s',
1401
+ },
1348
1402
  0: {
1349
1403
  field: 'event',
1350
1404
  type: 'event',
@@ -2805,6 +2859,96 @@ export const PROFILE_MESSAGES = {
2805
2859
  name: 'session',
2806
2860
  },
2807
2861
  19: {
2862
+ 27: {
2863
+ field: 'nec_lat',
2864
+ type: 'sint32',
2865
+ baseType: 'sint32',
2866
+ array: false,
2867
+ scale: 1,
2868
+ offset: 0,
2869
+ units: 'semicircles',
2870
+ },
2871
+ 28: {
2872
+ field: 'nec_long',
2873
+ type: 'sint32',
2874
+ baseType: 'sint32',
2875
+ array: false,
2876
+ scale: 1,
2877
+ offset: 0,
2878
+ units: 'semicircles',
2879
+ },
2880
+ 29: {
2881
+ field: 'swc_lat',
2882
+ type: 'sint32',
2883
+ baseType: 'sint32',
2884
+ array: false,
2885
+ scale: 1,
2886
+ offset: 0,
2887
+ units: 'semicircles',
2888
+ },
2889
+ 30: {
2890
+ field: 'swc_long',
2891
+ type: 'sint32',
2892
+ baseType: 'sint32',
2893
+ array: false,
2894
+ scale: 1,
2895
+ offset: 0,
2896
+ units: 'semicircles',
2897
+ },
2898
+ 73: {
2899
+ field: 'avg_swolf',
2900
+ type: 'uint16',
2901
+ baseType: 'uint16',
2902
+ array: false,
2903
+ scale: 1,
2904
+ offset: 0,
2905
+ units: '',
2906
+ },
2907
+ 90: {
2908
+ field: 'avg_strokes_per_length',
2909
+ type: 'uint16',
2910
+ baseType: 'uint16',
2911
+ array: false,
2912
+ scale: 10,
2913
+ offset: 0,
2914
+ units: 'strokes / length',
2915
+ },
2916
+ 96: {
2917
+ field: 'front_gear_shift_count',
2918
+ type: 'uint16',
2919
+ baseType: 'uint16',
2920
+ array: false,
2921
+ scale: 1,
2922
+ offset: 0,
2923
+ units: '',
2924
+ },
2925
+ 97: {
2926
+ field: 'rear_gear_shift_count',
2927
+ type: 'uint16',
2928
+ baseType: 'uint16',
2929
+ array: false,
2930
+ scale: 1,
2931
+ offset: 0,
2932
+ units: '',
2933
+ },
2934
+ 155: {
2935
+ field: 'metabolic_calories',
2936
+ type: 'uint16',
2937
+ baseType: 'uint16',
2938
+ array: false,
2939
+ scale: 1,
2940
+ offset: 0,
2941
+ units: 'kcal',
2942
+ },
2943
+ 161: {
2944
+ field: 'avg_grade_adjusted_speed',
2945
+ type: 'uint32',
2946
+ baseType: 'uint32',
2947
+ array: false,
2948
+ scale: 1000,
2949
+ offset: 0,
2950
+ units: 'm/s',
2951
+ },
2808
2952
  0: {
2809
2953
  field: 'event',
2810
2954
  type: 'event',
@@ -6983,6 +7127,27 @@ export const PROFILE_MESSAGES = {
6983
7127
  },
6984
7128
  name: 'monitoring_info',
6985
7129
  },
7130
+ 104: {
7131
+ 2: {
7132
+ field: 'capacity',
7133
+ type: 'uint8',
7134
+ baseType: 'uint8',
7135
+ array: false,
7136
+ scale: 1,
7137
+ offset: 0,
7138
+ units: '%',
7139
+ },
7140
+ 253: {
7141
+ field: 'timestamp',
7142
+ type: 'date_time',
7143
+ baseType: 'uint32',
7144
+ array: false,
7145
+ scale: 1,
7146
+ offset: 0,
7147
+ units: '',
7148
+ },
7149
+ name: 'battery',
7150
+ },
6986
7151
  105: {
6987
7152
  name: 'pad',
6988
7153
  },
@@ -15263,6 +15428,7 @@ export const PROFILE_TYPES = {
15263
15428
  82: 'ant_channel_id',
15264
15429
  101: 'length',
15265
15430
  103: 'monitoring_info',
15431
+ 104: 'battery',
15266
15432
  105: 'pad',
15267
15433
  106: 'slave_device',
15268
15434
  127: 'connectivity',
@@ -240,6 +240,7 @@ export function generateFitType() {
240
240
  time_in_zone: 'parsed_time_in_zone',
241
241
  activity_metrics: 'parsed_activity_metrics',
242
242
  user_metrics: 'parsed_user_metrics',
243
+ batteries: 'parsed_battery',
243
244
  };
244
245
  const referenceProperties = {
245
246
  file_creator: 'parsed_file_creator',
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "fit-file-parser",
3
3
  "type": "module",
4
- "version": "6.1.2",
4
+ "version": "6.2.0",
5
5
  "private": false,
6
- "description": "Parse your .FIT files easily, directly from JS (Garmin, Polar, Suunto)",
6
+ "description": "JavaScript and TypeScript library for parsing and encoding FIT files, with developer-field support, raw message access, and ESM/CommonJS builds.",
7
7
  "author": {
8
8
  "name": "jimmykane"
9
9
  },
@@ -24,13 +24,22 @@
24
24
  "url": "git+ssh://git@github.com/jimmykane/fit-parser.git"
25
25
  },
26
26
  "bugs": {
27
- "url": "https://github.com/jimmykane/fit-parser"
27
+ "url": "https://github.com/jimmykane/fit-parser/issues"
28
28
  },
29
29
  "keywords": [
30
30
  "fit",
31
- "ant",
31
+ "fit-file",
32
+ "fit-parser",
33
+ "fit-file-parsing",
34
+ "fit-encoder",
35
+ "javascript",
36
+ "typescript",
37
+ "nodejs",
38
+ "fitness",
32
39
  "garmin",
33
- "parse"
40
+ "polar",
41
+ "suunto",
42
+ "binary-parser"
34
43
  ],
35
44
  "exports": {
36
45
  ".": {