Private/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>\garmin_tokens.json param( [string]$TokenStore ) if ([string]::IsNullOrWhiteSpace($TokenStore)) { $TokenStore = [Environment]::GetEnvironmentVariable('GARMINTOKENS', 'Process') } if ([string]::IsNullOrWhiteSpace($TokenStore)) { $TokenStore = Join-Path -Path $HOME -ChildPath '.garminconnect' } $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 'garmin_tokens.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 if (-not (Test-Path -LiteralPath $tokenFile -PathType Leaf)) { throw "No Garmin token file found at '$tokenFile'. Sign in first with Get-GarminToken." } $store = Get-Content -LiteralPath $tokenFile -Raw | ConvertFrom-Json if (-not $store.di_token) { throw "The token file '$tokenFile' contains no DI token. Sign in again with Get-GarminToken." } $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 ) $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 $status = [int]$response.StatusCode switch ($status) { { $_ -in 200, 201, 202 } { if ($OutFile) { [System.IO.File]::WriteAllBytes([System.IO.Path]::GetFullPath($OutFile), $response.RawContentStream.ToArray()) return Get-Item -LiteralPath $OutFile } $content = if ($response.Content -is [byte[]]) { [Text.Encoding]::UTF8.GetString($response.Content) } else { [string]$response.Content } if ([string]::IsNullOrWhiteSpace($content)) { return $null } return $content | ConvertFrom-Json -Depth 100 } 204 { return $null } 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." } } } |