Public/Convert-TanitaExportToFitFile.ps1

<#
.SYNOPSIS
Converts Tanita (RD-545) body measurement CSV data into Garmin-compatible FIT files.
 
.DESCRIPTION
Convert-TanitaExportToFitFile validates a Tanita CSV export, resolves the output directory,
and writes one Garmin weight FIT file per measurement directly from PowerShell.
Each file contains a file_id and a weight_scale message.
 
If no CSV file is provided, the function opens a file picker so that an input file
can be selected interactively. Measurements with missing values ('-') are skipped.
 
Column names:
The CSV column for each value is taken from the section "MeasurementHeaders" in the user
configuration $env:USERPROFILE\GCCare\Config\GCCare.json. The key is fixed, the value is the
column name in your CSV file, so exports with other column names - e.g. from German
systems - can be used without changes:
 
    "MeasurementHeaders": {
        "Date": "Datum",
        "Weight (kg)": "Gewicht (kg)",
        "Body Fat (%)": "Fettanteil (%)",
        ...
    }
 
Keys: Date, Weight (kg), Body Fat (%), Body Water (%), Muscle Mass (kg), Metab Age,
Visc Fat, Physique Rating, BMR (kcal), BMI. Keys that are missing in GCCare.json keep the
default Tanita column name (identical to the key). -HeaderMapping overrides single
columns for one call. The BMI column is optional; without it the BMI is calculated from
-HeightCm.
 
CSV format:
- Delimiter ',' or ';' (detected automatically from the header line).
- Decimal separator '.' or ','.
- Date formats yyyy-MM-dd HH:mm[:ss], dd.MM.yyyy HH:mm[:ss], yyyy/MM/dd HH:mm:ss,
  dd/MM/yyyy HH:mm:ss or any format of the current culture.
 
Written weight_scale values: weight, percent fat, percent hydration, bone mass (filled with
the body fat mass in kg), muscle mass, BMI, visceral fat rating, metabolic age,
physique rating, basal/active metabolic rate and user profile index 0.
 
.PARAMETER csvFile
Path to the Tanita CSV input file. Only .csv files are accepted. If omitted, the
function prompts for a file by using a Windows file selection dialog.
 
.PARAMETER outputDirectory
Directory where the generated FIT files will be written. If the directory does not
exist, it is created automatically. The default is $env:USERPROFILE\GCCare\FitFiles.
 
.PARAMETER HeightCm
Body height in centimeters, used to calculate the BMI when the CSV has no BMI column or
no BMI value. Must be greater than zero. The default value is 178.0.
 
.PARAMETER LastX
Limits the conversion to the most recent number of measurements in the CSV file.
If omitted, the function asks for the number of entries.
 
.PARAMETER Upload
Uploads the created FIT files directly to Garmin Connect with Send-FitFileToGarminConnect and
moves them to the subfolder Uploaded of the output directory (requires a token created by
Get-GarminToken).
 
.PARAMETER HeaderMapping
Optional hashtable that overrides CSV column names for this call only, e.g.
@{ 'Date' = 'Datum'; 'Weight (kg)' = 'Gewicht' }. Takes precedence over "MeasurementHeaders"
in GCCare.json. Only the keys listed in the description are allowed.
 
.PARAMETER TokenStore
Optional path to the Garmin token store directory or gctoken.json file (used with -Upload).
 
.PARAMETER EnableLogging
Optional switch to enable module logging behavior (if supported by module logging helpers).
 
.EXAMPLE
PS> Convert-TanitaExportToFitFile -csvFile 'C:\Data\Tanita\bodydata.csv' -LastX 7
 
Converts the latest seven measurements and writes the FIT files to $env:USERPROFILE\GCCare\FitFiles.
 
.EXAMPLE
PS> Convert-TanitaExportToFitFile -csvFile 'C:\Data\Tanita\bodydata.csv' -outputDirectory 'C:\Data\Tanita\Fit' -HeightCm 180 -LastX 7
 
Converts only the latest seven measurements, uses a height of 180 cm for missing BMI values,
and writes the resulting FIT files to the specified output directory.
 
.EXAMPLE
PS> Convert-TanitaExportToFitFile -csvFile 'C:\Data\Tanita\bodydata.csv' -LastX 1 -Upload
 
Converts the latest measurement, uploads it to Garmin Connect and moves the FIT file to
$env:USERPROFILE\GCCare\FitFiles\Uploaded.
 
.EXAMPLE
PS> Convert-TanitaExportToFitFile -csvFile 'C:\Data\Tanita\messwerte.csv' -LastX 7 -HeaderMapping @{ 'Date' = 'Datum'; 'Weight (kg)' = 'Gewicht (kg)' }
 
Converts a German export whose date and weight columns have different names; all other
columns are taken from "MeasurementHeaders" in GCCare.json.
 
.NOTES
Requires the private helpers New-GarminWeightFitFile (FIT encoder) and
Get-GCCareMeasurementHeaders (column mapping) and, for -Upload, Send-FitFileToGarminConnect.
Existing user configurations created before "MeasurementHeaders" was added keep working with
the default column names; copy the section from the module's GCCare.json to customize them.
 
.LINK
https://developer.garmin.com/fit/protocol/
https://www.fitfileviewer.com/
#>

Function Convert-TanitaExportToFitFile {

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [ValidatePattern('(?i)\.csv$')]
        [string]$csvFile,
        [string]$outputDirectory = $Script:DefaultFitFilesFolder,
        [double]$HeightCm = 178.0,
        [int]$LastX,
        [switch]$Upload,
        [hashtable]$HeaderMapping,
        [string]$TokenStore,
        [switch]$EnableLogging
    )

    $CurrentFunction = Get-FunctionName
    Write-Log -Message "### Start Function $CurrentFunction ###"
    $StartRunTime = (Get-Date).ToString($Script:DateFormatLog)
    #################### main code | out- host #####################

    Invoke-Output -Type Header -Message "Converting Tanita CSV to FIT files ..."

    if (-not $csvFile) {
        Add-Type -AssemblyName System.Windows.Forms

        $dialog = New-Object System.Windows.Forms.OpenFileDialog
        $dialog.Title = "Select csv input file"
        $dialog.Filter = "csv files (*.csv)|*.csv"
        $dialog.Multiselect = $false
        $dialog.CheckFileExists = $true
        $dialog.InitialDirectory = (Get-Location).Path

        $dialogResult = $dialog.ShowDialog()
        if ($dialogResult -ne [System.Windows.Forms.DialogResult]::OK -or [string]::IsNullOrWhiteSpace($dialog.FileName)) {
            Invoke-Output -Type Missing -Message "No CSV file selected. Script canceled."
            return
        }

        $csvFile = $dialog.FileName
    }

    if (-not (Test-Path -LiteralPath $csvFile -PathType Leaf)) {
        invoke-output -type Missing -message "CSV file not found: " -Textmaker "$csvFile"
        return
    }

    if ([System.IO.Path]::GetExtension($csvFile) -notmatch '^(?i)\.csv$') {
        invoke-output -type Missing -message "Only .csv files are allowed: " -Textmaker "$csvFile"
        return
    }

    $csvFile = (Resolve-Path -LiteralPath $csvFile).Path

    Invoke-Output -Type Bullet -Message "Input CSV file: " -Textmaker "$csvFile"

    If (-not $outputDirectory) {
        $outputDirectory = $Script:DefaultFitFilesFolder
    }    
    $outputDirectory = [System.IO.Path]::GetFullPath($outputDirectory)

    if (-not (Test-Path -LiteralPath $outputDirectory)) {
        $null = New-Item -Path $outputDirectory -ItemType Directory -Force
    }

    Invoke-Output -Type Bullet -Message "Output directory:" -Textmaker "$outputDirectory"
    Write-host

    if ($HeightCm -le 0) {
        throw 'HeightCm must be greater than zero.'
    }

    if ($PSBoundParameters.ContainsKey('LastX') -and $LastX -le 0) {
        throw 'Last X must be greater than zero.'
    }

    if (-not $PSBoundParameters.ContainsKey('LastX')) {
        $answer = Invoke-Output -Type Input -Message "How many of the latest entries should be exported?"
        if (-not [int]::TryParse($answer, [ref]$LastX) -or $LastX -le 0) {
            throw 'The number of entries must be greater than zero.'
        }
    }

    $invariant = [System.Globalization.CultureInfo]::InvariantCulture
    $heightM = $HeightCm / 100.0

    # Column names: defaults < GCCare.json "MeasurementHeaders" < -HeaderMapping
    $headers = Get-GCCareMeasurementHeaders -Mapping $HeaderMapping

    # Delimiter: ',' (English export) or ';' (e.g. German Excel/export), detected from the header line
    $headerLine = Get-Content -LiteralPath $csvFile -TotalCount 1 -Encoding UTF8
    $delimiter = if (([regex]::Matches($headerLine, ';')).Count -gt ([regex]::Matches($headerLine, ',')).Count) { ';' } else { ',' }
    Write-Log -Message " >> CSV delimiter: '$delimiter'"

    $rows = @(Import-Csv -LiteralPath $csvFile -Delimiter $delimiter -Encoding UTF8)
    if ($rows.Count -eq 0) {
        Invoke-Output -Type Missing -Message "CSV file contains no measurements: " -TextMaker "$csvFile"
        return
    }

    $csvColumns = $rows[0].PSObject.Properties.Name
    # BMI is optional: when the column or a value is missing, it is calculated from -HeightCm
    $missingColumns = $headers.Keys | Where-Object { $_ -ne 'BMI' -and $headers[$_] -notin $csvColumns }
    if ($missingColumns) {
        $details = ($missingColumns | ForEach-Object { "'$($headers[$_])' ($_)" }) -join ', '
        throw ("CSV file is missing required columns: $details.`n" +
            "Adjust 'MeasurementHeaders' in $Script:ConfigFile or use -HeaderMapping. Columns in the CSV file: $($csvColumns -join ', ')")
    }

    # Numbers with '.' or ',' as decimal separator; '-' or empty means not measured
    function ConvertTo-MeasurementNumber([string]$Text) {
        $number = 0.0
        if ([double]::TryParse($Text.Trim().Replace(',', '.'), [System.Globalization.NumberStyles]::Float, $invariant, [ref]$number)) {
            return $number
        }
        return $null
    }

    $dateFormats = [string[]]('yyyy-MM-dd HH:mm:ss', 'yyyy-MM-dd HH:mm', 'dd.MM.yyyy HH:mm:ss', 'dd.MM.yyyy HH:mm', 'yyyy/MM/dd HH:mm:ss', 'dd/MM/yyyy HH:mm:ss')

    $createdFiles = @()
    foreach ($row in ($rows | Select-Object -Last $LastX)) {
        $dateText = [string]$row.($headers['Date'])

        $values = @{}
        foreach ($key in $headers.Keys | Where-Object { $_ -notin 'Date', 'BMI' }) {
            $values[$key] = ConvertTo-MeasurementNumber $row.($headers[$key])
        }
        if ($values.Values -contains $null) {
            Invoke-Output -Type Missing -Message "Skipped incomplete measurement: " -TextMaker $dateText -NoExtraLines
            continue
        }

        $timestamp = [datetime]::MinValue
        if (-not [datetime]::TryParseExact($dateText.Trim(), $dateFormats, $invariant, [System.Globalization.DateTimeStyles]::None, [ref]$timestamp) -and
            -not [datetime]::TryParse($dateText, [ref]$timestamp)) {
            Invoke-Output -Type Missing -Message "Skipped measurement with unknown date format: " -TextMaker $dateText -NoExtraLines
            continue
        }

        $weight = $values['Weight (kg)']
        $fatPercent = $values['Body Fat (%)']

        $bmi = if ($headers['BMI'] -in $csvColumns) { ConvertTo-MeasurementNumber $row.($headers['BMI']) } else { $null }
        if (-not $bmi) {
            $bmi = [math]::Round($weight / ($heightM * $heightM), 2)
        }

        $fitParams = @{
            Path              = Join-Path -Path $outputDirectory -ChildPath ("weight_{0:yyyy-MM-dd_HH-mm}.fit" -f $timestamp)
            Timestamp         = $timestamp
            Weight            = $weight
            PercentFat        = $fatPercent
            PercentHydration  = $values['Body Water (%)']
            # Intentional: the bone_mass field carries the body fat mass in kg (do not change)
            BoneMass          = [math]::Round($weight * ($fatPercent / 100.0), 2)
            MuscleMass        = $values['Muscle Mass (kg)']
            Bmi               = $bmi
            VisceralFatRating = [int][math]::Truncate($values['Visc Fat'])
            MetabolicAge      = [int][math]::Truncate($values['Metab Age'])
            PhysiqueRating    = [int][math]::Truncate($values['Physique Rating'])
            BasalMet          = $values['BMR (kcal)']
            ActiveMet         = 2000
        }

        $fitFile = New-GarminWeightFitFile @fitParams
        $createdFiles += $fitFile
        Invoke-Output -Type Bullet -Message "FIT file created:" -TextMaker $fitFile.Name -NoExtraLines
    }

    Invoke-Output -Type Success -Message "Conversion completed successfully ($($createdFiles.Count) files). `n FIT files are located in: $outputDirectory"

    # Upload only the files created in this run; uploaded files are moved to <outputDirectory>\Uploaded
    if ($Upload -and $createdFiles.Count -gt 0) {
        $null = Send-FitFileToGarminConnect -Path $createdFiles.FullName -TokenStore $TokenStore
    }
    ######################## main code ############################
    $runtime = Get-RunTime -StartRunTime $StartRunTime
    Write-Log -Message " Run Time: $runtime [h] ###"
    Write-Log -Message "### End Function $CurrentFunction ###"

}