Public/Convert-TanitaExportToFitFile.ps1
|
<#
.SYNOPSIS Converts Tanita 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. 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 (2000 kcal) 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 C:\Users\Public\GCCare\FitFiles. .PARAMETER HeightCm Body height in centimeters, used to calculate the BMI when the CSV has 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 (requires a token created by Get-GarminToken). .PARAMETER TokenStore Optional path to the Garmin token store directory or garmin_tokens.json file (used with -Upload). .EXAMPLE PS> Convert-TanitaExportToFitFile -csvFile 'C:\Data\Tanita\bodydata.csv' -LastX 7 Converts the latest seven measurements and writes the FIT files to C:\Users\Public\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 and uploads it to Garmin Connect. .NOTES Requires the private helpers New-GarminWeightFitFile (FIT encoder) and, for -Upload, Invoke-GarminConnectApi. .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, [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 $requiredColumns = 'Date', 'Weight (kg)', 'Body Fat (%)', 'Body Water (%)', 'Muscle Mass (kg)', 'Metab Age', 'Visc Fat', 'Physique Rating' $rows = @(Import-Csv -LiteralPath $csvFile -Encoding UTF8) if ($rows.Count -eq 0) { Invoke-Output -Type Missing -Message "CSV file contains no measurements: " -TextMaker "$csvFile" return } $missingColumns = $requiredColumns | Where-Object { $_ -notin $rows[0].PSObject.Properties.Name } if ($missingColumns) { throw "CSV file is missing required columns: $($missingColumns -join ', ')" } $createdFiles = @() foreach ($row in ($rows | Select-Object -Last $LastX)) { # Tanita exports '-' for values that were not measured $values = @{} foreach ($column in $requiredColumns | Where-Object { $_ -ne 'Date' }) { $number = 0.0 if (-not [double]::TryParse($row.$column, [System.Globalization.NumberStyles]::Float, $invariant, [ref]$number)) { $number = $null } $values[$column] = $number } if ($values.Values -contains $null) { Invoke-Output -Type Missing -Message "Skipped incomplete measurement: " -TextMaker "$($row.Date)" -NoExtraLines continue } $timestamp = [datetime]::ParseExact($row.Date, 'yyyy-MM-dd HH:mm:ss', $invariant) $weight = $values['Weight (kg)'] $fatPercent = $values['Body Fat (%)'] $bmi = 0.0 if (-not [double]::TryParse($row.BMI, [System.Globalization.NumberStyles]::Float, $invariant, [ref]$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 = 2000 ActiveMet = 2000 } $fitFile = New-GarminWeightFitFile @fitParams $createdFiles += $fitFile Invoke-Output -Type Bullet -Message "FIT file created:" -TextMaker $fitFile.Name -NoExtraLines Write-Log -Message " >> FIT file created: $($fitFile.FullName)" } if ($Upload -and $createdFiles.Count -gt 0) { Write-Host foreach ($fitFile in $createdFiles) { try { $null = Invoke-GarminConnectApi -Path '/upload-service/upload' -Method Post -Form @{ file = $fitFile } -TokenStore $TokenStore Invoke-Output -Type Bullet -Message "Uploaded: " -TextMaker $fitFile.Name -NoExtraLines } catch { Invoke-Output -Type Missing -Message "Upload failed: " -TextMaker "$($fitFile.Name) - $($_.Exception.Message)" -NoExtraLines } } } Invoke-Output -Type Success -Message "Conversion completed successfully ($($createdFiles.Count) files). `n FIT files are located in: $outputDirectory" ######################## main code ############################ $runtime = Get-RunTime -StartRunTime $StartRunTime Write-Log -Message " Run Time: $runtime [h] ###" Write-Log -Message "### End Function $CurrentFunction ###" } |