fit-file-parser 6.1.2 → 6.2.1

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,61 @@
1
1
  # Change Log
2
2
 
3
+ ## 6.2.1 - 2026-10-10
4
+
5
+ ### Added
6
+
7
+ - Add the opt-in `strict` parser option to require complete files and records,
8
+ valid definitions, and no trailing bytes in every parsing API. Checksum
9
+ validation remains controlled by `force`.
10
+
11
+ ### Fixed
12
+
13
+ - Apply the same recovery policy to `parseFilesAsync()`, `parseAsync()`, and
14
+ callback parsing. The all-file API now accepts missing CRC bytes with
15
+ `force: true` when `strict` is disabled, and ignores a non-FIT suffix.
16
+ - Keep existing decoder behavior, recovery, and output when `strict` is not
17
+ requested, including handling of malformed records and definitions.
18
+ - Apply additional definition and record bounds checks only in strict mode.
19
+
20
+ ### Compatibility
21
+
22
+ - `force` still defaults to `true`; `strict` defaults to `false`.
23
+ - `strict: true` requires exactly one file for `parseAsync()` and callback
24
+ parsing, or complete concatenated files for `parseFilesAsync()`.
25
+ - Temperature mappings and unit conversions are unchanged.
26
+
27
+ ## 6.2.0 - 2026-10-10
28
+
29
+ ### Added
30
+
31
+ - Add `parseFilesAsync()` to return every complete FIT file in concatenated
32
+ input as a separate result, with independent decoder state and file order.
33
+ - Decode documented session and lap swimming summaries, segment and gear-shift
34
+ counts, lap bounds, metabolic calories, and grade-adjusted speed.
35
+ - Retain every battery sample in `batteries` and `messages.battery`, including
36
+ timestamp and capacity percentage when present.
37
+ - Add `--all-files` to the external corpus checker to validate appended files.
38
+
39
+ ### Fixed
40
+
41
+ - Preserve exact bytes for developer fields without usable names in opt-in
42
+ `unmapped_messages`, including fields whose descriptions have been cached.
43
+ - Recover unsigned-byte record temperatures from 0 through 126 with normal
44
+ unit conversion, while retaining their original bytes in opt-in output.
45
+ - Reject records and definitions extending beyond the declared data section
46
+ when using the complete-file `parseFilesAsync()` API.
47
+
48
+ ### Compatibility
49
+
50
+ - Existing `parseAsync()` and callback APIs retain their single-file output
51
+ and recovery behavior. Newly supported fields and battery collections are
52
+ additive.
53
+ - `parseFilesAsync()` returns a one-element array for ordinary FIT input and
54
+ rejects incomplete files or trailing non-FIT data, even with `force: true`.
55
+ CRC validation follows the caller's `force` option for every file.
56
+ - Unsigned temperature bytes from 127 through 255 and other undocumented or
57
+ incompatible fields remain undecoded and available through opt-in retention.
58
+
3
59
  ## 6.1.2 - 2026-09-22
4
60
 
5
61
  ### 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,38 @@ 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. All parsing APIs share the same `force` and
81
+ `strict` options. By default, complete fields in a partial final record can be
82
+ recovered, and a non-FIT suffix after parsed files is ignored. With `force: true`,
83
+ missing CRC bytes are also accepted, including when the next FIT header starts
84
+ at the declared data boundary or after one CRC byte. The parser never scans
85
+ record payloads for guessed file boundaries.
86
+
87
+ Use `{ force: false, strict: true }` to require complete files and records,
88
+ valid checksums, and no trailing bytes. `{ force: true, strict: true }` enforces
89
+ the same structure while tolerating known checksum failures. Any file that
90
+ cannot be parsed rejects the whole call with an `Error`. Empty input, invalid
91
+ headers, and declared data beyond the available input
92
+ remain errors in every mode.
93
+
94
+ The existing `parseAsync()` and callback APIs return the first FIT file only.
95
+ With `strict: true`, they require exactly one file; use `parseFilesAsync()` for
96
+ strict validation of concatenated input.
97
+
65
98
  ### Callback API
66
99
 
67
100
  ```javascript
@@ -86,8 +119,9 @@ readFile('./activity.fit', (readError, content) => {
86
119
  })
87
120
  ```
88
121
 
89
- Parser errors are strings. `parseAsync()` rejects with the same value that the
90
- callback API receives as its first argument.
122
+ The callback reports validation errors as strings. `parseAsync()` rejects with
123
+ those values; decoder failures can reject with an `Error`. `parseFilesAsync()`
124
+ consistently rejects with an `Error`.
91
125
 
92
126
  ## Parser options
93
127
 
@@ -97,6 +131,7 @@ All options are optional.
97
131
  | --------------------------- | --------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
98
132
  | `mode` | `list`, `cascade`, `both` | `list` | Controls whether primary activity collections are returned as root lists, nested data, or both. |
99
133
  | `force` | `true`, `false` | `true` | Skips header and file CRC validation and enables supported best-effort field recovery. Structural header and data bounds are always validated. |
134
+ | `strict` | `true`, `false` | `false` | Requires CRC bytes, complete records, valid definitions, and no trailing bytes. CRC validation remains controlled by `force`. |
100
135
  | `speedUnit` | `m/s`, `km/h`, `mph` | `m/s` | Converts speed-related fields. |
101
136
  | `lengthUnit` | `m`, `km`, `mi` | `m` | Converts distance, altitude, and other length-related fields. |
102
137
  | `temperatureUnit` | `celsius`, `°C`, `kelvin`, `fahrenheit` | `celsius` | Converts temperature fields. `°C` remains available as a legacy alias. |
@@ -110,6 +145,24 @@ All options are optional.
110
145
  too short, have an invalid header size or signature, or declare data beyond the
111
146
  available bytes are rejected in both modes.
112
147
 
148
+ `strict: false` keeps the existing decoder behavior, including its handling of
149
+ malformed records and definitions. `parseAsync()` and callback callers keep
150
+ their current defaults, recovery, and output. `parseFilesAsync()` uses that
151
+ same decoder independently for each file.
152
+
153
+ `strict: true` adds definition and record bounds checks so CRC bytes cannot be
154
+ decoded as measurements. Full raw-message retention (`includeRawMessages`)
155
+ continues to require complete records as it did before this option was added.
156
+
157
+ Choose options according to the input you accept:
158
+
159
+ | Options | Policy |
160
+ | -------------------------------- | --------------------------------------------------------- |
161
+ | Default | Best effort, including missing or invalid CRCs. |
162
+ | `{ force: false }` | Validate CRCs while keeping existing field recovery. |
163
+ | `{ force: true, strict: true }` | Require complete structure while tolerating invalid CRCs. |
164
+ | `{ force: false, strict: true }` | Require complete structure and valid CRCs. |
165
+
113
166
  ### Lossless developer fields
114
167
 
115
168
  By default, developer fields retain the existing decoded, name-keyed output.
@@ -148,8 +201,8 @@ message numbers.
148
201
 
149
202
  Set `includeUnmappedMessages: true` to return fields that are absent from the
150
203
  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:
204
+ `unmapped_messages`. Fields without usable developer names are also retained.
205
+ Normal known fields are not duplicated:
153
206
 
154
207
  ```javascript
155
208
  const data = await new FitParser({ includeUnmappedMessages: true })
@@ -169,6 +222,20 @@ Each entry uses the same wire-level field representation as `raw_messages`.
169
222
  This opt-in ensures a newer FIT field cannot disappear merely because it
170
223
  does not yet have a reviewed semantic mapping.
171
224
 
225
+ For record temperature encoded as one unsigned byte, values from 0 through
226
+ 126 can be decoded as Celsius before the selected temperature conversion.
227
+ Values from 127 through 255 remain undecoded. This narrow interoperability
228
+ recovery preserves the original unsigned byte in `unmapped_messages` when
229
+ enabled, including values that also receive a decoded temperature.
230
+
231
+ ### Battery samples
232
+
233
+ `batteries` retains every battery message in file order, including its
234
+ timestamp and capacity percentage when present. These samples are also
235
+ available in `messages.battery`. Undocumented battery fields remain available
236
+ through `includeUnmappedMessages`; the parser does not infer their device
237
+ association or calculate battery consumption.
238
+
172
239
  ### Lossless selected messages
173
240
 
174
241
  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':
@@ -423,14 +424,14 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
423
424
  else if ((recordHeader & 64) === 64) {
424
425
  // is definition message
425
426
  // startIndex + 1 is reserved
426
- if (retainsRawMessages(options) && startIndex + 6 > dataEnd) {
427
+ if ((options.strict || retainsRawMessages(options)) && startIndex + 6 > dataEnd) {
427
428
  throw new Error('Invalid FIT definition bounds');
428
429
  }
429
430
  const hasDeveloperData = (recordHeader & 32) === 32;
430
431
  const lEnd = blob[startIndex + 2] === 0;
431
432
  const numberOfFields = blob[startIndex + 5];
432
433
  const nativeDefinitionsEnd = startIndex + 6 + numberOfFields * 3;
433
- if (retainsRawMessages(options)
434
+ if ((options.strict || retainsRawMessages(options))
434
435
  && ((recordHeader & 16) !== 0
435
436
  || blob[startIndex + 1] !== 0
436
437
  || blob[startIndex + 2] > 1
@@ -443,7 +444,7 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
443
444
  : 0;
444
445
  const definitionEnd = nativeDefinitionsEnd
445
446
  + (hasDeveloperData ? 1 + numberOfDeveloperDataFields * 3 : 0);
446
- if (retainsRawMessages(options) && definitionEnd > dataEnd) {
447
+ if ((options.strict || retainsRawMessages(options)) && definitionEnd > dataEnd) {
447
448
  throw new Error('Invalid FIT developer definition bounds');
448
449
  }
449
450
  const mTypeDef = {
@@ -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,
@@ -524,7 +530,7 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
524
530
  const rawDeveloperFields = includeRawDeveloperFields || includeRawMessage ? [] : undefined;
525
531
  const unmappedFields = options.includeUnmappedMessages ? [] : undefined;
526
532
  const unmappedDeveloperFields = options.includeUnmappedMessages ? [] : undefined;
527
- if (retainsRawMessages(options)) {
533
+ if (options.strict || retainsRawMessages(options)) {
528
534
  const nativeSize = messageType.fieldDefs.reduce((total, field, index) => (total + (isCompressedTimestamp && index === 0 && field.fDefNo === 253 ? 0 : field.size)), 0);
529
535
  const developerSize = developerFieldDefs.reduce((total, field) => total + field.size, 0);
530
536
  if (startIndex + 1 + nativeSize + developerSize > dataEnd) {
@@ -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);
@@ -652,7 +665,7 @@ export function readRecord(blob, messageTypes, developerFields, startIndex, opti
652
665
  if (isCompressedTimestamp) {
653
666
  const previousTimestamp = decoderState.lastTimestamp;
654
667
  if (previousTimestamp === undefined) {
655
- if (!options.force) {
668
+ if (!options.force || options.strict) {
656
669
  throw new Error('Compressed timestamp requires a previous timestamp');
657
670
  }
658
671
  }
@@ -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':
@@ -429,14 +430,14 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
429
430
  else if ((recordHeader & 64) === 64) {
430
431
  // is definition message
431
432
  // startIndex + 1 is reserved
432
- if (retainsRawMessages(options) && startIndex + 6 > dataEnd) {
433
+ if ((options.strict || retainsRawMessages(options)) && startIndex + 6 > dataEnd) {
433
434
  throw new Error('Invalid FIT definition bounds');
434
435
  }
435
436
  const hasDeveloperData = (recordHeader & 32) === 32;
436
437
  const lEnd = blob[startIndex + 2] === 0;
437
438
  const numberOfFields = blob[startIndex + 5];
438
439
  const nativeDefinitionsEnd = startIndex + 6 + numberOfFields * 3;
439
- if (retainsRawMessages(options)
440
+ if ((options.strict || retainsRawMessages(options))
440
441
  && ((recordHeader & 16) !== 0
441
442
  || blob[startIndex + 1] !== 0
442
443
  || blob[startIndex + 2] > 1
@@ -449,7 +450,7 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
449
450
  : 0;
450
451
  const definitionEnd = nativeDefinitionsEnd
451
452
  + (hasDeveloperData ? 1 + numberOfDeveloperDataFields * 3 : 0);
452
- if (retainsRawMessages(options) && definitionEnd > dataEnd) {
453
+ if ((options.strict || retainsRawMessages(options)) && definitionEnd > dataEnd) {
453
454
  throw new Error('Invalid FIT developer definition bounds');
454
455
  }
455
456
  const mTypeDef = {
@@ -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,
@@ -530,7 +536,7 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
530
536
  const rawDeveloperFields = includeRawDeveloperFields || includeRawMessage ? [] : undefined;
531
537
  const unmappedFields = options.includeUnmappedMessages ? [] : undefined;
532
538
  const unmappedDeveloperFields = options.includeUnmappedMessages ? [] : undefined;
533
- if (retainsRawMessages(options)) {
539
+ if (options.strict || retainsRawMessages(options)) {
534
540
  const nativeSize = messageType.fieldDefs.reduce((total, field, index) => (total + (isCompressedTimestamp && index === 0 && field.fDefNo === 253 ? 0 : field.size)), 0);
535
541
  const developerSize = developerFieldDefs.reduce((total, field) => total + field.size, 0);
536
542
  if (startIndex + 1 + nativeSize + developerSize > dataEnd) {
@@ -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);
@@ -658,7 +671,7 @@ function readRecord(blob, messageTypes, developerFields, startIndex, options, st
658
671
  if (isCompressedTimestamp) {
659
672
  const previousTimestamp = decoderState.lastTimestamp;
660
673
  if (previousTimestamp === undefined) {
661
- if (!options.force) {
674
+ if (!options.force || options.strict) {
662
675
  throw new Error('Compressed timestamp requires a previous timestamp');
663
676
  }
664
677
  }
@@ -7,7 +7,10 @@ export { getFitCoursePointId, getFitGarminProductDisplayName, getFitGarminProduc
7
7
  export { FitMessageReaderError, fitTimestampToUnixMilliseconds, getFitBaseTypeId, readFitMessages, readFitStringField, readFitUnsignedField, } from './raw-message-reader.js';
8
8
  export type { FitMessageReaderErrorCode, FitMessageReaderIssue, FitMessageReaderIssueCode, FitMessageReaderOptions, FitMessageReaderResult, FitRawDeveloperField, FitRawField, FitRawMessage, } from './raw-message-reader.js';
9
9
  export interface FitParserOptions {
10
+ /** Skips CRC validation and enables supported best-effort recovery. Defaults to true. */
10
11
  force?: boolean;
12
+ /** Requires complete files and records, and rejects trailing bytes. Defaults to false. */
13
+ strict?: boolean;
11
14
  speedUnit?: string;
12
15
  lengthUnit?: string;
13
16
  temperatureUnit?: string;
@@ -28,5 +31,13 @@ export default class FitParser {
28
31
  private readonly options;
29
32
  constructor(options?: FitParserOptions);
30
33
  parseAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit>;
34
+ /**
35
+ * Parses FIT files in input order with independent decoder state. Uses the
36
+ * same force and strict policies as parseAsync; strict mode rejects trailing
37
+ * bytes, while the default ignores a non-FIT suffix after parsed files.
38
+ * Rejects with an Error when any file cannot be parsed.
39
+ */
40
+ parseFilesAsync(content: ArrayBuffer | Buffer<ArrayBuffer>): Promise<ParsedFit[]>;
31
41
  parse(content: ArrayBuffer | Buffer<ArrayBuffer>, callback: FitParserCallback): void;
42
+ private parseFile;
32
43
  }