Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/elixir-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Setup Elixir Project
uses: ./.github/actions/elixir-setup
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
- Add `Nimrag.steps_weekly`
- Add `Nimrag.sleep_daily`
- Add `Nimrag.user_settings`
- Rename struct `Nimrag.Api.Activity` to `Nimrag.Api.ActivityList` (data struct for elements of activities list)
- Add `Nimrag.activity`
- Add `Nimrag.activity_details`

## Breaking changes

- `Nimrag.activity_list/2` returned struct is now `Nimrag.Api.ActivityList` and uses `activity_id` instead of `:id`

## 0.1.0 (2024-03-29)

Init release
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Nimrag

[![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)
[![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)
[![Hex.pm](https://img.shields.io/hexpm/v/nimrag.svg?style=flat)](https://hex.pm/packages/nimrag)
[![Documentation](https://img.shields.io/badge/hex-docs-lightgreen.svg?style=flat)](https://hexdocs.pm/nimrag)
[![License](https://img.shields.io/hexpm/l/nimrag.svg?style=flat)](https://github.com/arathunku/nimrag/blob/main/LICENSE.md)
Expand Down Expand Up @@ -34,11 +34,11 @@ https://github.com/arathunku/nimrag/assets/749393/7246f688-4820-4276-96de-5d8ed7
Garmin doesn't have any official public API for individuals, only businesses.
It means we're required to use username, password and (optionally) MFA code to obtain
OAuth tokens. OAuth1 token is valid for up to a year and it's used to generate
OAuth2 token that expires quickly, OAuth2 token is used for making the API calls.
After OAuth1 token expires, we need to repeat the authentication process.
OAuth2 token that expires quickly, OAuth2 token is used for making the API calls.
After OAuth1 token expires, we need to repeat the authentication process.

Please see `Nimrag.Auth` docs for more details about authentication,
and see `Nimrag.Credentials` on how to avoid providing plaintext credentials directly in code.
Please see `Nimrag.Auth` docs for more details about authentication,
and see `Nimrag.Credentials` on how to avoid providing plaintext credentials directly in code.

```elixir
# If you're using it for the first time, we need to get OAuth Tokens first.
Expand All @@ -63,7 +63,10 @@ client = Nimrag.Client.new() |> Nimrag.Client.with_auth(Nimrag.Credentials.read_
{:ok, %Nimrag.Api.Profile{} = profile, client} = Nimrag.profile(client)

# Fetch your latest activity
{:ok, %Nimrag.Api.Activity{} = activity, client} = Nimrag.last_activity(client)
{:ok, %Nimrag.Api.ActivityList{} = activity, client} = Nimrag.last_activity(client)

# Fetch your latest activity details
{:ok, %Nimrag.Api.ActivityDetails{} = activity, client} = Nimrag.activity_details(client, activity.activity_id)

# Call at the end of the session to cache new OAuth2 token
:ok = Nimrag.Credentials.write_fs_oauth2_token(client)
Expand All @@ -86,7 +89,7 @@ automatically updated when it's near expiration.
There's at this moment no extensive coverage of API endpoints, feel free to submit
PR with new structs and endpoints, see [Contributing](#contributing).

### Rate limit
### Rate limit

By default, Nimrag uses [Hammer](https://github.com/ExHammer/hammer) for rate limiting requests.
If you are already using `Hammer`, you can configure backend key via config.
Expand All @@ -111,7 +114,7 @@ You can discover new endpoints by setting up [mitmproxy](https://mitmproxy.org/)
traffic from mobile app or website. You can also take a look at
[python-garminconnect](https://github.com/cyberjunky/python-garminconnect/blob/master/garminconnect/__init__.py).

For local setup, the project has minimal dependencies and is easy to install
For local setup, the project has minimal dependencies and is easy to install

```sh
# fork and clone the repo
Expand Down Expand Up @@ -142,6 +145,16 @@ $ mix check
1. Define new [`Schematic`](https://github.com/mhanberg/schematic) schema in `Nimrag.Api`,
and ensure all tests pass.


### Data structure

- Any method returning API's data returns it as a struct, with known fields.
- When Garmin removes any of the fields, they'll be removed here too.
- Garmin API is consistenly inconsistent where it comes to timestamps - sometimes they're formatted,
sometimes they're unix timestamp, even for the fields on the same name. Nimrag changes all datetimes
without timezone into NaiveDateTime "as is", and all "GMT" timestamps by Garmin are DateTime with Etc/UTC
timezone. All fields with NaiveDateTime/DateTime end with `_at`

## License

Copyright © 2024 Michal Forys
Expand Down
13 changes: 8 additions & 5 deletions lib/nimrag.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ defmodule Nimrag do
Note: this doesn't return the same data structure as a list of activities!
"""
@spec activity(Client.t(), integer()) :: {:ok, Api.Activity.t(), Client.t()} | error()
def activity(client, id), do: client |> activity_req(id) |> response_as_data(Api.Activity)
def activity(client, id) when is_integer(id) or is_bitstring(id),
do: client |> activity_req(id) |> response_as_data(Api.Activity)

def activity_req(client, id),
do: get(client, url: "/activity-service/activity/:id", path_params: [id: id])
Expand All @@ -110,7 +111,7 @@ defmodule Nimrag do
"""
@spec activity_details(Client.t(), integer()) ::
{:ok, Api.ActivityDetails.t(), Client.t()} | error()
def activity_details(client, id),
def activity_details(client, id) when is_integer(id) or is_bitstring(id),
do: client |> activity_details_req(id) |> response_as_data(Api.ActivityDetails)

def activity_details_req(client, id),
Expand All @@ -124,7 +125,7 @@ defmodule Nimrag do
{:ok, list(Api.ActivityList.t()), Client.t()} | error()
@spec activities(Client.t(), offset :: integer(), limit :: integer()) ::
{:ok, list(Api.ActivityList.t()), Client.t()} | error()
def activities(client, offset \\ 0, limit \\ 10) do
def activities(client, offset \\ 0, limit \\ 10) when is_integer(offset) and is_integer(limit) do
client |> activities_req(offset, limit) |> response_as_data(Api.ActivityList)
end

Expand Down Expand Up @@ -163,7 +164,8 @@ defmodule Nimrag do
{:ok, binary(), Client.t()} | error()
@spec download_activity(Client.t(), activity_id :: integer(), :csv) ::
{:ok, binary(), Client.t()} | error()
def download_activity(client, activity_id, :raw) do
def download_activity(client, activity_id, :raw)
when is_integer(activity_id) or is_bitstring(activity_id) do
with {:ok, %{body: body, status: 200}, client} <-
download_activity_req(client,
prefix_url: "download-service/files/activity",
Expand Down Expand Up @@ -210,7 +212,8 @@ defmodule Nimrag do
{:ok, list(Api.SleepDaily.t()), Client.t()} | error()
@spec sleep_daily(Client.t(), username :: String.t(), date :: Date.t(), integer()) ::
{:ok, list(Api.SleepDaily.t()), Client.t()} | error()
def sleep_daily(client, username, date \\ Date.utc_today(), buffer_minutes \\ 60) do
def sleep_daily(client, username, date \\ Date.utc_today(), buffer_minutes \\ 60)
when is_bitstring(username) do
client |> sleep_daily_req(username, date, buffer_minutes) |> response_as_data(Api.SleepDaily)
end

Expand Down
32 changes: 18 additions & 14 deletions lib/nimrag/api/activity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,47 @@ defmodule Nimrag.Api.Activity do
@type t() :: %__MODULE__{
distance: float(),
duration: float(),
average_hr: float(),
max_hr: float(),
elevation_gain: float(),
elevation_loss: float()
average_hr: nil | float(),
max_hr: nil | float(),
elevation_gain: nil | float(),
elevation_loss: nil | float(),
start_local_at: NaiveDateTime.t(),
start_at: DateTime.t()
}

defstruct ~w(
id distance duration average_hr max_hr elevation_gain elevation_loss
)a
defstruct ~w(distance duration average_hr max_hr elevation_gain elevation_loss start_local_at start_at)a

def schematic() do
schema(__MODULE__, %{
field(:distance) => float(),
field(:duration) => float(),
{"maxHR", :max_hr} => float(),
{"averageHR", :average_hr} => float(),
field(:elevation_gain) => float(),
field(:elevation_loss) => float()
optional({"maxHR", :max_hr}) => nullable(float()),
optional({"averageHR", :average_hr}) => nullable(float()),
optional(field(:elevation_gain)) => nullable(float()),
optional(field(:elevation_loss)) => nullable(float()),
{"startTimeLocal", :start_local_at} => naive_datetime(),
{"startTimeGMT", :start_at} => gmt_datetime_as_datetime()
})
end
end

@type t() :: %__MODULE__{
id: integer(),
activity_id: integer(),
activity_name: String.t(),
activity_type: ActivityType.t(),
description: nil | String.t(),
summary: __MODULE__.Summary.t()
}

defstruct ~w(
id activity_name activity_type summary
activity_id activity_name activity_type summary description
)a

def schematic() do
schema(__MODULE__, %{
{"activityId", :id} => int(),
field(:activity_id) => int(),
field(:activity_name) => str(),
optional(field(:description)) => nullable(str()),
{"activityTypeDTO", :activity_type} => ActivityType.schematic(),
{"summaryDTO", :summary} => __MODULE__.Summary.schematic()
})
Expand Down
33 changes: 17 additions & 16 deletions lib/nimrag/api/activity_list.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,39 @@ defmodule Nimrag.Api.ActivityList do
alias Nimrag.Api.ActivityType

@type t() :: %__MODULE__{
id: integer(),
activity_id: integer(),
distance: float(),
duration: float(),
activity_name: String.t(),
begin_at: DateTime.t(),
start_local_at: NaiveDateTime.t(),
average_hr: float(),
max_hr: float(),
elevation_gain: float(),
elevation_loss: float(),
average_hr: nil | float(),
max_hr: nil | float(),
elevation_gain: nil | float(),
elevation_loss: nil | float(),
description: nil | String.t(),
activity_type: ActivityType.t()
}

defstruct ~w(
id distance duration begin_at start_local_at activity_name
average_hr max_hr elevation_gain elevation_loss description activity_type
activity_id distance duration begin_at start_local_at activity_name description
average_hr max_hr elevation_gain elevation_loss activity_type
)a

def schematic() do
schema(__MODULE__, %{
{"beginTimestamp", :begin_at} => timestamp_datetime(),
# TODO: check methods
{"beginTimestamp", :begin_at} => timestamp_as_datetime(),
{"startTimeLocal", :start_local_at} => naive_datetime(),
{"activityId", :id} => int(),
field(:activity_id) => int(),
field(:activity_name) => str(),
:distance => float(),
:duration => float(),
{"averageHR", :average_hr} => float(),
{"maxHR", :max_hr} => float(),
field(:elevationGain) => float(),
field(:elevationLoss) => float(),
field(:description) => nullable(str()),
optional(field(:description)) => nullable(str()),
field(:distance) => float(),
field(:duration) => float(),
optional({"averageHR", :average_hr}) => nullable(float()),
optional({"maxHR", :max_hr}) => nullable(float()),
optional(field(:elevationGain)) => nullable(float()),
optional(field(:elevationLoss)) => nullable(float()),
field(:activity_type) => ActivityType.schematic()
})
end
Expand Down
73 changes: 70 additions & 3 deletions lib/nimrag/api/data.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ defmodule Nimrag.Api.Data do
end
end

def timestamp_datetime() do
def timestamp_as_datetime() do
raw(
fn
i, :to -> is_number(i) and match?({:ok, _}, DateTime.from_unix(i, :millisecond))
i, :from -> match?(%DateTime{}, i)
i, :to ->
is_number(i) and match?({:ok, _}, DateTime.from_unix(i, :millisecond))

i, :from ->
match?(%DateTime{}, i)
end,
transform: fn
i, :to ->
Expand All @@ -43,6 +46,28 @@ defmodule Nimrag.Api.Data do
)
end

def timestamp_as_naive_datetime() do
raw(
fn
i, :to ->
is_number(i) and
match?({:ok, _}, DateTime.from_unix(i, :millisecond))

i, :from ->
match?(%NaiveDateTime{}, i)
end,
transform: fn
i, :to ->
{:ok, dt} = DateTime.from_unix(i, :millisecond)
DateTime.to_naive(dt)

v, :from ->
DateTime.from_naive(v, "Etc/UTC")
|> DateTime.to_unix(:millisecond)
end
)
end

def naive_datetime() do
raw(
fn
Expand All @@ -60,6 +85,48 @@ defmodule Nimrag.Api.Data do
)
end

def gmt_timestamp_as_datetime() do
raw(
fn
i, :to ->
is_binary(i) and
match?({:ok, _}, NaiveDateTime.from_iso8601(i) |> DateTime.from_naive!("Etc/UTC"))

i, :from ->
match?(%NaiveDateTime{}, i)
end,
transform: fn
i, :to ->
{:ok, dt} = NaiveDateTime.from_iso8601(i)
DateTime.from_naive!(dt, "Etc/UTC")

i, :from ->
DateTime.to_iso8601(i)
end
)
end

def gmt_datetime_as_datetime() do
raw(
fn
i, :to ->
is_binary(i) and
match?({:ok, _}, NaiveDateTime.from_iso8601!(i) |> DateTime.from_naive("Etc/UTC"))

i, :from ->
match?(%NaiveDateTime{}, i)
end,
transform: fn
i, :to ->
{:ok, dt} = NaiveDateTime.from_iso8601(i)
DateTime.from_naive!(dt, "Etc/UTC")

i, :from ->
DateTime.to_iso8601(i)
end
)
end

def date() do
raw(
fn
Expand Down
2 changes: 1 addition & 1 deletion lib/nimrag/api/profile.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ defmodule Nimrag.Api.Profile do
field(:favorite_activity_types) => list(str()),
field(:favorite_cycling_activity_types) => list(str()),
field(:full_name) => str(),
field(:garmin_guid) => nullable(str()),
optional(field(:garmin_guid)) => nullable(str()),
field(:id) => int(),
field(:level_is_viewed) => bool(),
field(:level_point_threshold) => int(),
Expand Down
Loading
Loading