├── .dialyzer_ignore.exs ├── test ├── test_helper.exs ├── fixtures │ └── api │ │ ├── __usersummary-service__stats__steps__daily__2024-04-06__2024-04-06.json │ │ ├── __usersummary-service__stats__steps__weekly__2024-05-01__4.json │ │ ├── __userprofile-service__socialProfile.json │ │ ├── __userprofile-service__userprofile__user-settings.json │ │ ├── __activity-service__activity__15205844761.json │ │ ├── __wellness-service__wellness__dailySleepData__arathunku.json │ │ └── __activitylist-service__activities__search__activities.json ├── support │ └── api_helper.ex └── nimrag_test.exs ├── .envrc ├── lefthook.yml ├── .formatter.exs ├── .github ├── dependabot.yml ├── workflows │ ├── elixir-build-and-test.yml │ ├── elixir-quality-checks.yml │ └── elixir-dialyzer.yml └── actions │ └── elixir-setup │ └── action.yml ├── lib ├── nimrag │ ├── api │ │ ├── hydration_container.ex │ │ ├── steps_daily.ex │ │ ├── user_settings.ex │ │ ├── activity_type.ex │ │ ├── steps_weekly.ex │ │ ├── activity_details.ex │ │ ├── activity_list.ex │ │ ├── activity.ex │ │ ├── data.ex │ │ ├── sleep_daily.ex │ │ ├── user_summary_daily.ex │ │ ├── user_data.ex │ │ └── profile.ex │ ├── oauth1_token.ex │ ├── oauth2_token.ex │ ├── client.ex │ ├── api.ex │ ├── credentials.ex │ └── auth.ex └── nimrag.ex ├── CHANGELOG.md ├── .gitignore ├── LICENSE.md ├── examples ├── sleep.livemd └── basic.livemd ├── .credo.exs ├── mix.exs ├── README.md └── mix.lock /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | dotenv 2 | dotenv_if_exists .env.private 3 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | # lefthook version: v1.6.10 2 | pre-commit: 3 | commands: 4 | check: 5 | run: mix check 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | line_length: 99, 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "12:00" 8 | open-pull-requests-limit: 3 9 | -------------------------------------------------------------------------------- /test/fixtures/api/__usersummary-service__stats__steps__daily__2024-04-06__2024-04-06.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "calendarDate": "2024-04-06", 4 | "stepGoal": 7777, 5 | "totalDistance": 6191, 6 | "totalSteps": 7820 7 | } 8 | ] -------------------------------------------------------------------------------- /lib/nimrag/api/hydration_container.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.HydrationContainer do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{name: String.t(), volume: integer(), unit: String.t()} 5 | 6 | defstruct ~w(name volume unit)a 7 | 8 | def schematic() do 9 | schema(__MODULE__, %{ 10 | field(:name) => str(), 11 | field(:volume) => int(), 12 | field(:unit) => str() 13 | }) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | 6 | 7 | ## Unreleased 8 | 9 | - Add `Nimrag.steps_weekly` 10 | - Add `Nimrag.sleep_daily` 11 | - Add `Nimrag.user_settings` 12 | - Rename struct `Nimrag.Api.Activity` to `Nimrag.Api.ActivityList` (data struct for elements of activities list) 13 | - Add `Nimrag.activity` 14 | - Add `Nimrag.activity_details` 15 | 16 | ## 0.1.0 (2024-03-29) 17 | 18 | Init release 19 | -------------------------------------------------------------------------------- /lib/nimrag/api/steps_daily.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.StepsDaily do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{ 5 | calendar_date: String.t(), 6 | step_goal: integer(), 7 | total_distance: integer(), 8 | total_steps: integer() 9 | } 10 | 11 | defstruct calendar_date: nil, step_goal: 0, total_distance: 0, total_steps: 0 12 | 13 | def schematic() do 14 | schema(__MODULE__, %{ 15 | field(:calendar_date) => date(), 16 | field(:step_goal) => int(), 17 | field(:total_distance) => int(), 18 | field(:total_steps) => int() 19 | }) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/nimrag/api/user_settings.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.UserSettings do 2 | use Nimrag.Api.Data 3 | alias Nimrag.Api.UserData 4 | 5 | @type t() :: %__MODULE__{ 6 | id: integer(), 7 | user_data: UserData.t(), 8 | # user_sleep: UserSleep 9 | connect_date: nil | String.t(), 10 | source_type: nil | String.t() 11 | } 12 | 13 | defstruct ~w(id user_data connect_date source_type)a 14 | 15 | def schematic() do 16 | schema(__MODULE__, %{ 17 | field(:id) => int(), 18 | field(:user_data) => UserData.schematic(), 19 | # user_sleep: UserSleep 20 | field(:connect_date) => nullable(str()), 21 | field(:source_type) => nullable(str()) 22 | }) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/nimrag/api/activity_type.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.ActivityType do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{ 5 | is_hidden: boolean(), 6 | parent_type_id: integer(), 7 | restricted: boolean(), 8 | trimmable: boolean(), 9 | type_id: integer(), 10 | type_key: String.t() 11 | } 12 | 13 | defstruct ~w(is_hidden parent_type_id restricted trimmable type_id type_key)a 14 | 15 | def schematic() do 16 | schema(__MODULE__, %{ 17 | field(:is_hidden) => bool(), 18 | field(:parent_type_id) => int(), 19 | field(:restricted) => bool(), 20 | field(:trimmable) => bool(), 21 | field(:type_id) => int(), 22 | field(:type_key) => str() 23 | }) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.github/workflows/elixir-build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | name: Build and test 14 | runs-on: ubuntu-latest 15 | env: 16 | MIX_ENV: test 17 | strategy: 18 | matrix: 19 | elixir: ["1.16.2"] 20 | otp: ["25.3.2"] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v2 25 | 26 | - name: Setup Elixir Project 27 | uses: ./.github/actions/elixir-setup 28 | with: 29 | elixir-version: ${{ matrix.elixir }} 30 | otp-version: ${{ matrix.otp }} 31 | build-flags: --all-warnings --warnings-as-errors 32 | 33 | - name: Run Tests 34 | run: mix coveralls.json --warnings-as-errors 35 | if: always() 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | nimrag-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | priv/plts 29 | 30 | # dot examples are not commited until they're ready! 31 | # makes it nicer to live test some APIs when local scripts 32 | examples/.* 33 | -------------------------------------------------------------------------------- /lib/nimrag/api/steps_weekly.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.StepsWeekly do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{ 5 | calendar_date: String.t(), 6 | values: %{ 7 | total_steps: float(), 8 | average_steps: float(), 9 | average_distance: float(), 10 | total_distance: float(), 11 | wellness_data_days_count: integer() 12 | } 13 | } 14 | 15 | defstruct ~w(calendar_date values)a 16 | 17 | def schematic() do 18 | schema(__MODULE__, %{ 19 | field(:calendar_date) => date(), 20 | field(:values) => 21 | map(%{ 22 | field(:total_steps) => float(), 23 | field(:average_steps) => float(), 24 | field(:average_distance) => float(), 25 | field(:total_distance) => float(), 26 | field(:wellness_data_days_count) => int() 27 | }) 28 | }) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Michal Forys 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/nimrag/api/activity_details.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.ActivityDetails do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{ 5 | activity_id: integer(), 6 | metric_descriptors: list(), 7 | activity_detail_metrics: list(map()), 8 | measurement_count: integer(), 9 | activity_detail_metrics: list(map()) 10 | } 11 | 12 | defstruct ~w( 13 | activity_id metric_descriptors measurement_count activity_detail_metrics 14 | )a 15 | 16 | def schematic() do 17 | schema(__MODULE__, %{ 18 | field(:activity_id) => int(), 19 | field(:metric_descriptors) => 20 | list( 21 | map(%{ 22 | field(:key) => str(), 23 | field(:metrics_index) => int(), 24 | field(:unit) => 25 | map(%{ 26 | field(:factor) => float(), 27 | field(:id) => int(), 28 | field(:key) => str() 29 | }) 30 | }) 31 | ), 32 | field(:measurement_count) => int(), 33 | field(:activity_detail_metrics) => list(map(%{field(:metrics) => list(nullable(float()))})) 34 | }) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/fixtures/api/__usersummary-service__stats__steps__weekly__2024-05-01__4.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "calendarDate": "2024-04-04", 4 | "values": { 5 | "averageDistance": 20809.85714285714, 6 | "averageSteps": 23066.285714285714, 7 | "totalDistance": 145669.0, 8 | "totalSteps": 161464.0, 9 | "wellnessDataDaysCount": 7 10 | } 11 | }, 12 | { 13 | "calendarDate": "2024-04-11", 14 | "values": { 15 | "averageDistance": 24847.714285714286, 16 | "averageSteps": 26442.0, 17 | "totalDistance": 173934.0, 18 | "totalSteps": 185094.0, 19 | "wellnessDataDaysCount": 7 20 | } 21 | }, 22 | { 23 | "calendarDate": "2024-04-18", 24 | "values": { 25 | "averageDistance": 22184.85714285714, 26 | "averageSteps": 24670.14285714286, 27 | "totalDistance": 155294.0, 28 | "totalSteps": 172691.0, 29 | "wellnessDataDaysCount": 7 30 | } 31 | }, 32 | { 33 | "calendarDate": "2024-04-25", 34 | "values": { 35 | "averageDistance": 23794.428571428572, 36 | "averageSteps": 25655.571428571428, 37 | "totalDistance": 166561.0, 38 | "totalSteps": 179589.0, 39 | "wellnessDataDaysCount": 7 40 | } 41 | } 42 | ] -------------------------------------------------------------------------------- /lib/nimrag/oauth1_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.OAuth1Token do 2 | @moduledoc """ 3 | See `Nimrag.Credentials` for more details on how to obtain auth tokens. 4 | """ 5 | @type t() :: %__MODULE__{ 6 | oauth_token: nil | String.t(), 7 | oauth_token_secret: nil | String.t(), 8 | mfa_token: nil | String.t(), 9 | domain: nil | String.t(), 10 | expires_at: nil | DateTime.t() 11 | } 12 | @derive Jason.Encoder 13 | defstruct ~w(oauth_token oauth_token_secret mfa_token domain expires_at)a 14 | 15 | @spec expired?(t()) :: boolean() 16 | def expired?(%__MODULE__{expires_at: nil}), do: true 17 | 18 | def expired?(%__MODULE__{expires_at: expires_at}), 19 | do: DateTime.before?(expires_at, DateTime.utc_now()) 20 | end 21 | 22 | defimpl Inspect, for: Nimrag.OAuth1Token do 23 | alias Nimrag.OAuth1Token 24 | import Inspect.Algebra 25 | 26 | def inspect( 27 | %OAuth1Token{oauth_token: oauth_token, mfa_token: mfa_token} = token, 28 | opts 29 | ) do 30 | details = 31 | Inspect.List.inspect( 32 | [ 33 | oauth_token: String.slice(oauth_token || "", 0, 5) <> "...", 34 | mfa_token: String.slice(mfa_token || "", 0, 5) <> "...", 35 | expired?: OAuth1Token.expired?(token), 36 | expires_at: token.expires_at 37 | ], 38 | opts 39 | ) 40 | 41 | concat(["#Nimrag.OAuth1Token<", details, ">"]) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/nimrag/api/activity_list.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.ActivityList do 2 | use Nimrag.Api.Data 3 | alias Nimrag.Api.ActivityType 4 | 5 | @type t() :: %__MODULE__{ 6 | id: integer(), 7 | distance: float(), 8 | duration: float(), 9 | activity_name: String.t(), 10 | begin_at: DateTime.t(), 11 | start_local_at: NaiveDateTime.t(), 12 | average_hr: float(), 13 | max_hr: float(), 14 | elevation_gain: float(), 15 | elevation_loss: float(), 16 | description: nil | String.t(), 17 | activity_type: ActivityType.t() 18 | } 19 | 20 | defstruct ~w( 21 | id distance duration begin_at start_local_at activity_name 22 | average_hr max_hr elevation_gain elevation_loss description activity_type 23 | )a 24 | 25 | def schematic() do 26 | schema(__MODULE__, %{ 27 | {"beginTimestamp", :begin_at} => timestamp_datetime(), 28 | {"startTimeLocal", :start_local_at} => naive_datetime(), 29 | {"activityId", :id} => int(), 30 | field(:activity_name) => str(), 31 | :distance => float(), 32 | :duration => float(), 33 | {"averageHR", :average_hr} => float(), 34 | {"maxHR", :max_hr} => float(), 35 | field(:elevationGain) => float(), 36 | field(:elevationLoss) => float(), 37 | field(:description) => nullable(str()), 38 | field(:activity_type) => ActivityType.schematic() 39 | }) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/nimrag/api/activity.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.Activity do 2 | use Nimrag.Api.Data 3 | alias Nimrag.Api.ActivityType 4 | 5 | defmodule Summary do 6 | @type t() :: %__MODULE__{ 7 | distance: float(), 8 | duration: float(), 9 | average_hr: float(), 10 | max_hr: float(), 11 | elevation_gain: float(), 12 | elevation_loss: float() 13 | } 14 | 15 | defstruct ~w( 16 | id distance duration average_hr max_hr elevation_gain elevation_loss 17 | )a 18 | 19 | def schematic() do 20 | schema(__MODULE__, %{ 21 | field(:distance) => float(), 22 | field(:duration) => float(), 23 | {"maxHR", :max_hr} => float(), 24 | {"averageHR", :average_hr} => float(), 25 | field(:elevation_gain) => float(), 26 | field(:elevation_loss) => float() 27 | }) 28 | end 29 | end 30 | 31 | @type t() :: %__MODULE__{ 32 | id: integer(), 33 | activity_name: String.t(), 34 | activity_type: ActivityType.t(), 35 | summary: __MODULE__.Summary.t() 36 | } 37 | 38 | defstruct ~w( 39 | id activity_name activity_type summary 40 | )a 41 | 42 | def schematic() do 43 | schema(__MODULE__, %{ 44 | {"activityId", :id} => int(), 45 | field(:activity_name) => str(), 46 | {"activityTypeDTO", :activity_type} => ActivityType.schematic(), 47 | {"summaryDTO", :summary} => __MODULE__.Summary.schematic() 48 | }) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.github/workflows/elixir-quality-checks.yml: -------------------------------------------------------------------------------- 1 | name: Elixir Quality Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | quality_checks: 13 | name: Formatting, and Unused Deps 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | elixir: ["1.16.2"] 18 | otp: ["25.3.2"] 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v2 23 | 24 | - name: Setup Elixir Project 25 | uses: ./.github/actions/elixir-setup 26 | with: 27 | elixir-version: ${{ matrix.elixir }} 28 | otp-version: ${{ matrix.otp }} 29 | build-app: false 30 | 31 | - name: Check for unused deps 32 | run: mix deps.unlock --check-unused 33 | - name: Check code formatting 34 | run: mix format --check-formatted 35 | # Check formatting even if there were unused deps so that 36 | # we give devs as much feedback as possible & save some time. 37 | if: always() 38 | - name: Run Credo 39 | run: mix credo suggest --min-priority=normal 40 | # Run Credo even if formatting or the unused deps check failed 41 | if: always() 42 | # - name: Check for compile-time dependencies 43 | # run: mix xref graph --label compile-connected --fail-above 0 44 | # if: always() 45 | # - name: Check for security vulnerabilities in Phoenix project 46 | # run: mix sobelow 47 | # if: always() 48 | -------------------------------------------------------------------------------- /test/support/api_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.ApiHelper do 2 | require Logger 3 | 4 | def store_response_as_test_fixture({:ok, %Req.Response{} = resp, _}) do 5 | request_path = Req.Response.get_private(resp, :request_path) 6 | path = rel_fixture_path(request_path) 7 | 8 | File.write!(path, Jason.encode!(resp.body, pretty: true)) 9 | Logger.debug(fn -> "Stored as test fixture: #{Path.relative_to(path, root())}" end) 10 | end 11 | 12 | def read_response_fixture(conn) do 13 | path = rel_fixture_path(conn.request_path) 14 | 15 | case File.read(path) do 16 | {:ok, data} -> 17 | Jason.decode!(data) 18 | 19 | {:error, reason} -> 20 | raise """ 21 | Failed to read fixture: #{inspect(reason)} 22 | 23 | Fix it: 24 | 25 | $ touch #{Path.relative_to(path, root())} 26 | $ nvim #{Path.relative_to(path, root())} 27 | 28 | Then add Garmin's JSON response. 29 | 30 | https://mitmproxy.org/ is an easy way to capture lots raw responses. 31 | """ 32 | end 33 | end 34 | 35 | defp rel_fixture_path(request_path) do 36 | filename = String.replace(request_path, "/", "__") <> ".json" 37 | Path.join([root(), "test", "fixtures", "api", filename]) 38 | end 39 | 40 | defp root() do 41 | Path.join([__DIR__, "..", ".."]) 42 | end 43 | 44 | @spec client :: Nimrag.Client.t() | no_return 45 | def client do 46 | Nimrag.Client.new( 47 | req_options: [plug: {Req.Test, Nimrag.Api}], 48 | rate_limit: false 49 | ) 50 | |> Nimrag.Client.with_auth({ 51 | %Nimrag.OAuth1Token{}, 52 | %Nimrag.OAuth2Token{ 53 | scope: "WRITE", 54 | jti: "uuid-1234-5678-9012-3456", 55 | token_type: "Bearer", 56 | refresh_token: "test-refresh-token", 57 | access_token: "test-access-token", 58 | expires_at: DateTime.utc_now() |> DateTime.add(1, :hour), 59 | refresh_token_expires_at: DateTime.utc_now() |> DateTime.add(1, :hour) 60 | } 61 | }) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/nimrag/oauth2_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.OAuth2Token do 2 | @moduledoc """ 3 | See `Nimrag.Credentials` for more details on how to obtain auth tokens. 4 | """ 5 | @type t() :: %__MODULE__{ 6 | scope: nil | String.t(), 7 | jti: nil | String.t(), 8 | token_type: nil | String.t(), 9 | refresh_token: nil | String.t(), 10 | access_token: nil | String.t(), 11 | expires_at: nil | DateTime.t(), 12 | refresh_token_expires_at: nil | DateTime.t() 13 | } 14 | @derive Jason.Encoder 15 | defstruct ~w( 16 | scope jti token_type refresh_token access_token expires_at 17 | refresh_token_expires_at 18 | )a 19 | 20 | @spec expired?(t()) :: boolean() 21 | def expired?(%__MODULE__{expires_at: nil}), do: true 22 | 23 | def expired?(%__MODULE__{expires_at: expires_at}), 24 | do: DateTime.before?(expires_at, DateTime.utc_now()) 25 | 26 | @spec refresh_token_expired?(t()) :: boolean() 27 | def refresh_token_expired?(%__MODULE__{refresh_token_expires_at: nil}), do: true 28 | 29 | def refresh_token_expired?(%__MODULE__{refresh_token_expires_at: expires_at}), 30 | do: DateTime.before?(expires_at, DateTime.utc_now()) 31 | end 32 | 33 | defimpl Inspect, for: Nimrag.OAuth2Token do 34 | alias Nimrag.OAuth2Token 35 | import Inspect.Algebra 36 | 37 | def inspect( 38 | %OAuth2Token{access_token: access_token, refresh_token: refresh_token} = token, 39 | opts 40 | ) do 41 | details = 42 | Inspect.List.inspect( 43 | [ 44 | access_token: String.slice(access_token || "", 0, 5) <> "...", 45 | refresh_token: String.slice(refresh_token || "", 0, 5) <> "...", 46 | expires_at: token.expires_at, 47 | expired?: OAuth2Token.expired?(token), 48 | refresh_token_expires_at: token.refresh_token_expires_at, 49 | refresh_token_expired?: OAuth2Token.refresh_token_expired?(token) 50 | ], 51 | opts 52 | ) 53 | 54 | concat(["#Nimrag.OAuth2Token<", details, ">"]) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/nimrag/api/data.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.Data do 2 | import Schematic 3 | 4 | @moduledoc false 5 | 6 | # Helper module for transforming API responses into proper structs 7 | # Uses https://github.com/mhanberg/schematic and adds 8 | 9 | defmacro __using__(_) do 10 | quote do 11 | import Nimrag.Api.Data 12 | import Schematic 13 | 14 | alias Nimrag.Api 15 | 16 | def from_api_response(resp) do 17 | Nimrag.Api.Data.from_api_response(resp, __MODULE__) 18 | end 19 | end 20 | end 21 | 22 | def from_api_response(resp, module) do 23 | case unify(module.schematic(), resp) do 24 | {:error, err} -> {:error, {:invalid_response, err, resp}} 25 | {:ok, data} -> {:ok, data} 26 | end 27 | end 28 | 29 | def timestamp_datetime() do 30 | raw( 31 | fn 32 | i, :to -> is_number(i) and match?({:ok, _}, DateTime.from_unix(i, :millisecond)) 33 | i, :from -> match?(%DateTime{}, i) 34 | end, 35 | transform: fn 36 | i, :to -> 37 | {:ok, dt} = DateTime.from_unix(i, :millisecond) 38 | dt 39 | 40 | i, :from -> 41 | DateTime.to_unix(i, :millisecond) 42 | end 43 | ) 44 | end 45 | 46 | def naive_datetime() do 47 | raw( 48 | fn 49 | i, :to -> is_binary(i) and match?({:ok, _}, NaiveDateTime.from_iso8601(i)) 50 | i, :from -> match?(%NaiveDateTime{}, i) 51 | end, 52 | transform: fn 53 | i, :to -> 54 | {:ok, dt} = NaiveDateTime.from_iso8601(i) 55 | dt 56 | 57 | i, :from -> 58 | NaiveDateTime.to_iso8601(i) 59 | end 60 | ) 61 | end 62 | 63 | def date() do 64 | raw( 65 | fn 66 | i, :to -> is_binary(i) and match?({:ok, _}, Date.from_iso8601(i)) 67 | i, :from -> match?(%Date{}, i) 68 | end, 69 | transform: fn 70 | i, :to -> 71 | {:ok, dt} = Date.from_iso8601(i) 72 | dt 73 | 74 | i, :from -> 75 | Date.to_iso8601(i) 76 | end 77 | ) 78 | end 79 | 80 | def field(field) when is_atom(field), 81 | do: { 82 | field |> to_string() |> Recase.to_camel(), 83 | field 84 | } 85 | end 86 | -------------------------------------------------------------------------------- /.github/workflows/elixir-dialyzer.yml: -------------------------------------------------------------------------------- 1 | name: Elixir Type Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | name: Run Dialyzer 14 | runs-on: ubuntu-latest 15 | env: 16 | MIX_ENV: dev 17 | strategy: 18 | matrix: 19 | elixir: ["1.16.2"] 20 | otp: ["25.3.2"] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v2 25 | 26 | - name: Setup Elixir Project 27 | uses: ./.github/actions/elixir-setup 28 | id: beam 29 | with: 30 | elixir-version: ${{ matrix.elixir }} 31 | otp-version: ${{ matrix.otp }} 32 | build-app: false 33 | 34 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 35 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 36 | - name: Restore PLT cache 37 | uses: actions/cache@v3 38 | id: plt_cache 39 | with: 40 | key: plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} 41 | restore-keys: | 42 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} 43 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}- 44 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- 45 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}- 46 | path: priv/plts 47 | 48 | # Create PLTs if no cache was found. 49 | # Always rebuild PLT when a job is retried 50 | # (If they were cached at all, they'll be updated when we run mix dialyzer with no flags.) 51 | - name: Create PLTs 52 | if: steps.plt_cache.outputs.cache-hit != 'true' || github.run_attempt != '1' 53 | run: mix dialyzer --plt 54 | 55 | - name: Run Dialyzer 56 | run: mix dialyzer --format github 57 | -------------------------------------------------------------------------------- /lib/nimrag/api/sleep_daily.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.SleepDaily do 2 | use Nimrag.Api.Data 3 | 4 | defmodule Sleep do 5 | use Nimrag.Api.Data 6 | 7 | @type t() :: %__MODULE__{ 8 | id: integer(), 9 | calendar_date: Date.t(), 10 | sleep_time_seconds: integer(), 11 | nap_time_seconds: integer(), 12 | sleep_start_timestamp_local: DateTime.t(), 13 | sleep_end_timestamp_local: DateTime.t() 14 | } 15 | defstruct ~w(id calendar_date sleep_time_seconds nap_time_seconds sleep_start_timestamp_local sleep_end_timestamp_local)a 16 | 17 | def schematic() do 18 | schema(__MODULE__, %{ 19 | field(:id) => int(), 20 | field(:calendar_date) => date(), 21 | field(:sleep_time_seconds) => int(), 22 | field(:nap_time_seconds) => int(), 23 | field(:sleep_start_timestamp_local) => timestamp_datetime(), 24 | field(:sleep_end_timestamp_local) => timestamp_datetime() 25 | }) 26 | end 27 | end 28 | 29 | defmodule SleepMovement do 30 | use Nimrag.Api.Data 31 | 32 | @type t() :: %__MODULE__{ 33 | start_gmt: DateTime.t(), 34 | end_gmt: DateTime.t(), 35 | activity_level: float() 36 | } 37 | defstruct ~w(start_gmt end_gmt activity_level)a 38 | 39 | def schematic() do 40 | schema(__MODULE__, %{ 41 | {"startGMT", :start_gmt} => naive_datetime(), 42 | {"endGMT", :end_gmt} => naive_datetime(), 43 | field(:activity_level) => float() 44 | }) 45 | end 46 | end 47 | 48 | alias __MODULE__.{Sleep, SleepMovement} 49 | 50 | @type t() :: %__MODULE__{ 51 | sleep: nil | Sleep.t(), 52 | sleep_movement: nil | SleepMovement.t(), 53 | avg_overnight_hrv: nil | float(), 54 | resting_heart_rate: nil | integer() 55 | } 56 | 57 | defstruct ~w(sleep sleep_movement avg_overnight_hrv resting_heart_rate)a 58 | 59 | def schematic() do 60 | schema(__MODULE__, %{ 61 | {"dailySleepDTO", :sleep} => nullable(Sleep.schematic()), 62 | field(:avg_overnight_hrv) => nullable(float()), 63 | field(:sleep_movement) => nullable(list(SleepMovement.schematic())), 64 | field(:resting_heart_rate) => nullable(int()) 65 | }) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /examples/sleep.livemd: -------------------------------------------------------------------------------- 1 | # Sleep 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:nimrag, path: "/data"}, 6 | {:kino, "~> 0.12"}, 7 | {:kino_vega_lite, "~> 0.1.10"}, 8 | {:explorer, "~> 0.8.0"}, 9 | # humanized format for durations from activities! 10 | {:timex, "~> 3.7.11"} 11 | ]) 12 | ``` 13 | 14 | ## Section 15 | 16 | To learn more about Nimrag, check the [documentation](https://hexdocs.pm/nimrag). 17 | 18 | This notebook assumes ready to-go OAuth tokens are saved in the filesystem. 19 | 20 | ```elixir 21 | client = 22 | Nimrag.Client.new() 23 | |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) 24 | 25 | {:ok, %Nimrag.Api.Profile{} = profile, client} = Nimrag.profile(client) 26 | :ok = Nimrag.Credentials.write_fs_oauth2_token(client) 27 | 28 | username = "arathunku" 29 | Kino.nothing() 30 | ``` 31 | 32 | ## Daily sleep 33 | 34 | ```elixir 35 | prev_number_of_days = 14 36 | 37 | sleep_data = 38 | prev_number_of_days..0 39 | |> Enum.map(fn day_shift -> 40 | date = Date.utc_today() |> Date.add(-1 * day_shift) 41 | 42 | {:ok, %Nimrag.Api.SleepDaily{} = sleep_daily, _client} = 43 | Nimrag.sleep_daily( 44 | client, 45 | username, 46 | date 47 | ) 48 | 49 | sleep_daily 50 | end) 51 | ``` 52 | 53 | ```elixir 54 | sleep_stats = 55 | sleep_data 56 | |> Enum.map( 57 | &%{ 58 | date: &1.sleep.calendar_date, 59 | duration_hours: &1.sleep.sleep_time_seconds / 3600, 60 | duration: 61 | &1.sleep.sleep_time_seconds 62 | |> Timex.Duration.from_seconds() 63 | |> Timex.format_duration(:humanized) 64 | } 65 | ) 66 | |> Explorer.DataFrame.new() 67 | 68 | VegaLite.new(title: "Sleep duration", width: 720, height: 400) 69 | |> VegaLite.data_from_values(sleep_stats, only: ["date", "duration_hours", "duration"]) 70 | |> VegaLite.mark(:bar, tooltip: true) 71 | |> VegaLite.encode_field(:y, "duration_hours", 72 | type: :quantitative, 73 | axis: %{title: "Duration", values: 0..24 |> Enum.to_list()} 74 | ) 75 | |> VegaLite.encode_field(:x, "date", 76 | type: :temporal, 77 | time_unit: "yearmonthdate", 78 | band_position: 0.5, 79 | axis: %{title: "Date", label_angle: -90, tick_count: [interval: "day", step: 1]} 80 | ) 81 | |> VegaLite.encode(:tooltip, [ 82 | [field: "duration", type: :nominal], 83 | [field: "date", type: :temporal] 84 | ]) 85 | |> VegaLite.param("hover", select: [type: "point", on: "pointerover", clear: "pointerout"]) 86 | ``` 87 | -------------------------------------------------------------------------------- /test/nimrag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule NimragTest do 2 | use ExUnit.Case 3 | alias Nimrag 4 | import Nimrag.ApiHelper 5 | 6 | doctest Nimrag 7 | 8 | test "#profile" do 9 | Req.Test.stub(Nimrag.Api, fn conn -> 10 | Req.Test.json(conn, read_response_fixture(conn)) 11 | end) 12 | 13 | assert {:ok, _profile, _client} = Nimrag.profile(client()) 14 | end 15 | 16 | test "#steps_daily" do 17 | Req.Test.stub(Nimrag.Api, fn conn -> 18 | Req.Test.json(conn, read_response_fixture(conn)) 19 | end) 20 | 21 | assert {:ok, _steps_daily, _client} = 22 | Nimrag.steps_daily(client(), ~D|2024-04-06|, ~D|2024-04-06|) 23 | end 24 | 25 | test "#activities" do 26 | Req.Test.stub(Nimrag.Api, fn conn -> 27 | Req.Test.json(conn, read_response_fixture(conn)) 28 | end) 29 | 30 | assert {:ok, _activities, _client} = Nimrag.activities(client(), 0, 1) 31 | end 32 | 33 | test "#user_settings" do 34 | Req.Test.stub(Nimrag.Api, fn conn -> 35 | Req.Test.json(conn, read_response_fixture(conn)) 36 | end) 37 | 38 | assert {:ok, _user_settings, _client} = Nimrag.user_settings(client()) 39 | end 40 | 41 | test "#steps_weekly" do 42 | Req.Test.stub(Nimrag.Api, fn conn -> 43 | Req.Test.json(conn, read_response_fixture(conn)) 44 | end) 45 | 46 | assert {:ok, steps_weekly, _client} = Nimrag.steps_weekly(client(), ~D|2024-05-01|, 4) 47 | 48 | assert Enum.count(steps_weekly) == 4 49 | assert hd(steps_weekly).calendar_date == ~D[2024-04-04] 50 | # Garmin is very strange with "weekly" grouping... 51 | assert List.last(steps_weekly).calendar_date == ~D[2024-04-25] 52 | end 53 | 54 | test "#sleep_daily" do 55 | Req.Test.stub(Nimrag.Api, fn conn -> 56 | Req.Test.json(conn, read_response_fixture(conn)) 57 | end) 58 | 59 | assert {:ok, %Nimrag.Api.SleepDaily{}, _client} = 60 | Nimrag.sleep_daily(client(), "arathunku", ~D|2024-05-01|, 60) 61 | end 62 | 63 | test "#activity" do 64 | Req.Test.stub(Nimrag.Api, fn conn -> 65 | Req.Test.json(conn, read_response_fixture(conn)) 66 | end) 67 | 68 | assert {:ok, %Nimrag.Api.Activity{}, _client} = Nimrag.activity(client(), 15_205_844_761) 69 | end 70 | 71 | test "#activity_details" do 72 | Req.Test.stub(Nimrag.Api, fn conn -> 73 | Req.Test.json(conn, read_response_fixture(conn)) 74 | end) 75 | 76 | assert {:ok, %Nimrag.Api.ActivityDetails{}, _client} = 77 | Nimrag.activity_details(client(), 15_205_844_761) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/fixtures/api/__userprofile-service__socialProfile.json: -------------------------------------------------------------------------------- 1 | { 2 | "swimmingTrainingSpeed": 0.0, 3 | "id": 1337, 4 | "showWeight": false, 5 | "levelUpdateDate": "2023-03-08T16:52:38.0", 6 | "nameApproved": true, 7 | "motivation": 5, 8 | "showHeight": false, 9 | "showWeightClass": false, 10 | "profileImageUrlSmall": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/-cb9ea33662d3-prth.png", 11 | "facebookUrl": "", 12 | "cyclingClassification": null, 13 | "courseVisibility": "public", 14 | "allowGolfScoringByConnections": true, 15 | "otherMotivation": null, 16 | "personalWebsite": "https://github.com/arathunku/nimrag", 17 | "userPro": false, 18 | "showUpcomingEvents": true, 19 | "showVO2Max": false, 20 | "location": "Germany", 21 | "profileImageUrlLarge": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/a00bb-4342-923d-cb9ea33662d3-prof.png", 22 | "runningTrainingSpeed": 2.9411764, 23 | "showGender": false, 24 | "profileId": 100, 25 | "userProfileFullName": "Michal Example", 26 | "userLevel": 4, 27 | "levelIsViewed": true, 28 | "showRecentGear": false, 29 | "favoriteActivityTypes": ["running", "hiking"], 30 | "cyclingTrainingSpeed": 0.0, 31 | "showPersonalRecords": true, 32 | "showAge": false, 33 | "otherActivity": "", 34 | "otherPrimaryActivity": null, 35 | "badgeVisibility": "groups", 36 | "cyclingMaxAvgPower": 0.0, 37 | "userPoint": 252, 38 | "showLast12Months": true, 39 | "userRoles": [ 40 | "SCOPE_ATP_READ", 41 | "SCOPE_ATP_WRITE", 42 | "SCOPE_COMMUNITY_COURSE_READ", 43 | "SCOPE_COMMUNITY_COURSE_WRITE", 44 | "SCOPE_CONNECT_READ", 45 | "SCOPE_CONNECT_WRITE", 46 | "SCOPE_DT_CLIENT_ANALYTICS_WRITE", 47 | "SCOPE_GARMINPAY_READ", 48 | "SCOPE_GARMINPAY_WRITE", 49 | "SCOPE_GCOFFER_READ", 50 | "SCOPE_GCOFFER_WRITE", 51 | "SCOPE_GHS_SAMD", 52 | "SCOPE_GHS_UPLOAD", 53 | "SCOPE_GOLF_API_READ", 54 | "SCOPE_GOLF_API_WRITE", 55 | "SCOPE_INSIGHTS_READ", 56 | "SCOPE_INSIGHTS_WRITE", 57 | "SCOPE_OMT_SUBSCRIPTION_READ", 58 | "SCOPE_PRODUCT_SEARCH_READ", 59 | "ROLE_CONNECTUSER", 60 | "ROLE_FITNESS_USER", 61 | "ROLE_WELLNESS_USER", 62 | "ROLE_OUTDOOR_USER", 63 | "ROLE_CONNECT_2_USER" 64 | ], 65 | "allowGolfLiveScoring": false, 66 | "activityPowerVisibility": "public", 67 | "showActivityClass": false, 68 | "displayName": "Michal", 69 | "bio": "photos, comments -> https://www.strava.com/athletes/555 ", 70 | "favoriteCyclingActivityTypes": [], 71 | "userPointOffset": 0, 72 | "levelPointThreshold": 300, 73 | "showRecentDevice": true, 74 | "showRecentFavorites": false, 75 | "twitterUrl": "", 76 | "userName": "nimrag", 77 | "activityStartVisibility": "private", 78 | "activityMapVisibility": "public", 79 | "profileVisibility": "public", 80 | "fullName": "Michal some name", 81 | "showAgeRange": false, 82 | "garminGUID": "a5305542-5811-46cc-b", 83 | "makeGolfScorecardsPrivate": true, 84 | "activityHeartRateVisibility": "public", 85 | "primaryActivity": "running", 86 | "profileImageUrlMedium": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/prfr.png", 87 | "showLifetimeTotals": true, 88 | "showBadges": true 89 | } 90 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib/", "src/"], 7 | excluded: [] 8 | }, 9 | plugins: [], 10 | requires: [], 11 | strict: false, 12 | parse_timeout: 5000, 13 | color: true, 14 | checks: %{ 15 | disabled: [ 16 | # Styler Rewrites 17 | # 18 | # The following rules are automatically rewritten by Styler and so disabled here to save time 19 | # Some of the rules have `priority: :high`, meaning Credo runs them unless we explicitly disable them 20 | # (removing them from this file wouldn't be enough, the `false` is required) 21 | # 22 | # Some rules have a comment before them explaining ways Styler deviates from the Credo rule. 23 | # 24 | # always expands `A.{B, C}` 25 | # {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 26 | # including `case`, `fn` and `with` statements 27 | {Credo.Check.Consistency.ParameterPatternMatching, false}, 28 | # Styler implements this rule with a depth of 3 and minimum repetition of 2 29 | {Credo.Check.Design.AliasUsage, false}, 30 | {Credo.Check.Readability.AliasOrder, false}, 31 | {Credo.Check.Readability.BlockPipe, false}, 32 | # goes further than formatter - fixes bad underscores, eg: `100_00` -> `10_000` 33 | {Credo.Check.Readability.LargeNumbers, false}, 34 | # adds `@moduledoc false` 35 | {Credo.Check.Readability.ModuleDoc, false}, 36 | {Credo.Check.Readability.MultiAlias, false}, 37 | {Credo.Check.Readability.OneArityFunctionInPipe, false}, 38 | # removes parens 39 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, 40 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, false}, 41 | {Credo.Check.Readability.PreferImplicitTry, false}, 42 | {Credo.Check.Readability.SinglePipe, false}, 43 | # **potentially breaks compilation** - see **Troubleshooting** section below 44 | {Credo.Check.Readability.StrictModuleLayout, false}, 45 | {Credo.Check.Readability.StringSigils, false}, 46 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 47 | {Credo.Check.Readability.WithSingleClause, false}, 48 | {Credo.Check.Refactor.CaseTrivialMatches, false}, 49 | {Credo.Check.Refactor.CondStatements, false}, 50 | # in pipes only 51 | {Credo.Check.Refactor.FilterCount, false}, 52 | # in pipes only 53 | {Credo.Check.Refactor.MapInto, false}, 54 | # in pipes only 55 | {Credo.Check.Refactor.MapJoin, false}, 56 | # {Credo.Check.Refactor.NegatedConditionsInUnless, false}, 57 | # {Credo.Check.Refactor.NegatedConditionsWithElse, false}, 58 | # allows ecto's `from 59 | {Credo.Check.Refactor.PipeChainStart, false}, 60 | {Credo.Check.Refactor.RedundantWithClauseResult, false}, 61 | {Credo.Check.Refactor.UnlessWithElse, false}, 62 | {Credo.Check.Refactor.WithClauses, false}, 63 | 64 | # custom ext_fit rules 65 | {Credo.Check.Refactor.Nesting, false}, 66 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 67 | {Credo.Check.Design.TagFIXME, false}, 68 | {Credo.Check.Design.TagTODO, false} 69 | ] 70 | } 71 | } 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.1.0" 5 | 6 | def project do 7 | [ 8 | app: :nimrag, 9 | version: @version, 10 | elixir: "~> 1.15", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | elixirc_options: [ 13 | warnings_as_errors: !!System.get_env("CI") 14 | ], 15 | consolidate_protocols: Mix.env() != :test, 16 | deps: deps(), 17 | package: package(), 18 | name: "Nimrag", 19 | source_url: "https://github.com/arathunku/nimrag", 20 | homepage_url: "https://github.com/arathunku/nimrag", 21 | docs: &docs/0, 22 | description: """ 23 | Use Garmin API from Elixir! Fetch activities, steps, and more from Garmin Connect. 24 | """, 25 | aliases: aliases(), 26 | test_coverage: [tool: ExCoveralls], 27 | preferred_cli_env: [ 28 | check: :test, 29 | coveralls: :test, 30 | "coveralls.detail": :test, 31 | "coveralls.html": :test 32 | ], 33 | dialyzer: [ 34 | ignore_warnings: ".dialyzer_ignore.exs", 35 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 36 | plt_add_apps: [:hammer], 37 | flags: [:error_handling, :unknown], 38 | # Error out when an ignore rule is no longer useful so we can remove it 39 | list_unused_filters: true 40 | ] 41 | ] 42 | end 43 | 44 | def application do 45 | if Application.get_env(:hammer, :backend) in [[], nil] do 46 | Application.put_env( 47 | :hammer, 48 | :backend, 49 | {Hammer.Backend.ETS, 50 | [ 51 | expiry_ms: 60_000 * 60 * 2, 52 | cleanup_interval_ms: 60_000 * 2 53 | ]} 54 | ) 55 | end 56 | 57 | [ 58 | # mod: {Nimrag.Application, []}, 59 | extra_applications: [:logger] 60 | ] 61 | end 62 | 63 | defp elixirc_paths(:test), do: ["lib", "test/support"] 64 | defp elixirc_paths(_), do: ["lib"] 65 | 66 | defp deps do 67 | [ 68 | {:req, "~> 0.5.0"}, 69 | {:oauther, "~> 1.1"}, 70 | {:jason, "~> 1.4"}, 71 | {:recase, "~> 0.7"}, 72 | {:schematic, "~> 0.3"}, 73 | {:hammer, "~> 6.2"}, 74 | {:plug, "~> 1.0", only: [:test]}, 75 | {:excoveralls, "~> 0.18.1", only: [:dev, :test], runtime: false}, 76 | {:ex_doc, "~> 0.31", only: :dev, runtime: false}, 77 | {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, 78 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 79 | {:styler, "~> 0.11", only: [:dev, :test], runtime: false} 80 | ] 81 | end 82 | 83 | defp aliases do 84 | [ 85 | check: [ 86 | "clean", 87 | "deps.unlock --check-unused", 88 | "compile --warnings-as-errors", 89 | "format --check-formatted", 90 | "deps.unlock --check-unused", 91 | "test --warnings-as-errors", 92 | "dialyzer --format short", 93 | "credo" 94 | ] 95 | ] 96 | end 97 | 98 | defp docs do 99 | [ 100 | source_ref: "v#{@version}", 101 | source_url: "https://github.com/arathunku/nimrag", 102 | extras: extras(), 103 | api_reference: false, 104 | groups_for_extras: [ 105 | {"Livebook examples", Path.wildcard("examples/*")} 106 | ], 107 | formatters: ["html"], 108 | main: "readme", 109 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"] 110 | ] 111 | end 112 | 113 | def extras do 114 | [ 115 | "README.md": [title: "Overview"], 116 | "CHANGELOG.md": [title: "Changelog"], 117 | # "CONTRIBUTING.md": [title: "Contributing"], 118 | "LICENSE.md": [title: "License"] 119 | ] ++ Path.wildcard("examples/*.livemd") 120 | end 121 | 122 | defp package do 123 | [ 124 | maintainers: ["@arathunku"], 125 | licenses: ["MIT"], 126 | links: %{ 127 | Changelog: "https://hexdocs.pm/nimrag/changelog.html", 128 | GitHub: "https://github.com/arathunku/nimrag" 129 | }, 130 | files: ~w(lib CHANGELOG.md LICENSE.md mix.exs README.md .formatter.exs) 131 | ] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/nimrag/api/user_summary_daily.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.UserSummaryDaily do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{ 5 | total_steps: integer() 6 | } 7 | 8 | defstruct ~w(total_steps)a 9 | 10 | def schematic() do 11 | schema(__MODULE__, %{ 12 | field(:total_steps) => int() 13 | }) 14 | end 15 | end 16 | 17 | # %{ 18 | # "uncategorizedStressDuration" => 120, 19 | # "measurableAwakeDuration" => 600, 20 | # "averageMonitoringEnvironmentAltitude" => 159.0, 21 | # "stressDuration" => 4200, 22 | # "bodyBatteryDuringSleep" => 46, 23 | # "activityStressPercentage" => 1.48, 24 | # "wellnessDistanceMeters" => 161, 25 | # "bodyBatteryVersion" => 3.0, 26 | # "floorsDescended" => 0.0, 27 | # "latestSpo2" => nil, 28 | # "wellnessStartTimeGmt" => "2024-04-05T22:00:00.0", 29 | # "wellnessEndTimeGmt" => "2024-04-06T04:51:00.0", 30 | # "wellnessEndTimeLocal" => "2024-04-06T06:51:00.0", 31 | # "measurableAsleepDuration" => 23580, 32 | # "wellnessStartTimeLocal" => "2024-04-06T00:00:00.0", 33 | # "minAvgHeartRate" => 53, 34 | # "wellnessActiveKilocalories" => 4.0, 35 | # "activityStressDuration" => 360, 36 | # "userDailySummaryId" => 3532005, 37 | # "totalSteps" => 203, 38 | # "averageStressLevel" => 22, 39 | # "floorsAscendedInMeters" => 0.0, 40 | # "userFloorsAscendedGoal" => 0, 41 | # "netRemainingKilocalories" => 1804.0, 42 | # "sedentarySeconds" => 616, 43 | # "includesActivityData" => false, 44 | # "netCalorieGoal" => 1800, 45 | # "includesWellnessData" => true, 46 | # "averageSpo2" => nil, 47 | # "bmrKilocalories" => 594.0, 48 | # "consumedKilocalories" => nil, 49 | # "lowestRespirationValue" => 13.0, 50 | # "lastSyncTimestampGMT" => "2024-04-06T04:51:55.607", 51 | # "vigorousIntensityMinutes" => 0, 52 | # "source" => "GARMIN", 53 | # "uncategorizedStressPercentage" => 0.49, 54 | # "bodyBatteryMostRecentValue" => 69, 55 | # "highStressDuration" => nil, 56 | # "rule" => %{"typeId" => 3, "typeKey" => "subscribers"}, 57 | # "userProfileId" => 3532005, 58 | # "totalDistanceMeters" => 161, 59 | # "wellnessDescription" => nil, 60 | # "bodyBatteryDynamicFeedbackEvent" => %{ 61 | # "bodyBatteryLevel" => "MODERATE", 62 | # "eventTimestampGmt" => "2024-04-06T02:32:33", 63 | # "feedbackLongType" => "EARLY_MORNING_NO_DATA", 64 | # "feedbackShortType" => nil 65 | # }, 66 | # "avgWakingRespirationValue" => 14.0, 67 | # "maxStressLevel" => 62, 68 | # "stressQualifier" => "UNKNOWN", 69 | # "highestRespirationValue" => 19.0, 70 | # "latestRespirationTimeGMT" => "2024-04-06T04:51:00.0", 71 | # "lowStressPercentage" => 17.04, 72 | # "wellnessKilocalories" => 598.0, 73 | # "lastSevenDaysAvgRestingHeartRate" => 52, 74 | # "uuid" => "d4b3a74c4ab94ebba80c556a9a5eff21", 75 | # "intensityMinutesGoal" => 180, 76 | # "bodyBatteryHighestValue" => 69, 77 | # "restingCaloriesFromActivity" => nil, 78 | # "dailyStepGoal" => 7777, 79 | # "remainingKilocalories" => 598.0, 80 | # "bodyBatteryChargedValue" => 40, 81 | # "includesCalorieConsumedData" => false, 82 | # "lowestSpo2" => nil, 83 | # "lowStressDuration" => 4140, 84 | # "activeSeconds" => 37, 85 | # "activeKilocalories" => 4.0, 86 | # "latestSpo2ReadingTimeLocal" => nil, 87 | # "minHeartRate" => 52, 88 | # "restingHeartRate" => 55, 89 | # "abnormalHeartRateAlertsCount" => nil, 90 | # "mediumStressDuration" => 60, 91 | # "bodyBatteryDrainedValue" => 0, 92 | # "durationInMilliseconds" => 24660000, 93 | # "latestRespirationValue" => 14.0, 94 | # "maxAvgHeartRate" => 88, 95 | # "calendarDate" => "2024-04-06", 96 | # "highlyActiveSeconds" => 44, 97 | # "privacyProtected" => false, 98 | # "mediumStressPercentage" => 0.25, 99 | # "totalStressDuration" => 24300, 100 | # "floorsDescendedInMeters" => 0.0, 101 | # "highStressPercentage" => 0.0, 102 | # "moderateIntensityMinutes" => 0, 103 | # "maxHeartRate" => 88, 104 | # "sleepingSeconds" => 23963, 105 | # "latestSpo2ReadingTimeGmt" => nil, 106 | # "burnedKilocalories" => nil, 107 | # "floorsAscended" => 0.0, 108 | # "stressPercentage" => 17.28, 109 | # "restStressPercentage" => 80.74, 110 | # "totalKilocalories" => 598.0, 111 | # "bodyBatteryLowestValue" => 29, 112 | # "restStressDuration" => 19620 113 | # } 114 | -------------------------------------------------------------------------------- /test/fixtures/api/__userprofile-service__userprofile__user-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "connectDate": null, 3 | "id": 3532005, 4 | "sourceType": null, 5 | "userData": { 6 | "golfElevationUnit": null, 7 | "gender": "MALE", 8 | "hydrationContainers": [ 9 | { 10 | "name": "Container 1", 11 | "unit": "milliliter", 12 | "volume": 1000 13 | }, 14 | { 15 | "name": "Container 2", 16 | "unit": "milliliter", 17 | "volume": 750 18 | }, 19 | { 20 | "name": "Container 3", 21 | "unit": "milliliter", 22 | "volume": 500 23 | } 24 | ], 25 | "vo2MaxCycling": 1.0, 26 | "firstbeatCyclingLtTimestamp": null, 27 | "diveNumber": null, 28 | "lactateThresholdSpeed": 0.35833233000000003, 29 | "birthDate": "1992-01-01", 30 | "firstbeatRunningLtTimestamp": 1082902691, 31 | "weight": null, 32 | "hydrationAutoGoalEnabled": true, 33 | "hydrationMeasurementUnit": "milliliter", 34 | "firstbeatMaxStressScore": null, 35 | "vigorousIntensityMinutesHrZone": 4, 36 | "availableTrainingDays": [ 37 | "WEDNESDAY", 38 | "MONDAY", 39 | "SUNDAY", 40 | "TUESDAY", 41 | "FRIDAY", 42 | "THURSDAY", 43 | "SATURDAY" 44 | ], 45 | "heartRateFormat": { 46 | "displayFormat": null, 47 | "formatId": 20, 48 | "formatKey": "zones", 49 | "groupingUsed": false, 50 | "maxFraction": 1, 51 | "minFraction": 1 52 | }, 53 | "activityLevel": 7, 54 | "thresholdHeartRateAutoDetected": true, 55 | "vo2MaxRunning": 54.0, 56 | "trainingStatusPausedDate": null, 57 | "ftpAutoDetected": true, 58 | "golfDistanceUnit": "metric", 59 | "powerFormat": { 60 | "displayFormat": null, 61 | "formatId": 30, 62 | "formatKey": "watt", 63 | "groupingUsed": true, 64 | "maxFraction": 0, 65 | "minFraction": 0 66 | }, 67 | "firstDayOfWeek": { 68 | "dayId": 3, 69 | "dayName": "monday", 70 | "isPossibleFirstDay": true, 71 | "sortOrder": 3 72 | }, 73 | "intensityMinutesCalcMethod": "AUTO", 74 | "golfSpeedUnit": null, 75 | "preferredLongTrainingDays": [ 76 | "SUNDAY", 77 | "SATURDAY" 78 | ], 79 | "weatherLocation": { 80 | "isoCountryCode": null, 81 | "latitude": null, 82 | "locationName": null, 83 | "longitude": null, 84 | "postalCode": null, 85 | "useFixedLocation": null 86 | }, 87 | "timeFormat": "time_twenty_four_hr", 88 | "measurementSystem": "metric", 89 | "height": 174.0, 90 | "handedness": "RIGHT", 91 | "moderateIntensityMinutesHrZone": 3, 92 | "externalBottomTime": null, 93 | "lactateThresholdHeartRate": null 94 | }, 95 | "userSleep": { 96 | "defaultSleepTime": false, 97 | "defaultWakeTime": false, 98 | "sleepTime": 79200, 99 | "wakeTime": 20400 100 | }, 101 | "userSleepWindows": [ 102 | { 103 | "endSleepTimeSecondsFromMidnight": 24000, 104 | "sleepWindowFrequency": "SUNDAY", 105 | "startSleepTimeSecondsFromMidnight": 77400 106 | }, 107 | { 108 | "endSleepTimeSecondsFromMidnight": 25200, 109 | "sleepWindowFrequency": "MONDAY", 110 | "startSleepTimeSecondsFromMidnight": 75600 111 | }, 112 | { 113 | "endSleepTimeSecondsFromMidnight": 25200, 114 | "sleepWindowFrequency": "TUESDAY", 115 | "startSleepTimeSecondsFromMidnight": 75600 116 | }, 117 | { 118 | "endSleepTimeSecondsFromMidnight": 25200, 119 | "sleepWindowFrequency": "WEDNESDAY", 120 | "startSleepTimeSecondsFromMidnight": 75600 121 | }, 122 | { 123 | "endSleepTimeSecondsFromMidnight": 25200, 124 | "sleepWindowFrequency": "THURSDAY", 125 | "startSleepTimeSecondsFromMidnight": 75600 126 | }, 127 | { 128 | "endSleepTimeSecondsFromMidnight": 25200, 129 | "sleepWindowFrequency": "FRIDAY", 130 | "startSleepTimeSecondsFromMidnight": 75600 131 | }, 132 | { 133 | "endSleepTimeSecondsFromMidnight": 25200, 134 | "sleepWindowFrequency": "SATURDAY", 135 | "startSleepTimeSecondsFromMidnight": 75600 136 | }, 137 | { 138 | "endSleepTimeSecondsFromMidnight": 20400, 139 | "sleepWindowFrequency": "DAILY", 140 | "startSleepTimeSecondsFromMidnight": 79200 141 | } 142 | ] 143 | } -------------------------------------------------------------------------------- /test/fixtures/api/__activity-service__activity__15205844761.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessControlRuleDTO": { 3 | "typeId": 2, 4 | "typeKey": "private" 5 | }, 6 | "activityId": 15205844761, 7 | "activityName": "Wächtersbach Running", 8 | "activityTypeDTO": { 9 | "isHidden": false, 10 | "parentTypeId": 17, 11 | "restricted": false, 12 | "trimmable": true, 13 | "typeId": 1, 14 | "typeKey": "running" 15 | }, 16 | "activityUUID": { 17 | "uuid": "446dc3de-f570-4077-8552-061a1fcf7e97" 18 | }, 19 | "eventTypeDTO": { 20 | "sortOrder": 10, 21 | "typeId": 9, 22 | "typeKey": "uncategorized" 23 | }, 24 | "isMultiSportParent": false, 25 | "locationName": "Wächtersbach", 26 | "metadataDTO": { 27 | "hasIntensityIntervals": false, 28 | "curatedCourseId": null, 29 | "diveNumber": null, 30 | "eBikeBatteryRemaining": null, 31 | "autoCalcCalories": false, 32 | "hasSplits": false, 33 | "hasPowerTimeInZones": false, 34 | "calendarEventInfo": null, 35 | "agentApplicationInstallationId": null, 36 | "associatedWorkoutId": null, 37 | "eBikeBatteryUsage": null, 38 | "personalRecord": false, 39 | "liveTrackPlusSessionUUIDs": null, 40 | "isOriginal": true, 41 | "deviceMetaDataDTO": { 42 | "deviceId": "3976108684", 43 | "deviceTypePk": 34976, 44 | "deviceVersionPk": 915743 45 | }, 46 | "fileFormat": { 47 | "formatId": 7, 48 | "formatKey": "fit" 49 | }, 50 | "manualActivity": false, 51 | "matchedCuratedCourseId": null, 52 | "trimmed": false, 53 | "userInfoDto": { 54 | "displayname": "88443055-174d-4a10-8698-400aa5f16a12", 55 | "fullname": "michal", 56 | "profileImageUrlLarge": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/87f725ab-dbd4-447f-b622-20b3aac3f996-prof.png", 57 | "profileImageUrlMedium": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/87f725ab-dbd4-447f-b622-20b3aac3f996-prfr.png", 58 | "profileImageUrlSmall": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/87f725ab-dbd4-447f-b622-20b3aac3f996-prth.png", 59 | "userPro": false, 60 | "userProfilePk": 121293937 61 | }, 62 | "deviceApplicationInstallationId": 915743, 63 | "agentString": null, 64 | "groupRideUUID": null, 65 | "hasPolyline": true, 66 | "associatedCourseId": null, 67 | "eBikeAssistModeInfoDTOList": null, 68 | "hasHrTimeInZones": true, 69 | "lastUpdateDate": "2024-05-03T16:00:56.0", 70 | "videoUrl": null, 71 | "runPowerWindDataEnabled": null, 72 | "favorite": false, 73 | "activityImages": [], 74 | "isAtpActivity": null, 75 | "manufacturer": "GARMIN", 76 | "hasChartData": true, 77 | "eBikeMaxAssistModes": null, 78 | "sensors": null, 79 | "elevationCorrected": false, 80 | "hasRunPowerWindData": null, 81 | "childIds": [], 82 | "uploadedDate": "2024-05-03T16:00:56.0", 83 | "childActivityTypes": [], 84 | "gcj02": false, 85 | "lapCount": 5 86 | }, 87 | "summaryDTO": { 88 | "aerobicTrainingEffectMessage": "IMPROVING_AEROBIC_FITNESS_2", 89 | "trainingEffect": 3.4000000953674316, 90 | "averageHR": 154.0, 91 | "maxVerticalSpeed": 0.600006103515625, 92 | "startLongitude": 9.307029582560062, 93 | "averageTemperature": 23.560234743434705, 94 | "duration": 1539.585, 95 | "elevationLoss": 4.0, 96 | "anaerobicTrainingEffectMessage": "NO_ANAEROBIC_BENEFIT_0", 97 | "averageSpeed": 2.7279999256134033, 98 | "steps": 4310, 99 | "movingDuration": 1539.007, 100 | "averageMovingSpeed": 2.729006371620868, 101 | "anaerobicTrainingEffect": 0.0, 102 | "minActivityLapDuration": 62.094, 103 | "calories": 304.0, 104 | "minTemperature": 22.0, 105 | "startLatitude": 50.26398749090731, 106 | "startTimeLocal": "2024-05-03T17:12:23.0", 107 | "averageRunCadence": 168.609375, 108 | "maxHR": 173.0, 109 | "startTimeGMT": "2024-05-03T15:12:23.0", 110 | "distance": 4199.96, 111 | "endLatitude": 50.25737123563886, 112 | "maxSpeed": 3.4800000190734863, 113 | "elevationGain": 0.0, 114 | "endLongitude": 9.301423011347651, 115 | "maxElevation": 151.2, 116 | "maxRunCadence": 173.0, 117 | "elapsedDuration": 1539.585, 118 | "minElevation": 143.2, 119 | "maxTemperature": 25.0, 120 | "strideLength": 97.06835257654699 121 | }, 122 | "timeZoneUnitDTO": { 123 | "factor": 0.0, 124 | "timeZone": "Europe/Paris", 125 | "unitId": 124, 126 | "unitKey": "Europe/Paris" 127 | }, 128 | "userProfileId": 121293937 129 | } -------------------------------------------------------------------------------- /lib/nimrag/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Client do 2 | @type t() :: %__MODULE__{ 3 | connectapi: Req.Request.t(), 4 | domain: String.t(), 5 | req_options: Keyword.t(), 6 | oauth1_token: Nimrag.OAuth1Token.t() | nil, 7 | oauth2_token: Nimrag.OAuth2Token.t() | nil, 8 | rate_limit: [scale_ms: integer(), limit: integer()] 9 | } 10 | 11 | alias Nimrag.OAuth1Token 12 | alias Nimrag.OAuth2Token 13 | 14 | defstruct connectapi: nil, 15 | domain: "garmin.com", 16 | req_options: [], 17 | oauth1_token: nil, 18 | oauth2_token: nil, 19 | rate_limit: nil 20 | 21 | # Options passed to Hammer, there are no official API limits so let's be 22 | # good citizens! Page load on Garmin dashboard performs over 200 requests 23 | @default_rate_limit [scale_ms: 30_000, limit: 60] 24 | @connectapi_user_agent "Mozilla/5.0 (Android 14; Mobile; rv:125.0) Gecko/125.0 Firefox/125.0" 25 | 26 | @moduledoc """ 27 | Struct containing all the required data to interact with the library and to make 28 | requests to Garmin. 29 | 30 | See `Nimrag.Client.new/1` for more details about the configuration. 31 | """ 32 | 33 | @doc """ 34 | 35 | Builds initial struct with the required configuration to interact with Garmin's API. 36 | 37 | Supported options: 38 | 39 | * `:domain` - Garmin's domain, by default it's "garmin.com". 40 | * `:req_options` - Custom Req options to be passed to all requests. 41 | 42 | You can capture and proxy all requests with [mitmmproxy](https://mitmproxy.org/), 43 | 44 | ```elixir 45 | req_options: [ 46 | connect_options: [ 47 | protocols: [:http2], 48 | transport_opts: [cacertfile: Path.expand("~/.mitmproxy/mitmproxy-ca-cert.pem")], 49 | proxy: {:http, "localhost", 8080, []} 50 | ] 51 | ] 52 | ``` 53 | 54 | 55 | * `:rate_limit` - Rate limit for all requests, see "Rate limit" in the `Nimrag` module, 56 | by default it's set to 60 requests every 30 seconds. 57 | 58 | ```elixir 59 | rate_limit: [scale_ms: 30_000, limit: 10] 60 | ``` 61 | 62 | """ 63 | 64 | @spec new() :: t() 65 | @spec new(Keyword.t()) :: t() | no_return 66 | def new(config \\ []) when is_list(config) do 67 | {domain, config} = Keyword.pop(config, :domain, "garmin.com") 68 | {custom_req_options, config} = Keyword.pop(config, :req_options, []) 69 | {rate_limit, config} = Keyword.pop(config, :rate_limit, @default_rate_limit) 70 | 71 | if config != [] do 72 | raise "Unknown config key(s): #{inspect(config)}" 73 | end 74 | 75 | req_opts = [user_agent: @connectapi_user_agent] |> Keyword.merge(custom_req_options) 76 | 77 | # use: Req.merge 78 | %__MODULE__{ 79 | req_options: req_opts, 80 | connectapi: 81 | [base_url: "https://connectapi.#{domain}"] |> Keyword.merge(req_opts) |> Req.new(), 82 | domain: domain, 83 | oauth1_token: nil, 84 | oauth2_token: nil, 85 | rate_limit: rate_limit 86 | } 87 | end 88 | 89 | @doc """ 90 | Used to attach OAuth tokens to the client 91 | 92 | ## Example 93 | 94 | ```elixir 95 | Nimrag.Client.new() |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) 96 | ``` 97 | 98 | """ 99 | 100 | @spec with_auth(t(), {OAuth1Token.t(), OAuth2Token.t()}) :: t() 101 | def with_auth(%__MODULE__{} = client, {%OAuth1Token{} = oauth1, %OAuth2Token{} = oauth2}) do 102 | client 103 | |> put_oauth_token(oauth1) 104 | |> put_oauth_token(oauth2) 105 | end 106 | 107 | @doc """ 108 | Adds OAuth1 or OAuth2 token to the client 109 | """ 110 | @spec put_oauth_token(t(), OAuth1Token.t()) :: t() 111 | @spec put_oauth_token(t(), OAuth2Token.t()) :: t() 112 | def put_oauth_token(%__MODULE__{} = client, %OAuth1Token{} = token) do 113 | client 114 | |> Map.put(:oauth1_token, token) 115 | end 116 | 117 | def put_oauth_token(%__MODULE__{} = client, %OAuth2Token{} = token) do 118 | client 119 | |> Map.put(:oauth2_token, token) 120 | end 121 | end 122 | 123 | defimpl Inspect, for: Nimrag.Client do 124 | alias Nimrag.Client 125 | import Inspect.Algebra 126 | 127 | def inspect( 128 | %Client{} = client, 129 | opts 130 | ) do 131 | details = 132 | Inspect.List.inspect( 133 | [ 134 | domain: client.domain, 135 | oauth1_token: client.oauth1_token && "#Nimrag.OAuth1Token<...>", 136 | oauth2_token: client.oauth2_token && "#Nimrag.OAuth2Token<...>" 137 | ], 138 | opts 139 | ) 140 | 141 | concat(["#Nimrag.Client<", details, ">"]) 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/nimrag/api.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api do 2 | alias Nimrag.Client 3 | alias Nimrag.OAuth1Token 4 | alias Nimrag.Auth 5 | 6 | require Logger 7 | 8 | @moduledoc """ 9 | Module to interact with Garmin's API **after** authentication. 10 | 11 | It handles common patterns of making the requests, pagination, list, etc. 12 | 13 | By default first argument is always the client, second options to Req, and 14 | all requests are executed against "connectapi" subdomain unless specified otherwise. 15 | 16 | OAuth2 token may get refreshed automatically if expired. This is why all responses 17 | return `{:ok, %Req.Response{}, client}` or `{:error, %Req.Response{}}`. 18 | """ 19 | 20 | @spec get(Client.t(), Keyword.t()) :: 21 | {:ok, Req.Response.t(), Client.t()} | {:error, Req.Response.t()} 22 | def get(%Client{} = client, opts) do 23 | client 24 | |> req(opts) 25 | |> Req.get() 26 | |> case do 27 | {:ok, %{status: 200} = resp} -> {:ok, resp, Req.Response.get_private(resp, :client)} 28 | {:error, error} -> {:error, error} 29 | end 30 | end 31 | 32 | @spec response_as_data({:ok, Req.Response.t(), Client.t()}, data_module :: atom()) :: 33 | {:ok, any(), Client.t()} | {:error, Req.Response.t()} 34 | @spec response_as_data({:error, any()}, data_module :: atom()) :: {:error, any()} 35 | def response_as_data({:ok, %Req.Response{status: 200, body: body}, client}, data_module) do 36 | with {:ok, data} <- do_response_as_data(body, data_module) do 37 | {:ok, data, client} 38 | end 39 | end 40 | 41 | def response_as_data({:error, error}, _data_module), do: {:error, error} 42 | 43 | defp do_response_as_data(body, data_module) when is_map(body) do 44 | data_module.from_api_response(body) 45 | end 46 | 47 | defp do_response_as_data(body, data_module) when is_list(body) do 48 | data = 49 | Enum.map(body, fn element -> 50 | with {:ok, data} <- data_module.from_api_response(element) do 51 | data 52 | end 53 | end) 54 | 55 | first_error = 56 | Enum.find(data, fn 57 | {:error, _} -> true 58 | _ -> false 59 | end) 60 | 61 | first_error || {:ok, data} 62 | end 63 | 64 | defp req(%Client{} = client, opts) do 65 | if client.oauth2_token == nil do 66 | Logger.warning( 67 | "Setup OAuth2 Token first with Nimrag.Auth.login_sso/2 or NimRag.Client.attach_auth/2" 68 | ) 69 | end 70 | 71 | client.connectapi 72 | |> Req.merge(opts) 73 | |> Req.Request.put_private(:client, client) 74 | |> Req.Request.append_request_steps( 75 | req_nimrag_rate_limit: &rate_limit(&1), 76 | req_nimrag_oauth: &connectapi_auth("connectapi." <> client.domain, &1) 77 | ) 78 | end 79 | 80 | defp connectapi_auth(host, %{url: %URI{scheme: "https", host: host, port: 443}} = req) do 81 | client = Req.Request.get_private(req, :client) 82 | 83 | case Auth.maybe_refresh_oauth2_token(client) do 84 | {:ok, client} -> 85 | req 86 | |> Req.Request.put_header("Authorization", "Bearer #{client.oauth2_token.access_token}") 87 | |> Req.Request.append_response_steps( 88 | req_nimrag_attach_request_path: fn {req, resp} -> 89 | %{path: path} = URI.parse(req.url) 90 | {req, Req.Response.put_private(resp, :request_path, path)} 91 | end, 92 | req_nimrag_attach_client: fn {req, resp} -> 93 | {req, Req.Response.put_private(resp, :client, client)} 94 | end 95 | ) 96 | 97 | {:error, reason} -> 98 | {Req.Request.halt(req, RuntimeError.exception("oauth2 token refresh error")), 99 | {:oauth2_token_refresh_error, reason}} 100 | end 101 | end 102 | 103 | defp connectapi_auth(_, req) do 104 | {Req.Request.halt(req, RuntimeError.exception("invalid request host")), :invalid_request_host} 105 | end 106 | 107 | defp rate_limit(req) do 108 | %Client{ 109 | oauth1_token: %OAuth1Token{oauth_token: oauth_token}, 110 | rate_limit: rate_limit, 111 | domain: domain 112 | } = Req.Request.get_private(req, :client) 113 | 114 | case rate_limit do 115 | [scale_ms: scale_ms, limit: limit] -> 116 | case Hammer.check_rate(hammer_backend(), "#{domain}:#{oauth_token}", scale_ms, limit) do 117 | {:allow, _count} -> 118 | req 119 | 120 | {:deny, limit} -> 121 | {Req.Request.halt(req, RuntimeError.exception("rate limit")), {:rate_limit, limit}} 122 | end 123 | 124 | false -> 125 | req 126 | end 127 | end 128 | 129 | defp hammer_backend do 130 | # single is a default 131 | Application.get_env(:nimrag, :hammer, backend: :single)[:backend] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /.github/actions/elixir-setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Elixir Project 2 | description: Checks out the code, configures Elixir, fetches dependencies, and manages build caching. 3 | inputs: 4 | elixir-version: 5 | required: true 6 | type: string 7 | description: Elixir version to set up 8 | otp-version: 9 | required: true 10 | type: string 11 | description: OTP version to set up 12 | ################################################################# 13 | # Everything below this line is optional. 14 | # 15 | # It's designed to make compiling a reasonably standard Elixir 16 | # codebase "just work," though there may be speed gains to be had 17 | # by tweaking these flags. 18 | ################################################################# 19 | build-deps: 20 | required: false 21 | type: boolean 22 | default: true 23 | description: True if we should compile dependencies 24 | build-app: 25 | required: false 26 | type: boolean 27 | default: true 28 | description: True if we should compile the application itself 29 | build-flags: 30 | required: false 31 | type: string 32 | default: '--all-warnings' 33 | description: Flags to pass to mix compile 34 | install-rebar: 35 | required: false 36 | type: boolean 37 | default: true 38 | description: By default, we will install Rebar (mix local.rebar --force). 39 | install-hex: 40 | required: false 41 | type: boolean 42 | default: true 43 | description: By default, we will install Hex (mix local.hex --force). 44 | cache-key: 45 | required: false 46 | type: string 47 | default: 'v1' 48 | description: If you need to reset the cache for some reason, you can change this key. 49 | outputs: 50 | otp-version: 51 | description: "Exact OTP version selected by the BEAM setup step" 52 | value: ${{ steps.beam.outputs.otp-version }} 53 | elixir-version: 54 | description: "Exact Elixir version selected by the BEAM setup step" 55 | value: ${{ steps.beam.outputs.elixir-version }} 56 | runs: 57 | using: "composite" 58 | steps: 59 | - name: Setup elixir 60 | uses: erlef/setup-beam@v1 61 | id: beam 62 | with: 63 | elixir-version: ${{ inputs.elixir-version }} 64 | otp-version: ${{ inputs.otp-version }} 65 | 66 | - name: Get deps cache 67 | uses: actions/cache@v2 68 | with: 69 | path: deps/ 70 | key: deps-${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('**/mix.lock') }} 71 | restore-keys: | 72 | deps-${{ inputs.cache-key }}-${{ runner.os }}- 73 | 74 | - name: Get build cache 75 | uses: actions/cache@v2 76 | id: build-cache 77 | with: 78 | path: _build/${{env.MIX_ENV}}/ 79 | key: build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}-${{ hashFiles('**/mix.lock') }} 80 | restore-keys: | 81 | build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}- 82 | 83 | - name: Get Hex cache 84 | uses: actions/cache@v2 85 | id: hex-cache 86 | with: 87 | path: ~/.hex 88 | key: build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 89 | restore-keys: | 90 | build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}- 91 | 92 | # In my experience, I have issues with incremental builds maybe 1 in 100 93 | # times that are fixed by doing a full recompile. 94 | # In order to not waste dev time on such trivial issues (while also reaping 95 | # the time savings of incremental builds for *most* day-to-day development), 96 | # I force a full recompile only on builds that we retry. 97 | - name: Clean to rule out incremental build as a source of flakiness 98 | if: github.run_attempt != '1' 99 | run: | 100 | mix deps.clean --all 101 | mix clean 102 | shell: sh 103 | 104 | - name: Install Rebar 105 | run: mix local.rebar --force 106 | shell: sh 107 | if: inputs.install-rebar == 'true' 108 | 109 | - name: Install Hex 110 | run: mix local.hex --force 111 | shell: sh 112 | if: inputs.install-hex == 'true' 113 | 114 | - name: Install Dependencies 115 | run: mix deps.get 116 | shell: sh 117 | 118 | # Normally we'd use `mix deps.compile` here, however that incurs a large 119 | # performance penalty when the dependencies are already fully compiled: 120 | # https://elixirforum.com/t/github-action-cache-elixir-always-recompiles-dependencies-elixir-1-13-3/45994/12 121 | # 122 | # Accoring to Jose Valim at the above link `mix loadpaths` will check and 123 | # compile missing dependencies 124 | - name: Compile Dependencies 125 | run: mix loadpaths 126 | shell: sh 127 | if: inputs.build-deps == 'true' 128 | 129 | - name: Compile Application 130 | run: mix compile ${{ inputs.build-flags }} 131 | shell: sh 132 | if: inputs.build-app == 'true' 133 | -------------------------------------------------------------------------------- /lib/nimrag/api/user_data.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.UserData do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{ 5 | activity_level: nil | integer(), 6 | birth_date: Date.t(), 7 | dive_number: nil | integer(), 8 | external_bottom_time: nil | float(), 9 | first_day_of_week: any(), 10 | firstbeat_cycling_lt_timestamp: nil | integer(), 11 | firstbeat_max_stress_score: nil | float(), 12 | firstbeat_running_lt_timestamp: nil | integer(), 13 | ftp_auto_detected: nil | bool(), 14 | gender: String.t(), 15 | available_training_days: [String.t()], 16 | preferred_long_training_days: [String.t()], 17 | golf_distance_unit: String.t(), 18 | golf_elevation_unit: nil | String.t(), 19 | golf_speed_unit: nil | String.t(), 20 | handedness: String.t(), 21 | # PowerFormat 22 | heart_rate_format: any(), 23 | height: float(), 24 | hydration_auto_goal_enabled: bool(), 25 | hydration_containers: [Api.HydrationContainer.t()], 26 | hydration_measurement_unit: String.t(), 27 | intensity_minutes_calc_method: String.t(), 28 | lactate_threshold_heart_rate: nil | float(), 29 | lactate_threshold_speed: nil | float(), 30 | measurement_system: String.t(), 31 | moderate_intensity_minutes_hr_zone: integer(), 32 | # PowerFormat 33 | power_format: any(), 34 | threshold_heart_rate_auto_detected: bool(), 35 | time_format: String.t(), 36 | training_status_paused_date: nil | String.t(), 37 | vigorous_intensity_minutes_hr_zone: integer(), 38 | vo_2_max_cycling: nil | float(), 39 | vo_2_max_running: nil | float(), 40 | # | WeatherLocation.t() 41 | weather_location: any(), 42 | weight: nil | float() 43 | } 44 | 45 | defstruct ~w( 46 | activity_level 47 | available_training_days 48 | preferred_long_training_days 49 | birth_date 50 | dive_number 51 | external_bottom_time 52 | first_day_of_week 53 | firstbeat_cycling_lt_timestamp 54 | firstbeat_max_stress_score 55 | firstbeat_running_lt_timestamp 56 | ftp_auto_detected 57 | gender 58 | golf_distance_unit 59 | golf_elevation_unit 60 | golf_speed_unit 61 | handedness 62 | heart_rate_format 63 | height 64 | hydration_auto_goal_enabled 65 | hydration_containers 66 | hydration_measurement_unit 67 | intensity_minutes_calc_method 68 | lactate_threshold_heart_rate 69 | lactate_threshold_speed 70 | measurement_system 71 | moderate_intensity_minutes_hr_zone 72 | power_format # PowerFormat 73 | threshold_heart_rate_auto_detected 74 | time_format 75 | training_status_paused_date 76 | vigorous_intensity_minutes_hr_zone 77 | vo_2_max_cycling 78 | vo_2_max_running 79 | weather_location 80 | weight 81 | )a 82 | 83 | def schematic() do 84 | schema(__MODULE__, %{ 85 | field(:activity_level) => nullable(int()), 86 | field(:birth_date) => date(), 87 | field(:dive_number) => nullable(int()), 88 | field(:available_training_days) => 89 | list(oneof(["WEDNESDAY", "MONDAY", "SUNDAY", "TUESDAY", "FRIDAY", "THURSDAY", "SATURDAY"])), 90 | field(:preferred_long_training_days) => 91 | list(oneof(["WEDNESDAY", "MONDAY", "SUNDAY", "TUESDAY", "FRIDAY", "THURSDAY", "SATURDAY"])), 92 | field(:external_bottom_time) => nullable(float()), 93 | # first_day_of_week: any(), 94 | field(:firstbeat_cycling_lt_timestamp) => nullable(int()), 95 | field(:firstbeat_max_stress_score) => nullable(float()), 96 | field(:firstbeat_running_lt_timestamp) => nullable(int()), 97 | field(:ftp_auto_detected) => nullable(bool()), 98 | field(:gender) => str(), 99 | field(:golf_distance_unit) => nullable(str()), 100 | field(:golf_elevation_unit) => nullable(str()), 101 | field(:golf_speed_unit) => nullable(str()), 102 | field(:handedness) => str(), 103 | # heart_rate_format: any(), # PowerFormat 104 | field(:height) => float(), 105 | field(:hydration_auto_goal_enabled) => bool(), 106 | field(:hydration_containers) => list(Api.HydrationContainer.schematic()), 107 | field(:hydration_measurement_unit) => str(), 108 | field(:intensity_minutes_calc_method) => str(), 109 | field(:lactate_threshold_heart_rate) => nullable(float()), 110 | field(:lactate_threshold_speed) => nullable(float()), 111 | field(:measurement_system) => str(), 112 | field(:moderate_intensity_minutes_hr_zone) => int(), 113 | # power_format: any(), 114 | field(:threshold_heart_rate_auto_detected) => bool(), 115 | field(:time_format) => str(), 116 | field(:training_status_paused_date) => nullable(str()), 117 | field(:vigorous_intensity_minutes_hr_zone) => int(), 118 | # field(:vo_2_max_cycling) => nullable(float()), 119 | # field(:vo_2_max_running) => nullable(float()), 120 | # weather_location: any() 121 | field(:weight) => nullable(float()) 122 | }) 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nimrag 2 | 3 | [![Actions Status](https://github.com/arathunku/nimrag/actions/workflows/elixir-build-and-test.yml/badge.svg)](https://github.com/arathunku/nimrag/actions/workflows/elixir-build-and-test.yml) 4 | [![Hex.pm](https://img.shields.io/hexpm/v/nimrag.svg?style=flat)](https://hex.pm/packages/nimrag) 5 | [![Documentation](https://img.shields.io/badge/hex-docs-lightgreen.svg?style=flat)](https://hexdocs.pm/nimrag) 6 | [![License](https://img.shields.io/hexpm/l/nimrag.svg?style=flat)](https://github.com/arathunku/nimrag/blob/main/LICENSE.md) 7 | 8 | 9 | 10 | Use Garmin API from Elixir. Fetch activities, steps, and more! 11 | 12 | Nimrag is Garmin in reverse, because we have to reverse engineer the API and auth. ¯\_(ツ)_/¯ 13 | 14 | ## Installation 15 | 16 | The package can be installed by adding Nimrag to your list of dependencies in `mix.exs`: 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:nimrag, "~> 0.1.0"} 22 | ] 23 | end 24 | ``` 25 | 26 | If you'd like to use it from Livebook, take a look at [this example](./examples/basic.livemd) or watch demo: 27 | 28 | https://github.com/arathunku/nimrag/assets/749393/7246f688-4820-4276-96de-5d8ed7b2fd16 29 | 30 | ## Usage 31 | 32 | ### Initial auth 33 | 34 | Garmin doesn't have any official public API for individuals, only businesses. 35 | It means we're required to use username, password and (optionally) MFA code to obtain 36 | OAuth tokens. OAuth1 token is valid for up to a year and it's used to generate 37 | OAuth2 token that expires quickly, OAuth2 token is used for making the API calls. 38 | After OAuth1 token expires, we need to repeat the authentication process. 39 | 40 | Please see `Nimrag.Auth` docs for more details about authentication, 41 | and see `Nimrag.Credentials` on how to avoid providing plaintext credentials directly in code. 42 | 43 | ```elixir 44 | # If you're using it for the first time, we need to get OAuth Tokens first. 45 | credentials = Nimrag.Credentials.new("username", "password") 46 | # you may get prompted for MFA token on stdin 47 | {:ok, client} = Nimrag.Auth.login_sso() 48 | 49 | # OPTIONAL: If you'd like to store OAuth tokens in ~/.config/nimrag and not log in every time 50 | :ok = Nimrag.Credentials.write_fs_oauth1_token(client) 51 | :ok = Nimrag.Credentials.write_fs_oauth2_token(client) 52 | ``` 53 | 54 | ### General 55 | 56 | Use functions from `Nimrag` to fetch data from Garmin's API. 57 | 58 | ```elixir 59 | # Restore previously cached in ~/.nimrag OAuth tokens 60 | client = Nimrag.Client.new() |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) 61 | 62 | # Fetch your profile 63 | {:ok, %Nimrag.Api.Profile{} = profile, client} = Nimrag.profile(client) 64 | 65 | # Fetch your latest activity 66 | {:ok, %Nimrag.Api.Activity{} = activity, client} = Nimrag.last_activity(client) 67 | 68 | # Call at the end of the session to cache new OAuth2 token 69 | :ok = Nimrag.Credentials.write_fs_oauth2_token(client) 70 | ``` 71 | 72 | ### Fallback to raw responses 73 | 74 | `Nimrag` module has also functions with `_req` suffix. They return `{:ok, Req.Response{}, client}` and 75 | do not process nor validate returned body. Other functions may return, if applicable, 76 | structs with known fields. 77 | 78 | This is very important split between response and transformation. Garmin's API may change 79 | at any time but it should still be possible to fallback to raw response if needed, so that 80 | any user of the library didn't have to wait for a fix. 81 | 82 | API calls return `{:ok, data, client}` or `{:error, error}`. On success, client is there 83 | so that it could be chained with always up to date OAuth2 token that will get 84 | automatically updated when it's near expiration. 85 | 86 | There's at this moment no extensive coverage of API endpoints, feel free to submit 87 | PR with new structs and endpoints, see [Contributing](#contributing). 88 | 89 | ### Rate limit 90 | 91 | By default, Nimrag uses [Hammer](https://github.com/ExHammer/hammer) for rate limiting requests. 92 | If you are already using `Hammer`, you can configure backend key via config. 93 | 94 | ```elixir 95 | config :nimrag, hammer: [backend: :custom] 96 | ``` 97 | 98 | By default, Hammer's ETS based backend will be started. 99 | 100 | > #### API note {: .warning} 101 | > Nimrag is not using public Garmin's API so please be good citizens and don't hammer their servers. 102 | 103 | See `Nimrag.Client.new/1` for more details about changing the api limits. 104 | 105 | ## Contributing 106 | 107 | Please do! Garmin has a lot of endpoints, some are useful, some are less useful and 108 | responses contain a lot of fields! 109 | 110 | You can discover new endpoints by setting up [mitmproxy](https://mitmproxy.org/) and capturing 111 | traffic from mobile app or website. You can also take a look at 112 | [python-garminconnect](https://github.com/cyberjunky/python-garminconnect/blob/master/garminconnect/__init__.py). 113 | 114 | For local setup, the project has minimal dependencies and is easy to install 115 | 116 | ```sh 117 | # fork and clone the repo 118 | $ mix deps.get 119 | # ensure everything works! 120 | $ mix check 121 | # do your changes 122 | $ mix check 123 | # submit PR! 124 | # THANK YOU! 125 | ``` 126 | 127 | ### How to add new API endpoints, fields 128 | 129 | 1. Add new function in `Nimrag` module, one with `_req` suffix and one without. 130 | Functions with `_req` should returns direct `Nimrag.Api` result. 131 | 1. Call `_req` function in `test` env and save its response as fixture. 132 | 133 | Example for `Nimrag.profile/1`: 134 | 135 | ```elixir 136 | $ MIX_ENV=test iex -S mix 137 | client = Nimrag.Client.new() |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) 138 | client |> Nimrag.profile_req() |> Nimrag.ApiHelper.store_response_as_test_fixture() 139 | ``` 140 | 141 | 1. Add tests for new function in [`test/nimrag_test.exs`] 142 | 1. Define new [`Schematic`](https://github.com/mhanberg/schematic) schema in `Nimrag.Api`, 143 | and ensure all tests pass. 144 | 145 | ## License 146 | 147 | Copyright © 2024 Michal Forys 148 | 149 | This project is licensed under the MIT license. 150 | -------------------------------------------------------------------------------- /examples/basic.livemd: -------------------------------------------------------------------------------- 1 | # Basic 2 | 3 | ```elixir 4 | Mix.install([ 5 | # dev 6 | # {:nimrag, path: "/data"}, 7 | {:nimrag, "~> 0.1.0"}, 8 | {:kino, "~> 0.12"}, 9 | {:kino_vega_lite, "~> 0.1.10"}, 10 | {:explorer, "~> 0.8.0"}, 11 | # humanized format for durations from activities! 12 | {:timex, "~> 3.7.11"}, 13 | # parsing FIT files 14 | {:ext_fit, "~> 0.1"} 15 | ]) 16 | ``` 17 | 18 | ## Nimrag 19 | 20 | This notebook will show you: 21 | 22 | 1. How to do inital auth with Garmin's API, obtain OAuth keys 23 | 2. Fetch your profile information 24 | 3. Fetch latest activity and display some information about it 25 | 4. Graph steps from recent days/weeks 26 | 27 | ## Login 28 | 29 | Given that Garmin doesn't have official API for individuals, nor any public auth keys you can generate, Nimrag will use your username, password and may ask for MFA code. 30 | 31 | `login_sso` will do the Auth flow and may ask you for MFA code. 32 | 33 | 34 | 35 | ```elixir 36 | form = 37 | Kino.Control.form( 38 | [ 39 | username: Kino.Input.text("Garmin email"), 40 | password: Kino.Input.password("Garmin password") 41 | ], 42 | submit: "Log in", 43 | reset_on_submit: true 44 | ) 45 | 46 | mfa_code = Kino.Control.form([mfa: Kino.Input.text("MFA")], submit: "Submit") 47 | 48 | frame = Kino.Frame.new() 49 | Kino.render(frame) 50 | Kino.Frame.append(frame, form) 51 | 52 | Kino.listen(form, fn event -> 53 | Kino.Frame.append(frame, Kino.Markdown.new("Authenticating...")) 54 | 55 | credentials = 56 | Nimrag.Credentials.new( 57 | if(event.data.username != "", 58 | do: event.data.username, 59 | else: System.get_env("LB_NIMRAG_USERNAME") 60 | ), 61 | if(event.data.password != "", 62 | do: event.data.password, 63 | else: System.get_env("LB_NIMRAG_PASSWORD") 64 | ), 65 | fn -> 66 | Kino.Frame.append(frame, mfa_code) 67 | Kino.Control.subscribe(mfa_code, :mfa) 68 | 69 | receive do 70 | {:mfa, %{data: %{mfa: code}}} -> 71 | {:ok, String.trim(code)} 72 | after 73 | 30_000 -> 74 | IO.puts(:stderr, "No message in 30 seconds") 75 | {:error, :missing_mfa} 76 | end 77 | end 78 | ) 79 | 80 | {:ok, client} = Nimrag.Auth.login_sso(credentials) 81 | :ok = Nimrag.Credentials.write_fs_oauth1_token(client) 82 | :ok = Nimrag.Credentials.write_fs_oauth2_token(client) 83 | IO.puts("New OAuth tokens saved!") 84 | end) 85 | 86 | Kino.nothing() 87 | ``` 88 | 89 | ```elixir 90 | client = 91 | Nimrag.Client.new() 92 | |> Nimrag.Client.with_auth(Nimrag.Credentials.read_oauth_tokens!()) 93 | ``` 94 | 95 | ## Use API 96 | 97 | Fetch your profile 98 | 99 | ```elixir 100 | {:ok, %Nimrag.Api.Profile{} = profile, client} = Nimrag.profile(client) 101 | 102 | Kino.Markdown.new(""" 103 | ## Profile for: #{profile.display_name} 104 | 105 | ![profile pic](#{profile.profile_image_url_medium}) 106 | #{profile.bio} 107 | 108 | Favorite activity types: 109 | 110 | #{profile.favorite_activity_types |> Enum.map(&"- #{&1}") |> Enum.join("\n")} 111 | """) 112 | ``` 113 | 114 | Fetch latest activity 115 | 116 | ```elixir 117 | {:ok, %Nimrag.Api.Activity{} = activity, client} = Nimrag.last_activity(client) 118 | 119 | # IO.inspect(activity) 120 | 121 | duration_humanized = 122 | activity.duration 123 | |> trunc() 124 | |> Timex.Duration.from_seconds() 125 | |> Elixir.Timex.Format.Duration.Formatters.Humanized.format() 126 | 127 | Kino.Markdown.new(""" 128 | ## #{activity.activity_name} at #{activity.start_local_at} 129 | 130 | * Distance: #{Float.round(activity.distance / 1000, 2)} km 131 | * Duration: #{duration_humanized} 132 | * ID: #{activity.id} 133 | """) 134 | ``` 135 | 136 | Or even download and analyse raw FIT file 137 | 138 | ```elixir 139 | {:ok, zip, client} = Nimrag.download_activity(client, activity.id, :raw) 140 | {:ok, [file_path]} = :zip.unzip(zip, cwd: "/tmp") 141 | {:ok, records} = file_path |> File.read!() |> ExtFit.Decode.decode() 142 | 143 | hd(records) 144 | ``` 145 | 146 | Show a graph of steps from last week 147 | 148 | ```elixir 149 | today = Date.utc_today() 150 | 151 | read_date = fn input -> 152 | input 153 | |> Kino.render() 154 | |> Kino.Input.read() 155 | end 156 | 157 | Kino.Markdown.new("## Select date range") |> Kino.render() 158 | from_value = Kino.Input.date("From day", default: Date.add(today, -21)) |> read_date.() 159 | to_value = Kino.Input.date("To day", default: today) |> read_date.() 160 | 161 | if !from_value || !to_value do 162 | Kino.interrupt!(:error, "Input required") 163 | end 164 | 165 | {:ok, steps_daily, client} = Nimrag.steps_daily(client, from_value, to_value) 166 | 167 | steps = 168 | Explorer.DataFrame.new( 169 | date: Enum.map(steps_daily, & &1.calendar_date), 170 | steps: Enum.map(steps_daily, & &1.total_steps) 171 | ) 172 | 173 | Kino.nothing() 174 | ``` 175 | 176 | 177 | 178 | ```elixir 179 | VegaLite.new(width: 800, title: "Daily number of steps") 180 | |> VegaLite.data_from_values(steps, only: ["date", "steps"]) 181 | |> VegaLite.mark(:bar) 182 | |> VegaLite.encode_field(:x, "date", type: :temporal) 183 | |> VegaLite.encode_field(:y, "steps", type: :quantitative) 184 | ``` 185 | -------------------------------------------------------------------------------- /test/fixtures/api/__wellness-service__wellness__dailySleepData__arathunku.json: -------------------------------------------------------------------------------- 1 | { 2 | "avgOvernightHrv": 50.0, 3 | "bodyBatteryChange": 50, 4 | "dailySleepDTO": { 5 | "napTimeSeconds": 0, 6 | "id": 1714508465000, 7 | "autoSleepEndTimestampGMT": null, 8 | "sleepStartTimestampGMT": 1714508465000, 9 | "lightSleepSeconds": 14940, 10 | "sleepNeed": { 11 | "actual": 480, 12 | "baseline": 480, 13 | "calendarDate": "2024-05-01", 14 | "deviceId": 344, 15 | "displayedForTheDay": false, 16 | "feedback": "NO_CHANGE_NO_ADJUSTMENTS", 17 | "hrvAdjustment": "NO_CHANGE", 18 | "napAdjustment": "NO_CHANGE", 19 | "preferredActivityTracker": true, 20 | "sleepHistoryAdjustment": "NO_CHANGE", 21 | "timestampGmt": "2024-04-30T17:00:07", 22 | "trainingFeedback": "NO_CHANGE", 23 | "userProfilePk": 353 24 | }, 25 | "retro": false, 26 | "sleepStartTimestampLocal": 1714515665000, 27 | "sleepTimeSeconds": 24900, 28 | "sleepWindowConfirmationType": "enhanced_confirmed_final", 29 | "autoSleepStartTimestampGMT": null, 30 | "sleepScorePersonalizedInsight": "NOT_AVAILABLE", 31 | "lowestRespirationValue": 11.0, 32 | "sleepQualityTypePK": null, 33 | "remSleepSeconds": 3420, 34 | "awakeCount": 2, 35 | "sleepResultTypePK": null, 36 | "nextSleepNeed": { 37 | "actual": 510, 38 | "baseline": 480, 39 | "calendarDate": "2024-05-02", 40 | "deviceId": 344, 41 | "displayedForTheDay": true, 42 | "feedback": "INCREASED", 43 | "hrvAdjustment": "NO_CHANGE", 44 | "napAdjustment": "NO_CHANGE", 45 | "preferredActivityTracker": true, 46 | "sleepHistoryAdjustment": "NO_CHANGE", 47 | "timestampGmt": "2024-05-01T10:55:35", 48 | "trainingFeedback": "TODAYS_LOAD_AND_CHRONIC", 49 | "userProfilePk": 35 50 | }, 51 | "awakeSleepSeconds": 1320, 52 | "sleepScores": { 53 | "awakeCount": { 54 | "optimalEnd": 1.0, 55 | "optimalStart": 0.0, 56 | "qualifierKey": "FAIR" 57 | }, 58 | "deepPercentage": { 59 | "idealEndInSeconds": 8217.0, 60 | "idealStartInSeconds": 3984.0, 61 | "optimalEnd": 33.0, 62 | "optimalStart": 16.0, 63 | "qualifierKey": "EXCELLENT", 64 | "value": 26 65 | }, 66 | "lightPercentage": { 67 | "idealEndInSeconds": 15936.0, 68 | "idealStartInSeconds": 7470.0, 69 | "optimalEnd": 64.0, 70 | "optimalStart": 30.0, 71 | "qualifierKey": "GOOD", 72 | "value": 60 73 | }, 74 | "overall": { 75 | "qualifierKey": "FAIR", 76 | "value": 76 77 | }, 78 | "remPercentage": { 79 | "idealEndInSeconds": 7719.0, 80 | "idealStartInSeconds": 5229.0, 81 | "optimalEnd": 31.0, 82 | "optimalStart": 21.0, 83 | "qualifierKey": "FAIR", 84 | "value": 14 85 | }, 86 | "restlessness": { 87 | "optimalEnd": 5.0, 88 | "optimalStart": 0.0, 89 | "qualifierKey": "FAIR" 90 | }, 91 | "stress": { 92 | "optimalEnd": 15.0, 93 | "optimalStart": 0.0, 94 | "qualifierKey": "FAIR" 95 | }, 96 | "totalDuration": { 97 | "optimalEnd": 2.88e4, 98 | "optimalStart": 2.88e4, 99 | "qualifierKey": "FAIR" 100 | } 101 | }, 102 | "highestRespirationValue": 22.0, 103 | "sleepVersion": 2, 104 | "sleepEndTimestampGMT": 1714534685000, 105 | "deepSleepSeconds": 6540, 106 | "averageRespirationValue": 15.0, 107 | "userProfilePK": 353, 108 | "sleepEndTimestampLocal": 1714541885000, 109 | "avgSleepStress": 18.0, 110 | "sleepScoreFeedback": "POSITIVE_DEEP", 111 | "calendarDate": "2024-05-01", 112 | "unmeasurableSleepSeconds": 0, 113 | "deviceRemCapable": true, 114 | "sleepFromDevice": true, 115 | "sleepWindowConfirmed": true, 116 | "sleepScoreInsight": "NONE", 117 | "ageGroup": "ADULT" 118 | }, 119 | "hrvData": [ 120 | { 121 | "startGMT": 1714508471000, 122 | "value": 48.0 123 | }, 124 | { 125 | "startGMT": 1714508771000, 126 | "value": 48.0 127 | }, 128 | { 129 | "startGMT": 1714509071000, 130 | "value": 44.0 131 | } 132 | ], 133 | "hrvStatus": "BALANCED", 134 | "remSleepData": true, 135 | "restingHeartRate": 52, 136 | "restlessMomentsCount": 37, 137 | "skinTempDataExists": false, 138 | "sleepMovement": [ 139 | { 140 | "activityLevel": 7.401218898132148, 141 | "endGMT": "2024-04-30T19:22:00.0", 142 | "startGMT": "2024-04-30T19:21:00.0" 143 | }, 144 | { 145 | "activityLevel": 7.188998792486662, 146 | "endGMT": "2024-04-30T19:23:00.0", 147 | "startGMT": "2024-04-30T19:22:00.0" 148 | }, 149 | { 150 | "activityLevel": 6.949293553119624, 151 | "endGMT": "2024-04-30T19:24:00.0", 152 | "startGMT": "2024-04-30T19:23:00.0" 153 | } 154 | ], 155 | "sleepBodyBattery": [ 156 | { 157 | "startGMT": 1714508460000, 158 | "value": 30 159 | }, 160 | { 161 | "startGMT": 1714508640000, 162 | "value": 30 163 | }, 164 | { 165 | "startGMT": 1714508820000, 166 | "value": 30 167 | } 168 | ], 169 | "sleepLevels": [ 170 | { 171 | "activityLevel": 1.0, 172 | "endGMT": "2024-04-30T20:31:00.0", 173 | "startGMT": "2024-04-30T20:21:05.0" 174 | }, 175 | { 176 | "activityLevel": 0.0, 177 | "endGMT": "2024-04-30T21:17:00.0", 178 | "startGMT": "2024-04-30T20:31:05.0" 179 | }, 180 | { 181 | "activityLevel": 1.0, 182 | "endGMT": "2024-04-30T21:43:00.0", 183 | "startGMT": "2024-04-30T21:17:05.0" 184 | } 185 | ], 186 | "sleepRestlessMoments": [ 187 | { 188 | "startGMT": 1714511525000, 189 | "value": 1 190 | }, 191 | { 192 | "startGMT": 1714511705000, 193 | "value": 1 194 | }, 195 | { 196 | "startGMT": 1714512785000, 197 | "value": 1 198 | }, 199 | { 200 | "startGMT": 1714513505000, 201 | "value": 1 202 | } 203 | ], 204 | "wellnessEpochRespirationDataDTOList": [ 205 | { 206 | "respirationValue": 14.0, 207 | "startTimeGMT": 1714508465000 208 | }, 209 | { 210 | "respirationValue": 14.0, 211 | "startTimeGMT": 1714508520000 212 | }, 213 | { 214 | "respirationValue": 14.0, 215 | "startTimeGMT": 1714508640000 216 | } 217 | ] 218 | } 219 | -------------------------------------------------------------------------------- /lib/nimrag/api/profile.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Api.Profile do 2 | use Nimrag.Api.Data 3 | 4 | @type t() :: %__MODULE__{ 5 | id: integer(), 6 | profile_id: integer(), 7 | garmin_guid: String.t(), 8 | display_name: String.t(), 9 | full_name: String.t(), 10 | user_name: String.t(), 11 | profile_image_url_large: nil | String.t(), 12 | profile_image_url_medium: nil | String.t(), 13 | profile_image_url_small: nil | String.t(), 14 | location: nil | String.t(), 15 | facebook_url: nil | String.t(), 16 | twitter_url: nil | String.t(), 17 | personal_website: nil | String.t(), 18 | motivation: nil | integer(), 19 | bio: nil | String.t(), 20 | primary_activity: nil | String.t(), 21 | favorite_activity_types: list(String.t()), 22 | running_training_speed: float(), 23 | cycling_training_speed: float(), 24 | favorite_cycling_activity_types: list(String.t()), 25 | cycling_classification: String.t(), 26 | cycling_max_avg_power: float(), 27 | swimming_training_speed: float(), 28 | profile_visibility: String.t(), 29 | activity_start_visibility: String.t(), 30 | activity_map_visibility: String.t(), 31 | course_visibility: String.t(), 32 | activity_heart_rate_visibility: String.t(), 33 | activity_power_visibility: String.t(), 34 | badge_visibility: String.t(), 35 | show_age: boolean(), 36 | show_weight: boolean(), 37 | show_height: boolean(), 38 | show_weight_class: boolean(), 39 | show_age_range: boolean(), 40 | show_gender: boolean(), 41 | show_activity_class: boolean(), 42 | show_vo_2_max: boolean(), 43 | show_personal_records: boolean(), 44 | show_last_12_months: boolean(), 45 | show_lifetime_totals: boolean(), 46 | show_upcoming_events: boolean(), 47 | show_recent_favorites: boolean(), 48 | show_recent_device: boolean(), 49 | show_recent_gear: boolean(), 50 | show_badges: boolean(), 51 | other_activity: nil | String.t(), 52 | other_primary_activity: String.t(), 53 | other_motivation: String.t(), 54 | user_roles: list(String.t()), 55 | name_approved: boolean(), 56 | user_profile_full_name: String.t(), 57 | make_golf_scorecards_private: boolean(), 58 | allow_golf_live_scoring: boolean(), 59 | allow_golf_scoring_by_connections: boolean(), 60 | user_level: integer(), 61 | user_point: integer(), 62 | level_update_date: String.t(), 63 | level_is_viewed: boolean(), 64 | level_point_threshold: integer(), 65 | user_point_offset: integer(), 66 | user_pro: boolean() 67 | } 68 | 69 | @fields [ 70 | :activity_heart_rate_visibility, 71 | :activity_map_visibility, 72 | :activity_power_visibility, 73 | :activity_start_visibility, 74 | :allow_golf_live_scoring, 75 | :allow_golf_scoring_by_connections, 76 | :badge_visibility, 77 | :bio, 78 | :course_visibility, 79 | :cycling_classification, 80 | :cycling_max_avg_power, 81 | :cycling_training_speed, 82 | :display_name, 83 | :facebook_url, 84 | :favorite_activity_types, 85 | :favorite_cycling_activity_types, 86 | :full_name, 87 | :garmin_guid, 88 | :id, 89 | :level_is_viewed, 90 | :level_point_threshold, 91 | :level_update_date, 92 | :location, 93 | :make_golf_scorecards_private, 94 | :motivation, 95 | :name_approved, 96 | :other_activity, 97 | :other_motivation, 98 | :other_primary_activity, 99 | :personal_website, 100 | :primary_activity, 101 | :profile_id, 102 | :profile_image_url_large, 103 | :profile_image_url_medium, 104 | :profile_image_url_small, 105 | :profile_visibility, 106 | :running_training_speed, 107 | :show_activity_class, 108 | :show_age, 109 | :show_age_range, 110 | :show_badges, 111 | :show_gender, 112 | :show_height, 113 | :show_last_12_months, 114 | :show_lifetime_totals, 115 | :show_personal_records, 116 | :show_recent_device, 117 | :show_recent_favorites, 118 | :show_recent_gear, 119 | :show_upcoming_events, 120 | :show_vo_2_max, 121 | :show_weight, 122 | :show_weight_class, 123 | :swimming_training_speed, 124 | :twitter_url, 125 | :user_level, 126 | :user_name, 127 | :user_point, 128 | :user_point_offset, 129 | :user_pro, 130 | :user_profile_full_name, 131 | :user_roles 132 | ] 133 | 134 | defstruct @fields 135 | 136 | def schematic() do 137 | schema(__MODULE__, %{ 138 | field(:activity_heart_rate_visibility) => str(), 139 | field(:activity_map_visibility) => str(), 140 | field(:activity_power_visibility) => str(), 141 | field(:activity_start_visibility) => str(), 142 | field(:allow_golf_live_scoring) => bool(), 143 | field(:allow_golf_scoring_by_connections) => bool(), 144 | field(:badge_visibility) => str(), 145 | field(:bio) => nullable(str()), 146 | field(:course_visibility) => str(), 147 | field(:cycling_classification) => nullable(str()), 148 | field(:cycling_max_avg_power) => float(), 149 | field(:cycling_training_speed) => float(), 150 | field(:display_name) => str(), 151 | field(:facebook_url) => nullable(str()), 152 | field(:favorite_activity_types) => list(str()), 153 | field(:favorite_cycling_activity_types) => list(str()), 154 | field(:full_name) => str(), 155 | field(:garmin_guid) => nullable(str()), 156 | field(:id) => int(), 157 | field(:level_is_viewed) => bool(), 158 | field(:level_point_threshold) => int(), 159 | field(:level_update_date) => str(), 160 | field(:location) => nullable(str()), 161 | field(:make_golf_scorecards_private) => bool(), 162 | field(:motivation) => nullable(int()), 163 | field(:name_approved) => bool(), 164 | field(:other_activity) => nullable(str()), 165 | field(:other_motivation) => nullable(str()), 166 | field(:other_primary_activity) => nullable(str()), 167 | field(:personal_website) => nullable(str()), 168 | field(:primary_activity) => nullable(str()), 169 | field(:profile_id) => int(), 170 | field(:profile_image_url_large) => nullable(str()), 171 | field(:profile_image_url_medium) => nullable(str()), 172 | field(:profile_image_url_small) => nullable(str()), 173 | field(:profile_visibility) => str(), 174 | field(:running_training_speed) => float(), 175 | field(:show_activity_class) => bool(), 176 | field(:show_age) => bool(), 177 | field(:show_age_range) => bool(), 178 | field(:show_badges) => bool(), 179 | field(:show_gender) => bool(), 180 | field(:show_height) => bool(), 181 | field(:show_last_12_months) => bool(), 182 | field(:show_lifetime_totals) => bool(), 183 | field(:show_personal_records) => bool(), 184 | field(:show_recent_device) => bool(), 185 | field(:show_recent_favorites) => bool(), 186 | field(:show_recent_gear) => bool(), 187 | field(:show_upcoming_events) => bool(), 188 | {"showVO2Max", :show_vo_2_max} => bool(), 189 | field(:show_weight) => bool(), 190 | field(:show_weight_class) => bool(), 191 | field(:swimming_training_speed) => float(), 192 | field(:twitter_url) => nullable(str()), 193 | field(:user_level) => int(), 194 | field(:user_name) => str(), 195 | field(:user_point) => int(), 196 | field(:user_point_offset) => int(), 197 | field(:user_pro) => bool(), 198 | field(:user_profile_full_name) => str(), 199 | field(:user_roles) => list(str()) 200 | }) 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 4 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.33.0", "690562b153153c7e4d455dc21dab86e445f66ceba718defe64b0ef6f0bd83ba0", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "3f69adc28274cb51be37d09b03e4565232862a4b10288a3894587b0131412124"}, 9 | "excoveralls": {:hex, :excoveralls, "0.18.1", "a6f547570c6b24ec13f122a5634833a063aec49218f6fff27de9df693a15588c", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d65f79db146bb20399f23046015974de0079668b9abb2f5aac074d078da60b8d"}, 10 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 11 | "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, 12 | "hammer": {:hex, :hammer, "6.2.1", "5ae9c33e3dceaeb42de0db46bf505bd9c35f259c8defb03390cd7556fea67ee2", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b9476d0c13883d2dc0cc72e786bac6ac28911fba7cc2e04b70ce6a6d9c4b2bdc"}, 13 | "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, 14 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 18 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 19 | "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, 20 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 22 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 23 | "oauther": {:hex, :oauther, "1.3.0", "82b399607f0ca9d01c640438b34d74ebd9e4acd716508f868e864537ecdb1f76", [:mix], [], "hexpm", "78eb888ea875c72ca27b0864a6f550bc6ee84f2eeca37b093d3d833fbcaec04e"}, 24 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 25 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 26 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 27 | "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, 28 | "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, 29 | "schematic": {:hex, :schematic, "0.3.1", "be633c1472959dc0ace22dd0e1f1445b099991fec39f6d6e5273d35ebd217ac4", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "52c419b5c405286e2d0369b9ca472b00b850c59a8b0bdf0dd69172ad4418d5ea"}, 30 | "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, 31 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 32 | } 33 | -------------------------------------------------------------------------------- /lib/nimrag.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag do 2 | alias Nimrag.Api 3 | alias Nimrag.Client 4 | import Nimrag.Api, only: [get: 2, response_as_data: 2] 5 | 6 | @type error() :: {:error, any} 7 | 8 | @moduledoc "README.md" 9 | |> File.read!() 10 | |> String.split("") 11 | |> Enum.fetch!(1) 12 | @external_resource "README.md" 13 | 14 | @doc """ 15 | Gets full profile 16 | """ 17 | @spec profile(Client.t()) :: {:ok, Api.Profile.t(), Client.t()} | error() 18 | def profile(client), do: client |> profile_req() |> response_as_data(Api.Profile) 19 | def profile_req(client), do: get(client, url: "/userprofile-service/socialProfile") 20 | 21 | @doc """ 22 | Gets number of completed and goal steps for each day. 23 | 24 | Start date must be equal or before end date. 25 | 26 | Avoid requesting too big ranges as it may fail. 27 | """ 28 | @spec steps_daily(Client.t()) :: {:ok, list(Api.StepsDaily.t()), Client.t()} | error() 29 | @spec steps_daily(Client.t(), start_date :: Date.t()) :: 30 | {:ok, list(Api.StepsDaily.t()), Client.t()} | error() 31 | @spec steps_daily(Client.t(), start_date :: Date.t(), end_date :: Date.t()) :: 32 | {:ok, list(Api.StepsDaily.t()), Client.t()} | error() 33 | def steps_daily(client, start_date \\ Date.utc_today(), end_date \\ Date.utc_today()) do 34 | if Date.before?(end_date, start_date) do 35 | {:error, 36 | {:invalid_date_range, "Start date must be eq or earlier than end date.", start_date, 37 | end_date}} 38 | else 39 | client |> steps_daily_req(start_date, end_date) |> response_as_data(Api.StepsDaily) 40 | end 41 | end 42 | 43 | def steps_daily_req(client, start_date \\ Date.utc_today(), end_date \\ Date.utc_today()) do 44 | get(client, 45 | url: "/usersummary-service/stats/steps/daily/:start_date/:end_date", 46 | path_params: [start_date: Date.to_iso8601(start_date), end_date: Date.to_iso8601(end_date)] 47 | ) 48 | end 49 | 50 | @doc """ 51 | Gets number of completed and goal steps per week. 52 | """ 53 | @spec steps_weekly(Client.t()) :: {:ok, list(Api.StepsWeekly.t()), Client.t()} | error() 54 | @spec steps_weekly(Client.t(), end_date :: Date.t()) :: 55 | {:ok, list(Api.StepsWeekly.t()), Client.t()} | error() 56 | @spec steps_weekly(Client.t(), end_date :: Date.t(), weeks_count :: integer()) :: 57 | {:ok, list(Api.StepsWeekly.t()), Client.t()} | error() 58 | def steps_weekly(client, end_date \\ Date.utc_today(), weeks_count \\ 1) do 59 | client |> steps_weekly_req(end_date, weeks_count) |> response_as_data(Api.StepsWeekly) 60 | end 61 | 62 | def steps_weekly_req(client, end_date \\ Date.utc_today(), weeks_count \\ 1) do 63 | get(client, 64 | url: "/usersummary-service/stats/steps/weekly/:end_date/:weeks_count", 65 | path_params: [end_date: Date.to_iso8601(end_date), weeks_count: weeks_count] 66 | ) 67 | end 68 | 69 | @doc """ 70 | Gets a full summary of a given day. 71 | """ 72 | @spec user_summary(Client.t()) :: {:ok, list(Api.UserSummaryDaily.t()), Client.t()} | error() 73 | @spec user_summary(Client.t(), start_day :: Date.t()) :: 74 | {:ok, Api.UserSummaryDaily.t(), Client.t()} | error() 75 | def user_summary(client, date \\ Date.utc_today()), 76 | do: client |> user_summary_req(date) |> response_as_data(Api.UserSummaryDaily) 77 | 78 | def user_summary_req(client, date) do 79 | get(client, 80 | url: "/usersummary-service/usersummary/daily", 81 | params: [calendarDate: Date.to_iso8601(date)] 82 | ) 83 | end 84 | 85 | @doc """ 86 | Gets latest activity 87 | """ 88 | @spec last_activity(Client.t()) :: {:ok, Api.ActivityList.t(), Client.t()} | error() 89 | def last_activity(client) do 90 | case activities(client, 0, 1) do 91 | {:ok, [], _client} -> {:error, :not_found} 92 | {:ok, [activity | _], client} -> {:ok, activity, client} 93 | result -> result 94 | end 95 | end 96 | 97 | @doc """ 98 | Gets activity with given ID. 99 | 100 | Note: this doesn't return the same data structure as a list of activities! 101 | """ 102 | @spec activity(Client.t(), integer()) :: {:ok, Api.Activity.t(), Client.t()} | error() 103 | def activity(client, id), do: client |> activity_req(id) |> response_as_data(Api.Activity) 104 | 105 | def activity_req(client, id), 106 | do: get(client, url: "/activity-service/activity/:id", path_params: [id: id]) 107 | 108 | @doc """ 109 | Gets details for activitiy with given ID 110 | """ 111 | @spec activity_details(Client.t(), integer()) :: 112 | {:ok, Api.ActivityDetails.t(), Client.t()} | error() 113 | def activity_details(client, id), 114 | do: client |> activity_details_req(id) |> response_as_data(Api.ActivityDetails) 115 | 116 | def activity_details_req(client, id), 117 | do: get(client, url: "/activity-service/activity/:id/details", path_params: [id: id]) 118 | 119 | @doc """ 120 | Gets activities 121 | """ 122 | @spec activities(Client.t()) :: {:ok, list(Api.ActivityList.t()), Client.t()} | error() 123 | @spec activities(Client.t(), offset :: integer()) :: 124 | {:ok, list(Api.ActivityList.t()), Client.t()} | error() 125 | @spec activities(Client.t(), offset :: integer(), limit :: integer()) :: 126 | {:ok, list(Api.ActivityList.t()), Client.t()} | error() 127 | def activities(client, offset \\ 0, limit \\ 10) do 128 | client |> activities_req(offset, limit) |> response_as_data(Api.ActivityList) 129 | end 130 | 131 | def activities_req(client, offset, limit) do 132 | get(client, 133 | url: "/activitylist-service/activities/search/activities", 134 | params: [limit: limit, start: offset] 135 | ) 136 | end 137 | 138 | @doc """ 139 | Downloads activity. 140 | 141 | Activity download artifact - if original format is used, it's a zip and you 142 | still need to decode it. 143 | 144 | CSV download is contains a summary of splits. 145 | 146 | ## Working with original zip file 147 | 148 | ```elixir 149 | {:ok, zip, client} = Nimrag.download_activity(client, 123, :raw) 150 | {:ok, [{_filename, data}]} = :zip.extract(zip, [:memory]) 151 | # Use https://github.com/arathunku/ext_fit to decode FIT file 152 | {:ok, records} = data |> ExtFit.Decode.decode() 153 | ``` 154 | """ 155 | 156 | @spec download_activity(Client.t(), activity_id :: integer(), :raw) :: 157 | {:ok, binary(), Client.t()} | error() 158 | @spec download_activity(Client.t(), activity_id :: integer(), :tcx) :: 159 | {:ok, binary(), Client.t()} | error() 160 | @spec download_activity(Client.t(), activity_id :: integer(), :gpx) :: 161 | {:ok, binary(), Client.t()} | error() 162 | @spec download_activity(Client.t(), activity_id :: integer(), :kml) :: 163 | {:ok, binary(), Client.t()} | error() 164 | @spec download_activity(Client.t(), activity_id :: integer(), :csv) :: 165 | {:ok, binary(), Client.t()} | error() 166 | def download_activity(client, activity_id, :raw) do 167 | with {:ok, %{body: body, status: 200}, client} <- 168 | download_activity_req(client, 169 | prefix_url: "download-service/files/activity", 170 | activity_id: activity_id 171 | ) do 172 | {:ok, body, client} 173 | end 174 | end 175 | 176 | def download_activity(client, activity_id, format) when format in ~w(tcx gpx kml csv)a do 177 | with {:ok, %{body: body, status: 200}, client} <- 178 | download_activity_req(client, 179 | prefix_url: "download-service/export/#{format}/activity", 180 | activity_id: activity_id 181 | ) do 182 | {:ok, body, client} 183 | end 184 | end 185 | 186 | @doc false 187 | def download_activity_req(client, path_params) do 188 | get(client, 189 | url: ":prefix_url/:activity_id", 190 | path_params: path_params 191 | ) 192 | end 193 | 194 | @doc """ 195 | Returns user settings 196 | """ 197 | @spec user_settings(Client.t()) :: {:ok, Api.UserSettings.t(), Client.t()} | error() 198 | def user_settings(client), 199 | do: client |> user_settings_req() |> response_as_data(Api.UserSettings) 200 | 201 | def user_settings_req(client), 202 | do: get(client, url: "/userprofile-service/userprofile/user-settings") 203 | 204 | @doc """ 205 | Gets sleep data for a given day. 206 | """ 207 | @spec sleep_daily(Client.t(), username :: String.t()) :: 208 | {:ok, list(Api.SleepDaily.t()), Client.t()} | error() 209 | @spec sleep_daily(Client.t(), username :: String.t(), date :: Date.t()) :: 210 | {:ok, list(Api.SleepDaily.t()), Client.t()} | error() 211 | @spec sleep_daily(Client.t(), username :: String.t(), date :: Date.t(), integer()) :: 212 | {:ok, list(Api.SleepDaily.t()), Client.t()} | error() 213 | def sleep_daily(client, username, date \\ Date.utc_today(), buffer_minutes \\ 60) do 214 | client |> sleep_daily_req(username, date, buffer_minutes) |> response_as_data(Api.SleepDaily) 215 | end 216 | 217 | def sleep_daily_req(client, username, date \\ Date.utc_today(), buffer_minutes \\ 60) do 218 | get(client, 219 | url: "wellness-service/wellness/dailySleepData/:username", 220 | params: [nonSleepBufferMinutes: buffer_minutes, date: Date.to_iso8601(date)], 221 | path_params: [username: username] 222 | ) 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /test/fixtures/api/__activitylist-service__activities__search__activities.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "bmrCalories": 97.0, 4 | "strokes": null, 5 | "leftBalance": null, 6 | "curatedCourseId": null, 7 | "maxBottomTime": null, 8 | "sportTypeId": 1, 9 | "ownerProfileImageUrlMedium": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/cb9ea33662d3-prfr.png", 10 | "comments": null, 11 | "excludeFromPowerCurveReports": null, 12 | "diveNumber": null, 13 | "aerobicTrainingEffectMessage": "IMPROVING_AEROBIC_BASE_8", 14 | "hasVideo": false, 15 | "lactateThresholdSpeed": null, 16 | "maxAvgPower_600": null, 17 | "autoCalcCalories": false, 18 | "floorsDescended": null, 19 | "flow": null, 20 | "avgStrokes": null, 21 | "avgGroundContactBalance": null, 22 | "eventType": { 23 | "sortOrder": 10, 24 | "typeId": 9, 25 | "typeKey": "uncategorized" 26 | }, 27 | "unitOfPoolLength": null, 28 | "maxAvgPower_120": null, 29 | "averageHR": 135.0, 30 | "maxAvgPower_18000": null, 31 | "differenceBodyBattery": -13, 32 | "parent": false, 33 | "courseId": null, 34 | "avgGradeAdjustedSpeed": 3.0269999, 35 | "maxStrokeCadence": null, 36 | "trainingStressScore": null, 37 | "hasSplits": true, 38 | "maxAirSpeed": null, 39 | "maxVerticalSpeed": 1.0, 40 | "hasSeedFirstbeatProfile": null, 41 | "maxAvgPower_60": null, 42 | "ownerDisplayName": "nimrag", 43 | "startLongitude": 9.30, 44 | "averageBikingCadenceInRevPerMinute": null, 45 | "summarizedDiveInfo": { 46 | "current": null, 47 | "summarizedDiveGases": [], 48 | "surfaceCondition": null, 49 | "totalSurfaceTime": null, 50 | "visibility": null, 51 | "visibilityUnit": null, 52 | "waterDensity": null, 53 | "waterType": null, 54 | "weight": null, 55 | "weightUnit": null 56 | }, 57 | "activityLikeFullNames": null, 58 | "calendarEventUuid": null, 59 | "ownerProfileImageUrlSmall": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/a0923d-cb9ea33662d3-prth.png", 60 | "avgStress": null, 61 | "locationName": "Germany", 62 | "duration": 4006.337890625, 63 | "privacy": { 64 | "typeId": 3, 65 | "typeKey": "subscribers" 66 | }, 67 | "endCns": null, 68 | "maxLapAvgRunCadence": null, 69 | "maxBikingCadenceInRevPerMinute": null, 70 | "maxAvgPower_30": null, 71 | "avgStrokeCadence": null, 72 | "maxPower": 458.0, 73 | "maxAvgPower_3600": null, 74 | "endN2": null, 75 | "activityTrainingLoad": 94.26568603515625, 76 | "elevationLoss": 51.0, 77 | "avgWindYawAngle": null, 78 | "anaerobicTrainingEffectMessage": "NO_ANAEROBIC_BENEFIT_0", 79 | "activeSets": null, 80 | "averageSpeed": 3.0369999408721924, 81 | "maxDepth": null, 82 | "manualActivity": false, 83 | "numberOfActivityComments": null, 84 | "matchedCuratedCourseId": null, 85 | "vigorousIntensityMinutes": 63, 86 | "steps": 11472, 87 | "max20MinPower": null, 88 | "movingDuration": 4000.3209838867188, 89 | "avgRespirationRate": null, 90 | "avgVerticalOscillation": 8.759999847412109, 91 | "hasImages": false, 92 | "userPro": false, 93 | "maxAvgPower_10": null, 94 | "avgPower": 337.0, 95 | "minAirSpeed": null, 96 | "vO2MaxValue": 54.0, 97 | "jumpCount": null, 98 | "avgWheelchairCadence": null, 99 | "totalSets": null, 100 | "anaerobicTrainingEffect": 0.0, 101 | "maxAvgPower_1": null, 102 | "maxAvgPower_1800": null, 103 | "ownerProfileImageUrlLarge": "https://s3.amazonaws.com/garmin-connect-prod/profile_images/a00bba80-22f1-4342-923d-cb9ea33662d3-prof.png", 104 | "minActivityLapDuration": 52.87799835205078, 105 | "maxDoubleCadence": 212.0, 106 | "maxAvgPower_7200": null, 107 | "calories": 850.0, 108 | "minTemperature": 27.0, 109 | "maxFtp": null, 110 | "activityLikeAuthors": null, 111 | "startLatitude": 50.26, 112 | "groupRideUUID": null, 113 | "startTimeLocal": "2024-04-05 16:33:33", 114 | "hasPolyline": true, 115 | "averageSwimCadenceInStrokesPerMinute": null, 116 | "lactateThresholdBpm": null, 117 | "maxWheelchairCadence": null, 118 | "beginTimestamp": 1712327613000, 119 | "avgFractionalCadence": null, 120 | "avgGroundContactTime": 257.29998779296875, 121 | "maxAvgPower_1200": null, 122 | "startN2": null, 123 | "purposeful": false, 124 | "maxHR": 149.0, 125 | "maxFractionalCadence": null, 126 | "activityLikeProfileImageUrls": null, 127 | "surfaceInterval": null, 128 | "avgDepth": null, 129 | "maxRunningCadenceInStepsPerMinute": 212.0, 130 | "timeZoneId": 124, 131 | "startTimeGMT": "2024-04-05 14:33:33", 132 | "videoUrl": null, 133 | "conversationUuid": null, 134 | "avgVerticalSpeed": null, 135 | "avgStrokeDistance": null, 136 | "ownerFullName": "nimrag", 137 | "avgCda": null, 138 | "maxSwimCadenceInStrokesPerMinute": null, 139 | "gameName": null, 140 | "favorite": false, 141 | "maxCda": null, 142 | "maxJumpRopeCadence": null, 143 | "gameType": null, 144 | "averageSwolf": null, 145 | "deviceId": 3442024736, 146 | "avgLeftBalance": null, 147 | "userRoles": [ 148 | "SCOPE_GOLF_API_READ", 149 | "SCOPE_ATP_READ", 150 | "SCOPE_DIVE_API_WRITE", 151 | "SCOPE_DI_OAUTH_2_CLIENT_REVOCATION_ADMIN", 152 | "SCOPE_CONNECT_WEB_TEMPLATE_RENDER", 153 | "SCOPE_COMMUNITY_COURSE_ADMIN_READ", 154 | "SCOPE_DIVE_API_READ", 155 | "SCOPE_CONNECT_NON_SOCIAL_SHARED_READ", 156 | "SCOPE_DI_OAUTH_2_CLIENT_READ", 157 | "SCOPE_CONNECT_READ", 158 | "SCOPE_CONNECT_WRITE", 159 | "SCOPE_DI_OAUTH_2_TOKEN_ADMIN", 160 | "ROLE_CONNECTUSER", 161 | "ROLE_FITNESS_USER", 162 | "ROLE_WELLNESS_USER", 163 | "ROLE_OUTDOOR_USER", 164 | "ROLE_CONNECT_2_USER" 165 | ], 166 | "waterConsumed": null, 167 | "distance": 12167.2099609375, 168 | "activityName": "Running", 169 | "endLatitude": 50.264, 170 | "workoutId": null, 171 | "averageRunningCadenceInStepsPerMinute": 170.0, 172 | "maxSpeed": 3.4619998931884766, 173 | "splitSummaries": [ 174 | { 175 | "averageElevationGain": 43.0, 176 | "averageSpeed": 3.0369999408721924, 177 | "avgStress": null, 178 | "avgStressDraw": null, 179 | "avgStressLoss": null, 180 | "avgStressWin": null, 181 | "distance": 12167.1904296875, 182 | "duration": 4006.337890625, 183 | "elevationLoss": 51.0, 184 | "gamesAverageWon": null, 185 | "gamesNotWon": null, 186 | "gamesWon": null, 187 | "maxDistance": 12167, 188 | "maxElevationGain": 43.0, 189 | "maxGradeValue": null, 190 | "maxSpeed": 3.4619998931884766, 191 | "mode": null, 192 | "noOfSplits": 1, 193 | "numClimbSends": 0, 194 | "numFalls": 0, 195 | "splitType": "INTERVAL_ACTIVE", 196 | "totalAscent": 43.0 197 | }, 198 | { 199 | "averageElevationGain": 0.0, 200 | "averageSpeed": 3.1429998874664307, 201 | "avgStress": null, 202 | "avgStressDraw": null, 203 | "avgStressLoss": null, 204 | "avgStressWin": null, 205 | "distance": 12.569999694824219, 206 | "duration": 4.0, 207 | "elevationLoss": 0.0, 208 | "gamesAverageWon": null, 209 | "gamesNotWon": null, 210 | "gamesWon": null, 211 | "maxDistance": 12, 212 | "maxElevationGain": 0.0, 213 | "maxGradeValue": null, 214 | "maxSpeed": 1.156999945640564, 215 | "mode": null, 216 | "noOfSplits": 1, 217 | "numClimbSends": 0, 218 | "numFalls": 0, 219 | "splitType": "RWD_STAND", 220 | "totalAscent": 0.0 221 | }, 222 | { 223 | "averageElevationGain": 43.0, 224 | "averageSpeed": 3.0339999198913574, 225 | "avgStress": null, 226 | "avgStressDraw": null, 227 | "avgStressLoss": null, 228 | "avgStressWin": null, 229 | "distance": 12154.6201171875, 230 | "duration": 4006.318115234375, 231 | "elevationLoss": 51.0, 232 | "gamesAverageWon": null, 233 | "gamesNotWon": null, 234 | "gamesWon": null, 235 | "maxDistance": 12154, 236 | "maxElevationGain": 43.0, 237 | "maxGradeValue": null, 238 | "maxSpeed": 3.4619998931884766, 239 | "mode": null, 240 | "noOfSplits": 1, 241 | "numClimbSends": 0, 242 | "numFalls": 0, 243 | "splitType": "RWD_RUN", 244 | "totalAscent": 43.0 245 | } 246 | ], 247 | "maxRespirationRate": null, 248 | "avgGrit": null, 249 | "manufacturer": "GARMIN", 250 | "minCda": null, 251 | "normPower": 340.0, 252 | "avgVerticalRatio": 8.300000190734863, 253 | "elevationGain": 43.0, 254 | "caloriesConsumed": null, 255 | "avgFlow": null, 256 | "rightBalance": null, 257 | "decoDive": false, 258 | "avgStrideLength": 105.50999755859375, 259 | "elevationCorrected": false, 260 | "endStress": null, 261 | "endLongitude": 9.307776996865869, 262 | "maxElevation": 158.60000610351562, 263 | "minStrokes": null, 264 | "parentId": null, 265 | "description": null, 266 | "maxStress": null, 267 | "differenceStress": null, 268 | "activeLengths": null, 269 | "startCns": null, 270 | "activityType": { 271 | "isHidden": false, 272 | "parentTypeId": 17, 273 | "restricted": false, 274 | "trimmable": true, 275 | "typeId": 1, 276 | "typeKey": "running" 277 | }, 278 | "maxAvgPower_2": null, 279 | "numberOfActivityLikes": null, 280 | "intensityFactor": null, 281 | "requestorRelationship": null, 282 | "trainingEffectLabel": "AEROBIC_BASE", 283 | "moderateIntensityMinutes": 0, 284 | "commentedByUser": null, 285 | "likedByUser": null, 286 | "avgWattsPerCda": null, 287 | "elapsedDuration": 4010.8759765625, 288 | "maxAvgPower_300": null, 289 | "summarizedExerciseSets": null, 290 | "ownerId": 35, 291 | "maxAvgPower_20": null, 292 | "caloriesEstimated": null, 293 | "avgDoubleCadence": null, 294 | "pr": false, 295 | "minElevation": 137.60000610351562, 296 | "maxTemperature": 29.0, 297 | "calendarEventId": null, 298 | "avgAirSpeed": null, 299 | "waterEstimated": 1112.0, 300 | "poolLength": null, 301 | "minRespirationRate": null, 302 | "totalReps": null, 303 | "floorsClimbed": null, 304 | "bottomTime": null, 305 | "activityLikeDisplayNames": null, 306 | "maxAvgPower_5": null, 307 | "startStress": null, 308 | "grit": null, 309 | "activityId": 147, 310 | "lapCount": 13, 311 | "avgJumpRopeCadence": null, 312 | "conversationPk": null, 313 | "atpActivity": false, 314 | "aerobicTrainingEffect": 3.0999999046325684 315 | } 316 | ] 317 | -------------------------------------------------------------------------------- /lib/nimrag/credentials.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Credentials do 2 | require Logger 3 | 4 | alias Nimrag.Client 5 | alias Nimrag.OAuth1Token 6 | alias Nimrag.OAuth2Token 7 | 8 | @type get_mfa() :: nil | mfa() | (-> {:ok, String.t()} | {:error, atom()}) 9 | 10 | @type t() :: %__MODULE__{ 11 | username: nil | String.t(), 12 | password: nil | String.t(), 13 | get_mfa: get_mfa() 14 | } 15 | defstruct username: nil, password: nil, get_mfa: nil 16 | 17 | @moduledoc """ 18 | Holds credentials for authentication. Required only to setup initial OAuth tokens. 19 | 20 | Username and password are needed for `Nimrag.Auth.login_sso/2`. 21 | 22 | > ### Multi factor authentication (MFA) {: .warning} 23 | > Nimrag supports MFA flow by asking to input code when needed, 24 | > **it's highly recommended that you set up MFA on you Garmin account**. 25 | 26 | Nimrag tries to provied nice out of box defaults and credentials are obtained in a number of ways: 27 | 28 | * username 29 | 30 | 1. Passed as an argument to `new/2` 31 | 1. Environment variable `NIMRAG_USERNAME` 32 | 1. Read from file `{{config_path}}/nimrag/credentials.json` 33 | 34 | * password: 35 | 36 | 1. Passerd as an argument to `new/2` 37 | 1. Environment variable `NIMRAG_PASSWORD` 38 | 1. Environment variable `NIMRAG_PASSWORD_FILE` with a path to a file containing the password 39 | 1. Environment variable `NIMRAG_PASSWORD_COMMAND` with a command that will output the password 40 | 1. Read from file `{{config_path}}/credentials.json` (`XDG_CONFIG_HOME`) 41 | 42 | * MFA code - by default it's stdin, but you can provide your own function to read it 43 | 44 | You should use `{{config_path}}/credentials.json` as last resort and in case you do, 45 | ensure that the file has limited permissions(`600`), otherwise you'll get a warning. 46 | 47 | What's `{{config_path}}`? 48 | 49 | By default, it's going to be `~/.config/nimrag`. You can also supply custom 50 | value via `config :nimrag, config_fs_path: "/path/to/config"` or `NIMRAG_CONFIG_PATH`. 51 | This is the location for OAuth tokens, and optionally credentials. 52 | 53 | Created OAuth tokens are stored in `{{config_path}}/oauth1_token.json` and `{{config_path}}/oauth2_token.json`. 54 | OAuth2 token is valid for around an hour and is automatically refreshed when needed. 55 | OAuth1Token is valid for up to 1 year and when it expires, you'll need re-authenticate with 56 | username and password. 57 | """ 58 | 59 | @spec new() :: t() 60 | @spec new(username :: nil | String.t()) :: t() 61 | @spec new(username :: nil | String.t(), password :: nil | String.t()) :: t() 62 | @spec new(username :: nil | String.t(), password :: nil | String.t(), get_mfa :: get_mfa()) :: 63 | t() 64 | def new(username \\ nil, password \\ nil, get_mfa \\ nil) do 65 | %__MODULE__{ 66 | username: 67 | username || get_username() || read_fs_credentials().username || 68 | raise("Missing username for authentication"), 69 | password: 70 | password || get_password() || read_fs_credentials().password || 71 | raise("Missing password for authentication"), 72 | get_mfa: get_mfa || {__MODULE__, :read_user_input_mfa, []} 73 | } 74 | end 75 | 76 | @doc """ 77 | Reads previously stored OAuth tokens 78 | """ 79 | @spec read_oauth_tokens! :: {OAuth1Token.t(), OAuth2Token.t()} | no_return 80 | def read_oauth_tokens! do 81 | {read_oauth1_token!(), read_oauth2_token!()} 82 | end 83 | 84 | @doc """ 85 | See `read_oauth1_token/0` for details. 86 | """ 87 | @spec read_oauth1_token! :: OAuth1Token.t() | no_return 88 | def read_oauth1_token! do 89 | case read_oauth1_token() do 90 | {:ok, oauth1_token} -> oauth1_token 91 | {:error, error} -> raise error 92 | end 93 | end 94 | 95 | @spec read_oauth2_token! :: OAuth2Token.t() | no_return 96 | def read_oauth2_token! do 97 | case read_oauth2_token() do 98 | {:ok, oauth2_token} -> oauth2_token 99 | {:error, error} -> raise error 100 | end 101 | end 102 | 103 | @doc """ 104 | Reads OAuth1 token from `{{config_path}}/oauth1_token.json` 105 | 106 | See `Nimrag.Auth` for more details on how to obtain auth tokens. 107 | """ 108 | @spec read_oauth1_token :: {:ok, OAuth1Token.t()} | {:error, String.t()} 109 | def read_oauth1_token do 110 | read_oauth_token(:oauth1_token, fn data -> 111 | {:ok, expires_at, 0} = DateTime.from_iso8601(data["expires_at"]) 112 | 113 | %OAuth1Token{ 114 | domain: data["domain"], 115 | expires_at: expires_at, 116 | mfa_token: data["mfa_token"], 117 | oauth_token: data["oauth_token"], 118 | oauth_token_secret: data["oauth_token_secret"] 119 | } 120 | end) 121 | end 122 | 123 | @doc """ 124 | Reads OAuth2 token from `{{config_path}}/oauth2_token.json` 125 | 126 | See `Nimrag.Auth` for more details on how to obtain auth tokens. 127 | """ 128 | @spec read_oauth2_token :: {:ok, OAuth2Token.t()} | {:error, String.t()} 129 | def read_oauth2_token do 130 | read_oauth_token(:oauth2_token, fn data -> 131 | {:ok, expires_at, 0} = DateTime.from_iso8601(data["expires_at"]) 132 | {:ok, refresh_token_expires_at, 0} = DateTime.from_iso8601(data["refresh_token_expires_at"]) 133 | 134 | %OAuth2Token{ 135 | scope: data["scope"], 136 | jti: data["jit"], 137 | token_type: data["token_type"], 138 | access_token: data["access_token"], 139 | refresh_token: data["refresh_token"], 140 | expires_at: expires_at, 141 | refresh_token_expires_at: refresh_token_expires_at 142 | } 143 | end) 144 | end 145 | 146 | defp read_oauth_token(key, to_struct_mapper) do 147 | case read_fs_oauth_token(key, to_struct_mapper) do 148 | nil -> 149 | {:error, "No #{key}.json found."} 150 | 151 | oauth_token -> 152 | {:ok, oauth_token} 153 | end 154 | end 155 | 156 | defp read_fs_oauth_token(key, to_struct_mapper) do 157 | token_fs_path = Path.join(config_fs_path(), "#{key}.json") 158 | 159 | with {:ok, data} <- File.read(token_fs_path), 160 | {:ok, token} <- decode_json(data, "Invalid JSON in #{key}.json") do 161 | to_struct_mapper.(token) 162 | else 163 | _ -> 164 | nil 165 | end 166 | end 167 | 168 | @doc """ 169 | Writes currently used OAuth1 token to `{{config_path}}/oauth1_token.json` 170 | 171 | You only need to call this after initial login with `Nimrag.Auth`. 172 | """ 173 | def write_fs_oauth1_token(%Client{oauth1_token: token}), do: write_fs_oauth1_token(token) 174 | 175 | @doc false 176 | def write_fs_oauth1_token(%OAuth1Token{} = token), 177 | do: write_fs_oauth_token(:oauth1_token, token) 178 | 179 | @doc """ 180 | Writes currently used OAuth2 token to `{{config_path}}/oauth2_token.json` 181 | 182 | You should call it after initial login with `Nimrag.Auth`, and each session 183 | otherwise this token will have to be refreshed very often. 184 | """ 185 | def write_fs_oauth2_token(%Client{oauth2_token: token}), do: write_fs_oauth2_token(token) 186 | 187 | @doc false 188 | def write_fs_oauth2_token(%OAuth2Token{} = token), 189 | do: write_fs_oauth_token(:oauth2_token, token) 190 | 191 | defp write_fs_oauth_token(key, token) do 192 | path = Path.join(config_fs_path(), "#{key}.json") 193 | 194 | with {:ok, data} = Jason.encode(token, pretty: true), 195 | _ <- Logger.debug(fn -> ["writing ", path] end), 196 | :ok <- File.mkdir_p!(Path.dirname(path)), 197 | :ok <- File.touch!(path), 198 | :ok <- File.chmod!(path, 0o600), 199 | :ok <- File.write!(path, data) do 200 | :ok 201 | end 202 | end 203 | 204 | defp get_username, do: System.get_env("NIMRAG_USERNAME") 205 | 206 | defp get_password do 207 | cond do 208 | password = System.get_env("NIMRAG_PASSWORD") -> 209 | password 210 | 211 | password_file = System.get_env("NIMRAG_PASSWORD_FILE") -> 212 | password_file 213 | |> Path.expand() 214 | |> File.read!() 215 | 216 | password_cmd = System.get_env("NIMRAG_PASSWORD_COMMAND") -> 217 | [cmd | args] = String.split(password_cmd, " ", trim: true) 218 | 219 | case System.cmd(cmd, args) do 220 | {output, 0} -> 221 | output 222 | 223 | _ -> 224 | raise "Failed to execute password command: cmd=#{cmd} args=#{inspect(args)}" 225 | end 226 | end 227 | |> String.trim() 228 | end 229 | 230 | @doc false 231 | def get_mfa(%__MODULE__{get_mfa: get_mfa}) when is_function(get_mfa), do: get_mfa.() 232 | 233 | def get_mfa(%__MODULE__{get_mfa: {module, fun, args}}) when is_atom(module) and is_atom(fun), 234 | do: apply(module, fun, args) 235 | 236 | @doc false 237 | # Reads MFA code from stdin. This is used as a default. 238 | 239 | @spec read_user_input_mfa :: {:ok, String.t()} | {:error, atom()} 240 | def read_user_input_mfa do 241 | IO.gets("Enter MFA code: ") 242 | |> String.trim() 243 | |> case do 244 | "" -> {:error, :invalid_mfa} 245 | code -> {:ok, code} 246 | end 247 | end 248 | 249 | defp read_fs_credentials do 250 | credentials_fs_path = Path.join(config_fs_path(), "credentials.json") 251 | 252 | credentials = 253 | with {:ok, data} <- read_credentials(credentials_fs_path), 254 | {:ok, credentials} <- decode_json(data, "Invalid JSON in credentials.json") do 255 | %__MODULE__{ 256 | username: credentials["username"], 257 | password: credentials["password"] 258 | } 259 | else 260 | _ -> 261 | %__MODULE__{ 262 | username: nil, 263 | password: nil 264 | } 265 | end 266 | 267 | credentials 268 | end 269 | 270 | defp validate_permissions(path) do 271 | case File.stat(path) do 272 | {:ok, %File.Stat{mode: mode}} -> 273 | if mode != 0o100600 do 274 | raise """ 275 | Invalid permissions for #{path}. Expected 600, got #{Integer.to_string(mode, 8)} 276 | """ 277 | end 278 | 279 | _ -> 280 | raise "Could not read permissions for #{path}" 281 | end 282 | end 283 | 284 | defp decode_json(data, error_msg) do 285 | case Jason.decode(data) do 286 | {:ok, data} -> 287 | {:ok, data} 288 | 289 | {:error, _} -> 290 | Logger.warning(error_msg) 291 | 292 | nil 293 | end 294 | end 295 | 296 | defp read_credentials(path) do 297 | if File.exists?(path) do 298 | validate_permissions(path) 299 | File.read(path) 300 | end 301 | end 302 | 303 | defp config_fs_path do 304 | Application.get_env(:nimrag, :config_fs_path) || 305 | System.get_env("NIMRAG_CONFIG_PATH") || 306 | :filename.basedir(:user_config, "nimrag") 307 | end 308 | end 309 | 310 | defimpl Inspect, for: Nimrag.Credentials do 311 | alias Nimrag.Credentials 312 | import Inspect.Algebra 313 | 314 | def inspect( 315 | %Credentials{username: username}, 316 | opts 317 | ) do 318 | details = 319 | Inspect.List.inspect( 320 | [ 321 | username: 322 | (username |> String.split("@", trim: true) |> List.first() |> String.slice(0, 5)) <> 323 | "...", 324 | password: "*****" 325 | ], 326 | opts 327 | ) 328 | 329 | concat(["#Nimrag.Credentials<", details, ">"]) 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /lib/nimrag/auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Nimrag.Auth do 2 | alias Nimrag.Client 3 | alias Nimrag.Credentials 4 | alias Nimrag.{OAuth1Token, OAuth2Token} 5 | 6 | require Logger 7 | 8 | @moduledoc """ 9 | Unofficial authentication to Garmin's API. 10 | 11 | It requires username, password, and may ask for MFA code if needed. 12 | See `Nimrag.Credentials` for more details on how to provide credentials. 13 | 14 | It's using the same method for obtaining OAuth1 and OAuth2 tokens as the mobile app, 15 | and popular Python library [garth](https://github.com/matin/garth). 16 | 17 | **This may break at any time as it's not using public API.** 18 | 19 | Garmin API is available only to business partners, so please don't abuse it in any way. 20 | """ 21 | 22 | # hardcoded values from fetched s3 url in https://github.com/matin/garth 23 | # base64 encoded values, based on GitHub issues, that's what the app is using 24 | @oauth_consumer_key Base.url_decode64!("ZmMzZTk5ZDItMTE4Yy00NGI4LThhZTMtMDMzNzBkZGUyNGMw") 25 | @oauth_consumer_secret Base.url_decode64!("RTA4V0FSODk3V0V5MmtubjdhRkJydmVnVkFmMEFGZFdCQkY=") 26 | @mobile_user_agent "com.garmin.android.apps.connectmobile" 27 | # API always refreshes expired tokens BEFORE making request, NOT on 401 errors 28 | @short_expires_by_n_seconds 10 29 | 30 | # simulate web-like login flow without using secret key/secret extracted from mobile app 31 | # def login_web(client) do 32 | # end 33 | 34 | @spec login_sso(Credentials.t()) :: {:ok, Client.t()} | {:error, String.t()} 35 | @spec login_sso(Client.t()) :: {:ok, Client.t()} | {:error, String.t()} 36 | @spec login_sso(Client.t(), Credentials.t()) :: {:ok, Client.t()} | {:error, String.t()} 37 | 38 | def login_sso, do: login_sso(Client.new(), Credentials.new()) 39 | def login_sso(%Credentials{} = credentials), do: login_sso(Client.new(), credentials) 40 | def login_sso(%Client{} = client), do: login_sso(client, Credentials.new()) 41 | 42 | def login_sso(%Client{} = client, %Credentials{} = credentials) do 43 | with {:ok, sso} <- build_sso(client), 44 | {:ok, embed_response} <- embed_req(sso), 45 | {:ok, signin_response} <- signin_req(sso, embed_response), 46 | {:ok, signin_post_response} <- 47 | submit_signin_req(sso, signin_response, credentials), 48 | cookie = get_cookie(signin_response), 49 | {:ok, signin_post_response} <- 50 | maybe_handle_mfa(sso, signin_post_response, cookie, credentials), 51 | {:ok, ticket} <- get_ticket(signin_post_response), 52 | {:ok, oauth1_token} <- get_oauth1_token(client, ticket), 53 | {:ok, oauth2_token} <- get_oauth2_token(client, oauth1_token) do 54 | {:ok, 55 | client 56 | |> Client.put_oauth_token(oauth1_token) 57 | |> Client.put_oauth_token(oauth2_token)} 58 | else 59 | error -> 60 | Logger.debug(fn -> 61 | "Details why login failed: #{inspect(error)}. It may contain sensitive data, depending on the error." 62 | end) 63 | 64 | {:error, "Couldn't fully authenticate. Error data is only printed on debug log level."} 65 | end 66 | end 67 | 68 | def get_oauth1_token(%Client{} = client, ticket) do 69 | url = "/oauth-service/oauth/preauthorized" 70 | 71 | params = [ 72 | {"ticket", ticket}, 73 | {"login-url", sso_url(client) <> "/embed"}, 74 | {"accepts-mfa-tokens", "true"} 75 | ] 76 | 77 | {{"Authorization", oauth}, req_params} = 78 | OAuther.sign("get", client.connectapi.options.base_url <> url, params, oauth_creds()) 79 | |> OAuther.header() 80 | 81 | now = DateTime.utc_now() 82 | 83 | {:ok, response} = 84 | client.connectapi 85 | |> Req.Request.put_header("Authorization", oauth) 86 | |> Req.get( 87 | url: url, 88 | params: req_params, 89 | user_agent: @mobile_user_agent 90 | ) 91 | 92 | %{"oauth_token" => token, "oauth_token_secret" => secret} = 93 | query = URI.decode_query(response.body) 94 | 95 | {:ok, 96 | %OAuth1Token{ 97 | oauth_token: token, 98 | oauth_token_secret: secret, 99 | domain: client.domain, 100 | mfa_token: query["mfa_token"] || "", 101 | # TODO: OAuth1Token, Is that 365 days true with MFA active? We'll wait and see! 102 | expires_at: DateTime.add(now, 365, :day) 103 | }} 104 | end 105 | 106 | def maybe_refresh_oauth2_token(%Client{} = client, opts \\ []) do 107 | force = Keyword.get(opts, :force, false) 108 | 109 | if client.oauth2_token == nil || OAuth2Token.expired?(client.oauth2_token) || force do 110 | Logger.info(fn -> "Refreshing OAuth2Token #{inspect(client.oauth2_token)}" end) 111 | 112 | with {:ok, oauth2_token} <- get_oauth2_token(client, client.oauth1_token) do 113 | {:ok, client |> Client.put_oauth_token(oauth2_token)} 114 | end 115 | else 116 | {:ok, client} 117 | end 118 | end 119 | 120 | def get_oauth2_token(%Client{} = client) do 121 | get_oauth2_token(client, client.oauth1_token) 122 | end 123 | 124 | def get_oauth2_token(%Client{} = client, %OAuth1Token{} = oauth1_token) do 125 | url = "/oauth-service/oauth/exchange/user/2.0" 126 | 127 | params = 128 | if oauth1_token.mfa_token && oauth1_token.mfa_token != "" do 129 | [{"mfa_token", oauth1_token.mfa_token}] 130 | else 131 | [] 132 | end 133 | 134 | {{"Authorization" = auth, oauth}, req_params} = 135 | OAuther.sign( 136 | "post", 137 | client.connectapi.options.base_url <> url, 138 | params, 139 | oauth_creds(oauth1_token) 140 | ) 141 | |> OAuther.header() 142 | 143 | now = DateTime.utc_now() 144 | 145 | result = 146 | client.connectapi 147 | |> Req.Request.put_header(auth, oauth) 148 | |> Req.post( 149 | url: url, 150 | form: req_params, 151 | user_agent: @mobile_user_agent 152 | ) 153 | 154 | with {:ok, response} <- result do 155 | %{ 156 | "access_token" => access_token, 157 | "expires_in" => expires_in, 158 | "jti" => jti, 159 | "refresh_token" => refresh_token, 160 | "refresh_token_expires_in" => refresh_token_expires_in, 161 | "scope" => scope, 162 | "token_type" => token_type 163 | } = response.body 164 | 165 | expires_at = DateTime.add(now, expires_in - @short_expires_by_n_seconds, :second) 166 | 167 | refresh_token_expires_at = 168 | DateTime.add(now, refresh_token_expires_in - @short_expires_by_n_seconds, :second) 169 | 170 | {:ok, 171 | %OAuth2Token{ 172 | access_token: access_token, 173 | jti: jti, 174 | expires_at: expires_at, 175 | refresh_token: refresh_token, 176 | refresh_token_expires_at: refresh_token_expires_at, 177 | scope: scope, 178 | token_type: token_type 179 | }} 180 | end 181 | end 182 | 183 | defp maybe_handle_mfa(sso, %Req.Response{} = prev_resp, cookie, credentials) do 184 | if String.contains?(get_location(prev_resp), "verifyMFA") do 185 | submit_mfa(sso, cookie, credentials) 186 | else 187 | {:ok, prev_resp} 188 | end 189 | end 190 | 191 | defp submit_mfa(sso, cookie, credentials) do 192 | with {:ok, response} <- get_mfa(sso, cookie), 193 | {:ok, csrf_token} <- get_csrf_token(response), 194 | {:ok, mfa_code} <- Credentials.get_mfa(credentials), 195 | {:ok, %{status: 302} = response} <- submit_mfa_req(sso, csrf_token, cookie, mfa_code) do 196 | uri = response |> get_location() |> URI.parse() 197 | 198 | sso.client 199 | |> Req.Request.put_header("cookie", Enum.uniq(cookie ++ get_cookie(response))) 200 | |> Req.Request.put_header( 201 | "referer", 202 | "#{sso.url}/verifyMFA/loginEnterMfaCode" 203 | ) 204 | |> Req.get( 205 | url: "/login", 206 | params: URI.decode_query(uri.query) 207 | ) 208 | |> check_response(:submit_mfa) 209 | end 210 | end 211 | 212 | defp oauth_creds do 213 | OAuther.credentials( 214 | consumer_key: @oauth_consumer_key, 215 | consumer_secret: @oauth_consumer_secret 216 | ) 217 | end 218 | 219 | defp oauth_creds(%OAuth1Token{oauth_token: token, oauth_token_secret: secret}) do 220 | OAuther.credentials( 221 | consumer_key: @oauth_consumer_key, 222 | consumer_secret: @oauth_consumer_secret, 223 | token: token, 224 | token_secret: secret 225 | ) 226 | end 227 | 228 | defp get_cookie(%Req.Response{} = response), 229 | do: Req.Response.get_header(response, "set-cookie") 230 | 231 | defp get_location(%Req.Response{} = response), 232 | do: List.first(Req.Response.get_header(response, "location")) || "" 233 | 234 | defp get_csrf_token(%Req.Response{body: body, status: 200}) do 235 | case Regex.scan(~r/name="_csrf"\s+value="(.+?)"/, body) do 236 | [[_, csrf_token]] -> {:ok, csrf_token} 237 | _ -> {:error, :missing_csrf} 238 | end 239 | end 240 | 241 | defp get_csrf_token(%Req.Response{}), do: {:error, :missing_csrf} 242 | 243 | defp get_ticket(%Req.Response{body: body, status: 200}) do 244 | case Regex.scan(~r/embed\?ticket=([^"]+)"/, body) do 245 | [[_, ticket]] -> {:ok, ticket} 246 | _ -> {:error, :missing_ticket} 247 | end 248 | end 249 | 250 | defp submit_mfa_req(sso, csrf_token, cookie, mfa_code) do 251 | sso.client 252 | |> Req.Request.put_header("cookie", cookie) 253 | |> Req.Request.put_header("referer", "#{sso.url}/verifyMFA") 254 | |> Req.post( 255 | url: "/verifyMFA/loginEnterMfaCode", 256 | params: sso.signin_params, 257 | form: %{ 258 | "mfa-code" => mfa_code, 259 | fromPage: "setupEnterMfaCode", 260 | embed: "true", 261 | _csrf: csrf_token 262 | } 263 | ) 264 | |> check_response(:submit_mfa_req) 265 | end 266 | 267 | @get_mfa_retry_count 3 268 | defp get_mfa(sso, cookie), do: get_mfa(sso, cookie, @get_mfa_retry_count) 269 | defp get_mfa(_sso, _cookie, 0), do: {:error, :mfa_unavailable} 270 | 271 | defp get_mfa(sso, cookie, retry) do 272 | sso.client 273 | |> Req.Request.put_header("cookie", cookie) 274 | |> Req.Request.put_header("referer", "#{sso.url}/signin") 275 | |> Req.get( 276 | url: "/verifyMFA/loginEnterMfaCode", 277 | params: sso.signin_params 278 | ) 279 | |> check_response(:get_mfa) 280 | |> case do 281 | {:ok, %{status: 302}} -> 282 | Logger.debug(fn -> "Getting MFA submit page failed, retrying..." end) 283 | Process.sleep(1000) 284 | get_mfa(sso, cookie, retry - 1) 285 | 286 | result -> 287 | result 288 | end 289 | end 290 | 291 | defp embed_req(sso) do 292 | Req.get(sso.client, url: "/embed", params: sso.embed_params) 293 | |> check_response(:embed_req) 294 | end 295 | 296 | defp signin_req(sso, %Req.Response{} = prev_resp) do 297 | sso.client 298 | |> Req.Request.put_header("cookie", get_cookie(prev_resp)) 299 | |> Req.Request.put_header("referer", "#{sso.url}/embed") 300 | |> Req.get( 301 | url: "/signin", 302 | params: sso.signin_params 303 | ) 304 | |> check_response(:signin_req) 305 | end 306 | 307 | defp submit_signin_req(sso, %Req.Response{} = prev_resp, credentials) do 308 | with {:ok, csrf_token} <- get_csrf_token(prev_resp) do 309 | sso.client 310 | |> Req.Request.put_header("cookie", get_cookie(prev_resp)) 311 | |> Req.Request.put_header("referer", "#{sso.url}/signin") 312 | |> Req.post( 313 | url: "/signin", 314 | params: sso.signin_params, 315 | form: %{ 316 | username: credentials.username, 317 | password: credentials.password, 318 | embed: "true", 319 | _csrf: csrf_token 320 | } 321 | ) 322 | |> check_response(:submit_signin_req) 323 | end 324 | end 325 | 326 | def build_sso(%Client{} = client) do 327 | sso_url = sso_url(client) 328 | sso_embed = "#{sso_url}/embed" 329 | 330 | embed_params = %{ 331 | id: "gauth-widget", 332 | embedWidget: "true", 333 | gauthHost: sso_url 334 | } 335 | 336 | signin_params = 337 | Map.merge(embed_params, %{ 338 | gauthHost: sso_embed, 339 | service: sso_embed, 340 | source: sso_embed, 341 | redirectAfterAccountLoginUrl: sso_embed, 342 | redirectAfterAccountCreationUrl: sso_embed 343 | }) 344 | 345 | {:ok, 346 | %{ 347 | client: sso_client(client), 348 | url: sso_url, 349 | embed: sso_embed, 350 | embed_params: embed_params, 351 | signin_params: signin_params 352 | }} 353 | end 354 | 355 | defp sso_client(%Client{} = client) do 356 | client.req_options 357 | |> Keyword.put(:base_url, sso_url(client)) 358 | |> Keyword.put(:user_agent, @mobile_user_agent) 359 | |> Keyword.put(:retry, false) 360 | |> Keyword.put(:redirect, false) 361 | |> Keyword.put( 362 | :connect_options, 363 | Keyword.merge( 364 | Keyword.get(client.req_options, :connect_options) || [], 365 | # It's very important that http2 is here, otherwise MFA flow fails. 366 | protocols: [:http1, :http2] 367 | ) 368 | ) 369 | |> Req.new() 370 | |> Req.Request.put_new_header("host", "sso.#{client.domain}") 371 | |> Req.Request.put_header("host", client.domain) 372 | end 373 | 374 | defp sso_url(%{domain: domain}) do 375 | "https://sso.#{domain}/sso" 376 | end 377 | 378 | defp check_response({:ok, %{status: status} = response}, _tag) when status in [200, 302], 379 | do: {:ok, response} 380 | 381 | defp check_response({:ok, response}, tag), do: {:error, {tag, response}} 382 | defp check_response({:error, err}, tag), do: {:error, {tag, err}} 383 | end 384 | --------------------------------------------------------------------------------