Private/New-GarminWeightFitFile.ps1

################################################################################
##### #####
##### Pure PowerShell FIT encoder for Garmin weight scale files #####
##### (file_id + weight_scale message) #####
##### #####
################################################################################

function Get-FitCrc {
    # FIT CRC-16 (nibble table from the FIT SDK)
    param(
        [Parameter(Mandatory = $true)]
        [byte[]]$Bytes
    )

    $table = [uint16[]](0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
        0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400)

    [uint16]$crc = 0
    foreach ($b in $Bytes) {
        $tmp = $table[$crc -band 0xF]
        $crc = (($crc -shr 4) -band 0x0FFF) -bxor $tmp -bxor $table[$b -band 0xF]
        $tmp = $table[$crc -band 0xF]
        $crc = (($crc -shr 4) -band 0x0FFF) -bxor $tmp -bxor $table[($b -shr 4) -band 0xF]
    }
    return $crc
}

function ConvertTo-FitTimestamp {
    # FIT timestamps are seconds since 1989-12-31 00:00:00 UTC
    param(
        [Parameter(Mandatory = $true)]
        [datetime]$Date
    )

    $fitEpoch = [datetime]::new(1989, 12, 31, 0, 0, 0, [DateTimeKind]::Utc)
    return [uint32][math]::Floor(($Date.ToUniversalTime() - $fitEpoch).TotalSeconds)
}

function Write-FitMessage {
    # Writes a definition record and a data record (local message 0, little endian)
    param(
        [Parameter(Mandatory = $true)]
        [System.IO.BinaryWriter]$Writer,

        [Parameter(Mandatory = $true)]
        [uint16]$GlobalMessageNumber,

        # Each field: @{ Num = <field number>; Type = 'uint8' | 'uint16' | 'uint32' | 'enum'; Value = <raw value> }
        [Parameter(Mandatory = $true)]
        [object[]]$Fields
    )

    $baseTypes = @{
        enum   = @{ Size = 1; Id = 0x00 }
        uint8  = @{ Size = 1; Id = 0x02 }
        uint16 = @{ Size = 2; Id = 0x84 }
        uint32 = @{ Size = 4; Id = 0x86 }
    }

    # Definition record
    $Writer.Write([byte]0x40)
    $Writer.Write([byte]0)
    $Writer.Write([byte]0)
    $Writer.Write([uint16]$GlobalMessageNumber)
    $Writer.Write([byte]$Fields.Count)
    foreach ($field in $Fields) {
        $baseType = $baseTypes[$field.Type]
        $Writer.Write([byte]$field.Num)
        $Writer.Write([byte]$baseType.Size)
        $Writer.Write([byte]$baseType.Id)
    }

    # Data record
    $Writer.Write([byte]0x00)
    foreach ($field in $Fields) {
        switch ($field.Type) {
            { $_ -in 'enum', 'uint8' } { $Writer.Write([byte]$field.Value) }
            'uint16' { $Writer.Write([uint16]$field.Value) }
            'uint32' { $Writer.Write([uint32]$field.Value) }
        }
    }
}

function New-GarminWeightFitFile {
    # Creates a Garmin weight FIT file (file_id + weight_scale message)
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [datetime]$Timestamp,

        [Parameter(Mandatory = $true)]
        [double]$Weight,

        [double]$PercentFat,
        [double]$PercentHydration,
        [double]$BoneMass,
        [double]$MuscleMass,
        [double]$Bmi,
        [int]$VisceralFatRating,
        [int]$MetabolicAge,
        [int]$PhysiqueRating,
        [double]$BasalMet = 2000,
        [double]$ActiveMet = 2000,
        [int]$UserProfileIndex = 0
    )

    # Scaled values are stored as integers (e.g. weight 93.40 kg -> 9340)
    function Get-Scaled([double]$Value, [int]$Scale) {
        [math]::Round($Value * $Scale)
    }

    $fitTime = ConvertTo-FitTimestamp -Date $Timestamp

    $stream = [System.IO.MemoryStream]::new()
    $writer = [System.IO.BinaryWriter]::new($stream)
    try {
        # file_id (global message 0)
        Write-FitMessage -Writer $writer -GlobalMessageNumber 0 -Fields @(
            @{ Num = 0; Type = 'enum'; Value = 9 }          # type = weight
            @{ Num = 1; Type = 'uint16'; Value = 1 }        # manufacturer = garmin
            @{ Num = 4; Type = 'uint32'; Value = $fitTime } # time_created
        )

        # weight_scale (global message 30)
        Write-FitMessage -Writer $writer -GlobalMessageNumber 30 -Fields @(
            @{ Num = 253; Type = 'uint32'; Value = $fitTime }                                # timestamp
            @{ Num = 0; Type = 'uint16'; Value = (Get-Scaled $Weight 100) }                 # weight [kg]
            @{ Num = 1; Type = 'uint16'; Value = (Get-Scaled $PercentFat 100) }             # percent_fat [%]
            @{ Num = 2; Type = 'uint16'; Value = (Get-Scaled $PercentHydration 100) }       # percent_hydration [%]
            @{ Num = 4; Type = 'uint16'; Value = (Get-Scaled $BoneMass 100) }               # bone_mass [kg]
            @{ Num = 5; Type = 'uint16'; Value = (Get-Scaled $MuscleMass 100) }             # muscle_mass [kg]
            @{ Num = 7; Type = 'uint16'; Value = (Get-Scaled $BasalMet 4) }                 # basal_met [kcal/day]
            @{ Num = 8; Type = 'uint8'; Value = $PhysiqueRating }                           # physique_rating
            @{ Num = 9; Type = 'uint16'; Value = (Get-Scaled $ActiveMet 4) }                # active_met [kcal/day]
            @{ Num = 10; Type = 'uint8'; Value = $MetabolicAge }                            # metabolic_age [years]
            @{ Num = 11; Type = 'uint8'; Value = $VisceralFatRating }                       # visceral_fat_rating
            @{ Num = 12; Type = 'uint16'; Value = $UserProfileIndex }                       # user_profile_index
            @{ Num = 13; Type = 'uint16'; Value = (Get-Scaled $Bmi 10) }                    # bmi [kg/m2]
        )
        $writer.Flush()
        $records = $stream.ToArray()
    }
    finally {
        $writer.Dispose()
        $stream.Dispose()
    }

    # 12-byte header: size, protocol 2.3, profile 21.212, data size, ".FIT"
    $header = [System.IO.MemoryStream]::new()
    $headerWriter = [System.IO.BinaryWriter]::new($header)
    $headerWriter.Write([byte]12)
    $headerWriter.Write([byte]0x23)
    $headerWriter.Write([uint16]21212)
    $headerWriter.Write([uint32]$records.Length)
    $headerWriter.Write([Text.Encoding]::ASCII.GetBytes('.FIT'))
    $headerWriter.Flush()
    $content = $header.ToArray() + $records
    $headerWriter.Dispose()
    $header.Dispose()

    $crc = Get-FitCrc -Bytes $content
    $fileBytes = $content + [BitConverter]::GetBytes([uint16]$crc)

    $fullPath = [System.IO.Path]::GetFullPath($Path)
    [System.IO.File]::WriteAllBytes($fullPath, $fileBytes)
    return Get-Item -LiteralPath $fullPath
}