This is a library for Scala 3 for decoding and encoding FIT files.
The codecs are written using scodec.
FIT files are defined by garmin, https://developer.garmin.com/fit/protocol/. The fit file specification is only one part to fully obtain the data, the second part is a "profile" which defines the meaning for the data in a fit file.
The library is comprised of the following modules:
The codec module defines the codecs for reading and writing fit files. It has a single dependency on the scodec library. This module has no profile information and therefore allows to decode fit files but can only give limited knowledge on the decoded data.
The FitFile
represents a decoded fit file. With FitFile.read
a fit
file can be decoded:
import fit4s.codec.*
val testFile = TestData.Activities.moxyFr735
// testFile: TestFile = fit4s.codec.TestData$TestFile@34f87ad9
val fr735 = FitFile.read(testFile.contents).require.head
// fr735: FitFile = FitFile(
// header = FileHeader(
// protocolVersion = 16,
// profileVersion = 2006,
// dataSize = 4106L,
// crc = 48432
// ),
// records = Vector(
// DataRecord(
// header = NormalRecordHeader(
// messageTypeSpecific = false,
// localMessageType = 4
// ),
// definition = DefinitionMessage(
// meta = Meta(byteOrder = LittleEndian, globalMessageNum = 34),
// fields = List(
// FieldDef(
// fieldDefNumber = 253,
// sizeBytes = 4,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 6)
// ),
// FieldDef(
// fieldDefNumber = 0,
// sizeBytes = 4,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 6)
// ),
// FieldDef(
// fieldDefNumber = 5,
// sizeBytes = 4,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 6)
// ),
// FieldDef(
// fieldDefNumber = 1,
// sizeBytes = 2,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 4)
// ),
// FieldDef(
// fieldDefNumber = 2,
// sizeBytes = 1,
// baseType = FieldBaseType(endianAbility = false, baseTypeNum = 0)
// ),
// FieldDef(
// fieldDefNumber = 3,
// sizeBytes = 1,
// baseType = FieldBaseType(endianAbility = false, baseTypeNum = 0)
// ),
// FieldDef(
// fieldDefNumber = 4,
// sizeBytes = 1,
// ...
A fit file may contain many concatenated fit files. The data is in the
records
vector. It contains definition and data messages. Each data
message is described by a corresponding definition message.
// 0 is the FileId message
val msg = fr735.findMessages(0).head
// msg: DataRecord = DataRecord(
// header = NormalRecordHeader(messageTypeSpecific = false, localMessageType = 0),
// definition = DefinitionMessage(
// meta = Meta(byteOrder = LittleEndian, globalMessageNum = 0),
// fields = List(
// FieldDef(
// fieldDefNumber = 3,
// sizeBytes = 4,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 12)
// ),
// FieldDef(
// fieldDefNumber = 4,
// sizeBytes = 4,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 6)
// ),
// FieldDef(
// fieldDefNumber = 7,
// sizeBytes = 4,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 6)
// ),
// FieldDef(
// fieldDefNumber = 1,
// sizeBytes = 2,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 4)
// ),
// FieldDef(
// fieldDefNumber = 2,
// sizeBytes = 2,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 4)
// ),
// FieldDef(
// fieldDefNumber = 5,
// sizeBytes = 2,
// baseType = FieldBaseType(endianAbility = true, baseTypeNum = 4)
// ),
// FieldDef(
// fieldDefNumber = 0,
// sizeBytes = 1,
// baseType = FieldBaseType(endianAbility = false, baseTypeNum = 0)
// )
// ),
// devFields = List()
// ),
// lastTimestamp = None,
// fields = Vector(
// TypedDataField(
// meta = Meta(byteOrder = LittleEndian, globalMessageNum = 0),
// fieldDef = FieldDef(
// ...
This example gets the field 1, which defines the manufacturer. The value 1 means "Garmin".
msg.fieldData(1)
// res0: Option[Vector[FitBaseValue]] = Some(value = Vector(1))
While FitFile.read
decodes into data structures, if you only want to
verify the integrity, FitFileStructure
can be used.
FitFileStructure.checkIntegrity(TestData.Activities.fenix539.contents)
// res1: Option[Err] = None
FitFileStructure.checkIntegrity(TestData.Corrupted.badCrc.contents)
// res2: Option[Err] = Some(
// value = InvalidHeaderCrc(provided = 13793, computed = 4833, context = List())
// )
While FitFile.read
reads the entire file into memory, there is also
a StreamDecoder
which can be used to incrementally read fit files.
The profile module contains data structures that describe the "global profile". This data is necessary to make sense of a decoded fit file. For example, it provides constants for message types and its expected fields with their field names and other data that allows to interpret the values in correctly.
A fit message may contain all or a subset of the described fields in the corresponding profile.
For example, the FileIdMsg
has these fields in its profile:
import fit4s.profile.*
FileIdMsg.allFields
// res3: Map[Int, MsgField] = HashMap(
// 0 -> MsgField(
// fieldDefNum = 0,
// fieldName = "type",
// profileType = Some(value = <function1>),
// baseTypeName = "enum",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// subFields = List()
// ),
// 5 -> MsgField(
// fieldDefNum = 5,
// fieldName = "number",
// profileType = None,
// baseTypeName = "uint16",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// subFields = List()
// ),
// 1 -> MsgField(
// fieldDefNum = 1,
// fieldName = "manufacturer",
// profileType = Some(value = <function1>),
// baseTypeName = "uint16",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// subFields = List()
// ),
// 2 -> MsgField(
// fieldDefNum = 2,
// fieldName = "product",
// profileType = None,
// baseTypeName = "uint16",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// subFields = List(
// SubField(
// ...
Here you can see that field number 1
denotes the manufacturer. The type enum
means that the number in the fit file corresponds to some value in the profile type "manufacturer". The ManufacturerType
can be used to lookup the value:
ManufacturerType.values(1)
// res4: String = "garmin"
The core module combines the previous modules to create a more
convenient api for reading fit files. It wraps a FitFile
into a
Fit
class and a data record into a FitMessage
class. Then you can
get information about a field by providing the appropriate profile
information.
import fit4s.core.*
val fenix539 = Fit.read(TestData.Activities.fenix539.contents).require.head
// fenix539: Fit = fit4s.core.Fit@2dd48d07
val fileIdMsg = fenix539.getMessages(FileIdMsg).toVector.head
// fileIdMsg: FitMessage = FitMessage(
// mesgNum = 0,
// schema = HashMap(
// 0 -> MsgField(
// fieldDefNum = 0,
// fieldName = "type",
// profileType = Some(value = <function1>),
// baseTypeName = "enum",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// subFields = List()
// ),
// 5 -> MsgField(
// fieldDefNum = 5,
// fieldName = "number",
// profileType = None,
// baseTypeName = "uint16",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// subFields = List()
// ),
// 1 -> MsgField(
// fieldDefNum = 1,
// fieldName = "manufacturer",
// profileType = Some(value = <function1>),
// baseTypeName = "uint16",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// subFields = List()
// ),
// 2 -> MsgField(
// fieldDefNum = 2,
// fieldName = "garmin_product",
// profileType = Some(value = <function1>),
// baseTypeName = "uint16",
// components = List(),
// scale = List(),
// offset = 0.0,
// units = List(),
// bits = List(),
// ...
val manu = fileIdMsg.field(FileIdMsg.manufacturer)
// manu: Option[FieldValue] = Some(
// value = FieldValue(
// fieldName = "manufacturer",
// fieldNumber = 1,
// baseType = Uint16(4, byte=0x84, size=2),
// profileType = Some(value = <function1>),
// unit = None,
// data = Vector(1)
// )
// )
val value = manu.asEnum
// value: Option[ProfileEnum] = Some(
// value = ProfileEnum(profile = <function1>, value = "garmin")
// )
The field
method gets a FieldValue
from the message, that combines
the profile information and the data from the fit file. Each field my
be interpreted differently. The manufacturer
is a value from a
predefined enumeration. The enumeration is already known by the
profile. The call asEnum
looks up the value from the appropriate
enumaration and returns a ProfileEnum
value.
Other values can be obtained via as[<type>]
calls. This requires a
FieldReader
instance to be in scope. For example, the file-id
message also contains a serial number which can be seen as a long:
fileIdMsg.field(FileIdMsg.serialNumber).as[Long]
// res5: Option[Either[String, Long]] = Some(
// value = Right(value = 3945007650L)
// )
In the same way if there is a MessageReader
instance in scope, the
complete message can be converted into some value. There is a FileId
case class defined that provides such a reader.
fileIdMsg.as[FileId]
// res6: Either[String, Option[FileId]] = Right(
// value = Some(
// value = FileId(
// fileType = ProfileEnum(profile = <function1>, value = "activity"),
// manufacturer = ProfileEnum(profile = <function1>, value = "garmin"),
// product = Some(
// value = ProfileEnum(profile = <function1>, value = "fenix5")
// ),
// serialNumber = Some(value = 3945007650L),
// createdAt = Some(value = 2019-01-06T17:49:49Z),
// number = None,
// productName = None
// )
// )
// )
Finally a small cli application exists for demoing. It can read fit files and convert it into JSON or create a HTML file to view the data.
Try with --help
to find out more.