Private/Invoke-GarminConnectApi.ps1

################################################################################
##### #####
##### Native Garmin Connect API access (pure PowerShell) #####
##### Uses the DI OAuth2 token store written by Get-GarminToken #####
##### #####
################################################################################

$Script:GarminConnectApiBase = 'https://connectapi.garmin.com'
$Script:GarminDiTokenUrl = 'https://diauth.garmin.com/di-oauth2-service/oauth/token'

function Get-GarminNativeHeaders {
    # Headers of the Garmin Connect Android app
    param(
        [hashtable]$Extra = @{}
    )

    $headers = @{
        'User-Agent'                  = 'GCM-Android-5.23'
        'X-Garmin-User-Agent'         = 'com.garmin.android.apps.connectmobile/5.23; ; Google/sdk_gphone64_arm64/google; Android/33; Dalvik/2.1.0'
        'X-Garmin-Paired-App-Version' = '10861'
        'X-Garmin-Client-Platform'    = 'Android'
        'X-App-Ver'                   = '10861'
        'X-Lang'                      = 'en'
        'X-GCExperience'              = 'GC5'
        'Accept-Language'             = 'en-US,en;q=0.9'
    }
    foreach ($key in $Extra.Keys) {
        $headers[$key] = $Extra[$key]
    }
    return $headers
}

function ConvertFrom-GarminJwtPayload {
    # Decodes the JWT payload without verifying the signature (only Garmin has the key)
    param(
        [Parameter(Mandatory = $true)]
        [string]$Token
    )

    $parts = $Token.Split('.')
    if ($parts.Count -lt 2) {
        return $null
    }

    $payload = $parts[1].Replace('-', '+').Replace('_', '/')
    $payload += '=' * ((4 - $payload.Length % 4) % 4)
    try {
        return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json
    }
    catch {
        return $null
    }
}

function Resolve-GarminTokenFile {
    # A directory (default ~/.garminconnect) means <dir>\gctoken.json
    param(
        [string]$TokenStore
    )

    if ([string]::IsNullOrWhiteSpace($TokenStore)) {
        $TokenStore = [Environment]::GetEnvironmentVariable('GARMINTOKENS', 'Process')
    }
    if ([string]::IsNullOrWhiteSpace($TokenStore)) {
        $TokenStore = Join-Path -Path $HOME -ChildPath '.garminconnect'
    }

    # Expand only a leading ~ (8.3 short names like C:\Users\HOLGER~1 contain ~ as well)
    $path = [System.IO.Path]::GetFullPath(($TokenStore -replace '^~(?=$|[\\/])', $HOME))
    if ((Test-Path -LiteralPath $path -PathType Container) -or [System.IO.Path]::GetExtension($path) -ne '.json') {
        return Join-Path -Path $path -ChildPath 'gctoken.json'
    }
    return $path
}

function Get-GarminAccessToken {
    # Returns a valid DI access token; refreshes it (and updates the token file) when it expires within 15 minutes
    param(
        [string]$TokenStore
    )

    $tokenFile = Resolve-GarminTokenFile -TokenStore $TokenStore

    $store = $null
    if (Test-Path -LiteralPath $tokenFile -PathType Leaf) {
        $store = Get-Content -LiteralPath $tokenFile -Raw | ConvertFrom-Json
    }

    # No (usable) token yet: sign in interactively and continue with the new token
    if (-not $store -or -not $store.di_token) {
        Write-Log -Message " >> No Garmin token in $tokenFile, starting sign-in"
        Invoke-Output -Type Info -Message "No Garmin Connect token found ($tokenFile) - starting sign-in..." -NoExtraLines
        $null = Get-GarminToken -TokenFile $tokenFile
        if (-not (Test-Path -LiteralPath $tokenFile -PathType Leaf)) {
            throw "Garmin Connect sign-in did not create a token file at '$tokenFile'."
        }
        $store = Get-Content -LiteralPath $tokenFile -Raw | ConvertFrom-Json
    }

    $payload = ConvertFrom-GarminJwtPayload -Token $store.di_token
    $expiresSoon = $payload -and $payload.exp -and ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() -gt ([long]$payload.exp - 900))
    if (-not $expiresSoon) {
        return $store.di_token
    }

    Write-Log -Message " >> DI token expires soon, refreshing ($tokenFile)"
    if (-not $store.di_refresh_token -or -not $store.di_client_id) {
        throw 'The Garmin access token has expired and no refresh token is available. Sign in again with Get-GarminToken.'
    }

    $basicAuth = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($store.di_client_id):"))
    $response = Invoke-WebRequest -Method Post -Uri $Script:GarminDiTokenUrl -SkipHttpErrorCheck `
        -Headers (Get-GarminNativeHeaders -Extra @{ Authorization = $basicAuth; Accept = 'application/json'; 'Cache-Control' = 'no-cache' }) `
        -ContentType 'application/x-www-form-urlencoded' `
        -Body @{ grant_type = 'refresh_token'; client_id = $store.di_client_id; refresh_token = $store.di_refresh_token }

    if ([int]$response.StatusCode -ne 200) {
        throw "Garmin token refresh failed (HTTP $([int]$response.StatusCode)). Sign in again with Get-GarminToken."
    }

    $data = $response.Content | ConvertFrom-Json
    $newPayload = ConvertFrom-GarminJwtPayload -Token $data.access_token
    $updatedStore = [pscustomobject][ordered]@{
        di_token         = $data.access_token
        di_refresh_token = if ($data.PSObject.Properties['refresh_token'] -and $data.refresh_token) { $data.refresh_token } else { $store.di_refresh_token }
        di_client_id     = if ($newPayload -and $newPayload.client_id) { [string]$newPayload.client_id } else { $store.di_client_id }
    }
    $updatedStore | ConvertTo-Json -Compress | Set-Content -LiteralPath $tokenFile -Encoding utf8NoBOM

    return $updatedStore.di_token
}

function Invoke-GarminConnectApi {
    # Calls connectapi.garmin.com with the stored DI bearer token.
    # -Query: array values are sent as repeated parameters (metricId=22&metricId=23), booleans as true/false
    # -Body: object sent as JSON (POST/PUT)
    # -Form: multipart/form-data upload, e.g. @{ file = Get-Item .\activity.fit }
    # -OutFile: saves the raw response (FIT/TCX/GPX/ZIP downloads) instead of parsing JSON
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Path,

        [System.Collections.IDictionary]$Query = @{},

        [ValidateSet('Get', 'Post', 'Put', 'Delete')]
        [string]$Method = 'Get',

        [object]$Body,

        [hashtable]$Form,

        [string]$OutFile,

        [string]$TokenStore
    )

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

    $accessToken = Get-GarminAccessToken -TokenStore $TokenStore
    $uri = $Script:GarminConnectApiBase + '/' + $Path.TrimStart('/')
    if ($Query.Count -gt 0) {
        $pairs = foreach ($entry in $Query.GetEnumerator()) {
            foreach ($value in @($entry.Value)) {
                $text = if ($value -is [bool]) { $value.ToString().ToLower() } else { [string]$value }
                '{0}={1}' -f [Uri]::EscapeDataString([string]$entry.Key), [Uri]::EscapeDataString($text)
            }
        }
        $uri += '?' + ($pairs -join '&')
    }

    $request = @{
        Method             = $Method
        Uri                = $uri
        SkipHttpErrorCheck = $true
        Headers            = Get-GarminNativeHeaders -Extra @{ Authorization = "Bearer $accessToken"; Accept = $(if ($OutFile) { '*/*' } else { 'application/json' }) }
    }
    if ($PSBoundParameters.ContainsKey('Body')) {
        $request.Body = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 100 -Compress }
        $request.ContentType = 'application/json'
    }
    if ($PSBoundParameters.ContainsKey('Form')) {
        $request.Form = $Form
    }

    $response = Invoke-WebRequest @request

    $result = $null
    $status = [int]$response.StatusCode
    switch ($status) {
        { $_ -in 200, 201, 202 } {
            if ($OutFile) {
                [System.IO.File]::WriteAllBytes([System.IO.Path]::GetFullPath($OutFile), $response.RawContentStream.ToArray())
                $result = Get-Item -LiteralPath $OutFile
                break
            }
            $content = if ($response.Content -is [byte[]]) { [Text.Encoding]::UTF8.GetString($response.Content) } else { [string]$response.Content }
            if (-not [string]::IsNullOrWhiteSpace($content)) {
                $result = $content | ConvertFrom-Json -Depth 100
            }
            break
        }
        204 { break }
        409 { throw "Garmin API $Path : conflict (409), e.g. the activity already exists." }
        401 { throw "Garmin API $Path : authentication required (401). Sign in again with Get-GarminToken." }
        403 { throw "Garmin API $Path : access denied (403)." }
        404 { throw "Garmin API $Path : not found (404), the endpoint may have moved." }
        429 { throw "Garmin API $Path : rate limited (429), please wait before retrying." }
        default { throw "Garmin API $Path : HTTP $status." }
    }

    ######################## main code ############################
    $runtime = Get-RunTime -StartRunTime $StartRunTime
    #Add-SAFunctionRunTime -Function $CurrentFunction -Runtime $runtime
    Write-Log -Message " Run Time: $runtime [h] ###"
    Write-Log -Message "### End Function $CurrentFunction ###"

    return $result
}