From 5edc23aba0ce1254fd0b9dc5642ce4fe52543235 Mon Sep 17 00:00:00 2001 From: Bailey Date: Sun, 4 Feb 2024 11:54:04 -0600 Subject: [PATCH] [606] feat: robust settings for custom Device Info (#620) * robust support for custom device info - wip * finish up base GUI features * some fixes * update relase notes and example files * unit tests * documentation updates * fix * update release notes * try fix release workflow --- .github/actions/publish-ui-dist/action.yaml | 6 +- .github/workflows/publish-release.yaml | 8 +- .github/workflows/sync_peloton_to_garmin.yml | 11 +- configuration.example.json | 3 +- deviceInfo.sample.xml | 11 - mkdocs/docs/configuration/api.md | 34 ++ mkdocs/docs/configuration/app.md | 30 ++ mkdocs/docs/configuration/command-line.md | 4 - .../configuration/environment-variables.md | 36 -- mkdocs/docs/configuration/exercise-types.md | 17 + mkdocs/docs/configuration/format.md | 232 ++++++++++ mkdocs/docs/configuration/garmin.md | 40 ++ mkdocs/docs/configuration/index.md | 113 ++++- mkdocs/docs/configuration/json.md | 405 ------------------ mkdocs/docs/configuration/observability.md | 130 ++++++ mkdocs/docs/configuration/peloton.md | 55 +++ .../configuration/providing-device-info.md | 57 --- mkdocs/docs/configuration/webui.md | 50 +++ mkdocs/docs/faq.md | 26 +- mkdocs/docs/install/docker.md | 4 +- mkdocs/docs/migration/migrate-v1-v2.md | 30 +- mkdocs/docs/observability.md | 17 - mkdocs/mkdocs.yml | 14 +- src/ClientUI/MainPage.xaml.cs | 21 +- src/ClientUI/MauiProgram.cs | 1 + src/Common/Database/DbMigrations.cs | 69 ++- src/Common/Database/SyncStatusDb.cs | 19 +- src/Common/Dto/Garmin/GarminDeviceInfo.cs | 50 ++- src/Common/Dto/P2GWorkout.cs | 22 + src/Common/Dto/Settings.cs | 15 +- src/Common/Observe/Metrics.cs | 184 ++++---- .../Service/FileBasedSettingsService.cs | 59 ++- src/Common/Service/ISettingsService.cs | 3 +- src/Common/Service/SettingsService.cs | 62 +-- src/Conversion/FitConverter.cs | 2 +- src/Conversion/IConverter.cs | 62 +-- src/Conversion/TcxConverter.cs | 2 +- src/GitHub/ApiClient.cs | 24 -- src/GitHub/Dto/GitHubLatestRelease.cs | 9 - src/GitHub/Dto/P2GLatestRelease.cs | 11 - src/GitHub/GitHub.csproj | 20 - src/GitHub/GitHubService.cs | 102 ----- src/SharedUI/Pages/Settings.razor | 2 +- src/SharedUI/Shared/AppSettingsForm.razor | 2 +- src/SharedUI/Shared/DeviceInfoModal.razor | 107 +++++ src/SharedUI/Shared/FormatSettingsForm.razor | 183 +++++++- .../Common/Service/SettingServiceTests.cs | 147 +++++++ src/UnitTests/Conversion/ConverterTests.cs | 137 +----- src/UnitTests/Conversion/FitConverterTests.cs | 8 +- vNextReleaseNotes.md | 8 +- 50 files changed, 1543 insertions(+), 1121 deletions(-) delete mode 100644 deviceInfo.sample.xml create mode 100644 mkdocs/docs/configuration/api.md create mode 100644 mkdocs/docs/configuration/app.md delete mode 100644 mkdocs/docs/configuration/command-line.md delete mode 100644 mkdocs/docs/configuration/environment-variables.md create mode 100644 mkdocs/docs/configuration/exercise-types.md create mode 100644 mkdocs/docs/configuration/format.md create mode 100644 mkdocs/docs/configuration/garmin.md delete mode 100644 mkdocs/docs/configuration/json.md create mode 100644 mkdocs/docs/configuration/observability.md create mode 100644 mkdocs/docs/configuration/peloton.md delete mode 100644 mkdocs/docs/configuration/providing-device-info.md create mode 100644 mkdocs/docs/configuration/webui.md delete mode 100644 mkdocs/docs/observability.md delete mode 100644 src/GitHub/ApiClient.cs delete mode 100644 src/GitHub/Dto/GitHubLatestRelease.cs delete mode 100644 src/GitHub/Dto/P2GLatestRelease.cs delete mode 100644 src/GitHub/GitHub.csproj delete mode 100644 src/GitHub/GitHubService.cs create mode 100644 src/SharedUI/Shared/DeviceInfoModal.razor create mode 100644 src/UnitTests/Common/Service/SettingServiceTests.cs diff --git a/.github/actions/publish-ui-dist/action.yaml b/.github/actions/publish-ui-dist/action.yaml index 8ef12a6b9..7c06c3e5d 100644 --- a/.github/actions/publish-ui-dist/action.yaml +++ b/.github/actions/publish-ui-dist/action.yaml @@ -11,9 +11,9 @@ inputs: description: 'The OS we are running on' required: true outputs: - artifact: - description: 'Path to the published artifact' - value: ${{ github.workspace }}/src/ClientUI/bin/Release/${{ inputs.framework }}/${{ inputs.os }} + artifact_name: + description: 'Name of the uploaded artifact' + value: ui_${{ inputs.os }}_${{ env.BUILD_VERSION }} runs: using: "composite" steps: diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 1dd996113..bda6adbb9 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -49,7 +49,7 @@ jobs: name: Publish UI Distribution runs-on: 'windows-latest' outputs: - artifact: ${{ steps.win-ui-create-artifact.outputs.artifact }} + artifact_name: ${{ steps.win-ui-create-artifact.outputs.artifact_name }} strategy: matrix: dotnet: [ '7.0.400' ] @@ -71,10 +71,14 @@ jobs: runs-on: ubuntu-latest needs: [publish-ui-dist, publish-docker-images] steps: + - uses: actions/download-artifact@v3 + with: + name: ${{ needs.publish-ui-dist.outputs.artifact_name }} + path: /download - name: Create Zip for Win UI Release Artifact uses: papeloto/action-zip@v1 with: - files: ${{ needs.publish-ui-dist.outputs.artifact }} + files: /download/${{ needs.publish-ui-dist.outputs.artifact_name }} dest: /dist/ui_win_${{ github.event.inputs.version }}.zip - uses: actions/checkout@v4 diff --git a/.github/workflows/sync_peloton_to_garmin.yml b/.github/workflows/sync_peloton_to_garmin.yml index 6df79c0e3..8524d6ba0 100644 --- a/.github/workflows/sync_peloton_to_garmin.yml +++ b/.github/workflows/sync_peloton_to_garmin.yml @@ -25,14 +25,6 @@ jobs: - name: Set env run: echo "OUTPUT_DIR=/app/output" >> $GITHUB_ENV - run: mkdir -p ${{ env.OUTPUT_DIR }} - - name: Create device info file - env: - DEVICE_INFO: ${{ secrets.DEVICE_INFO }} - if: ${{ env.DEVICE_INFO }} - run: | - cat < /app/deviceInfo.xml - ${{secrets.DEVICE_INFO}} - EOT - name: Create config file env: DEFAULT_WORKOUT_NUM: 5 @@ -48,8 +40,7 @@ jobs: "Tcx": false, "SaveLocalCopy": ${{ github.event.inputs.saveLocalCopy || false }}, "IncludeTimeInHRZones": false, - "IncludeTimeInPowerZones": false, - "DeviceInfoPath": "./deviceInfo.xml" + "IncludeTimeInPowerZones": false }, "Peloton": { "NumWorkoutsToDownload": ${{ github.event.inputs.workoutsToDownload || env.DEFAULT_WORKOUT_NUM }}, diff --git a/configuration.example.json b/configuration.example.json index 995bd5566..a89f6fac8 100644 --- a/configuration.example.json +++ b/configuration.example.json @@ -11,8 +11,7 @@ "Tcx": false, "SaveLocalCopy": true, "IncludeTimeInHRZones": false, - "IncludeTimeInPowerZones": false, - "DeviceInfoPath": "./deviceInfo.xml" + "IncludeTimeInPowerZones": false }, "Peloton": { diff --git a/deviceInfo.sample.xml b/deviceInfo.sample.xml deleted file mode 100644 index 4a3e30fff..000000000 --- a/deviceInfo.sample.xml +++ /dev/null @@ -1,11 +0,0 @@ - - Garmin Sample Device - please create from exported TCX file - 00000000000 - 0000 - - 0 - 0 - 0 - 0 - - \ No newline at end of file diff --git a/mkdocs/docs/configuration/api.md b/mkdocs/docs/configuration/api.md new file mode 100644 index 000000000..33a30c075 --- /dev/null +++ b/mkdocs/docs/configuration/api.md @@ -0,0 +1,34 @@ +# API File Configuration + +!!! tip + + These settings only apply if you are running an Instance of the API. P2G provides some [recommended config files](https://github.com/philosowaffle/peloton-to-garmin/tree/master/docker/webui) to get you started. + +Some lower level configuration cannot be provided via the web user interface and can only be provided by config file. + +The Api looks for a file named `configuration.local.json` in the same directory where it is run. Below is an example of the structure of this config file. + +```json +{ + "Api": { /** (1)! **/ }, + "Observability": { /** (2)! **/ } +} +``` + +1. Jump to [Api Config Documentation](#api-config) +2. Go to [Observability Config Documentation](observability.md#observability-config) + +## Api Config + +!!! warning + Most people should not need to change this setting. + +```json + "Api": { + "HostUrl": "http://*:8080" + } +``` + +| Field | Required | Default | UI Setting Location | Description | +|:-----------|:---------|:--------|:--------------------|:------------| +| HostUrl | no | `http://localhost:8080` | none | The host and port the WebUI should bind to and listen on. | \ No newline at end of file diff --git a/mkdocs/docs/configuration/app.md b/mkdocs/docs/configuration/app.md new file mode 100644 index 000000000..74bb126c9 --- /dev/null +++ b/mkdocs/docs/configuration/app.md @@ -0,0 +1,30 @@ +# App Settings + +The App Settings provide global settings for the P2G application. + +## Settings location + +| Run Method | Location | +|------------|----------| +| Web UI | UI > Settings > App Tab | +| Windows Exe | UI > Settings > App Tab | +| GitHubAction | Config Section in Workflow | +| Headless (Docker or Console) | Config section in `configuration.local.json` | + +## File Configuration + +```json + "App": { + "EnablePolling": true, + "PollingIntervalSeconds": 86400, + "CheckForUpdates": true + } +``` + +## Settings Overview + +| Field | Required | Default | Description | +|:-----------|:---------|:--------|:------------| +| EnablePolling | no | `true` | `true` if you wish P2G to run continuously and poll Peloton for new workouts. | +| PollingIntervalSeconds | no | 86400 | The polling interval in seconds determines how frequently P2G should check for new workouts. Be warned, that setting this to a frequency of hourly or less may get you flagged by Peloton as a bad actor and they may reset your password. The default is set to Daily. | +| CheckForUpdates | no | `true` | `true` if P2G should check for updates and write a log message if a new release is available. If using the UI this message will display there as well. | \ No newline at end of file diff --git a/mkdocs/docs/configuration/command-line.md b/mkdocs/docs/configuration/command-line.md deleted file mode 100644 index 8fa97bc0a..000000000 --- a/mkdocs/docs/configuration/command-line.md +++ /dev/null @@ -1,4 +0,0 @@ - -# Command Line Configuration - -All of the values defined in the [Json config file](json.md) can also be defined as command line arguments. This functionality is provided by the default dotnet [IConfiguration interface](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0#command-line-1). diff --git a/mkdocs/docs/configuration/environment-variables.md b/mkdocs/docs/configuration/environment-variables.md deleted file mode 100644 index ac9c657e0..000000000 --- a/mkdocs/docs/configuration/environment-variables.md +++ /dev/null @@ -1,36 +0,0 @@ - -# Environment Variable Configuration - -All of the values defined in the [Json config file](json.md) can also be defined as environment variables. This functionality is provided by the default dotnet [IConfiguration interface](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0#environment-variables-1). - -The variables use the following convention, note the use of both single and double underscores: - -```bash -P2G_CONFIGSECTION__CONFIGPROPERTY=value -``` - -#### Example App Config - -```bash -P2G_APP__WORKINGDIRECTORY -P2G_APP__ENABLEPOLLING -P2G_APP__POLLINGINTERVALSECONDS -P2G_APP__PYTHONANDGUPLOADINSTALLED -``` - -#### Example Arrays - -```bash -P2G_PELOTON__EXCLUDEWORKOUTTYPES__0="meditation" -P2G_PELOTON__EXCLUDEWORKOUTTYPES__1="stretching" -P2G_PELOTON__EXCLUDEWORKOUTTYPES__2="yoga" -...and so on -``` - -#### Example Nested Sections - -For nested config sections, continue to use the same naming convention of defining the entire json path using `__` double underscores, and 0 based indexing for array values. - -```bash -P2G_OBSERVABILITY__SERILOG__WRITETO__0__NAME="File" -``` diff --git a/mkdocs/docs/configuration/exercise-types.md b/mkdocs/docs/configuration/exercise-types.md new file mode 100644 index 000000000..a1c1088ef --- /dev/null +++ b/mkdocs/docs/configuration/exercise-types.md @@ -0,0 +1,17 @@ +# Exercise Types + +```json + Cycling + Outdoor Cycling + BikeBootcamp + TreadmillRunning + OutdoorRunning + TreadmillWalking + OutdoorWalking + Cardio + Circuit + Strength + Stretching + Yoga + Meditation +``` \ No newline at end of file diff --git a/mkdocs/docs/configuration/format.md b/mkdocs/docs/configuration/format.md new file mode 100644 index 000000000..6b6338b2c --- /dev/null +++ b/mkdocs/docs/configuration/format.md @@ -0,0 +1,232 @@ +# Format Settings + +The Format Settings provide settings related to how workouts should be converted from Peloton. + +## Settings location + +| Run Method | Location | +|------------|----------| +| Web UI | UI > Settings > Conversion Tab | +| Windows Exe | UI > Settings > Conversion Tab | +| GitHubAction | Config Section in Workflow | +| Headless (Docker or Console) | Config section in `configuration.local.json` | + +## File Configuration + +```json +"Format": { + "Fit": true, + "Json": false, + "Tcx": false, + "SaveLocalCopy": false, + "IncldudeTimeInHRZones": false, + "IncludeTimeInPowerZones": false, + "DeviceInfoSettings": { /**(1)!**/ } + "Cycling": { + "PreferredLapType": "Class_Targets" + }, + "Running": { + "PreferredLapType": "Distance" + }, + "Rowing": { + "PreferredLapType": "Class_Segments" + }, + "Strength": { + "DefaultSecondsPerRep": 3 + }, + "WorkoutTitleTemplate": "{{PelotonWorkoutTitle}} with {{PelotonInstructorName}}" + } +``` + +1. Jump to [Device Info Settings Documentation](#customizing-the-garmin-device-associated-with-the-workout) + +## Settings Overview + +| Field | Required | Default | Description | +|:-----------|:---------|:--------|:------------| +| Fit | no | `false` | `true` indicates you wish downloaded workouts to be converted to FIT | +| Json | no | `false` | `true` indicates you wish downloaded workouts to be converted to JSON. This will automatically save a local copy when enabled. | +| Tcx | no | `false` | `true` indicates you wish downloaded workouts to be converted to TCX | +| SaveLocalCopy | no | `false` | `true` will save any converted workouts to the output directory. | +| IncludeTimeInHRZones | no | `false` | **Only use this if you are unable to configure your Max HR on Garmin Connect.** When set to True, P2G will attempt to capture the time spent in each HR Zone per the data returned by Peloton. See [understanding P2G provided zones](#understanding-p2g-provided-zones). | +| IncludePowerInHRZones | no | `false` | **Only use this if you are unable to configure your FTP and Power Zones on Garmin Connect.** When set to True, P2G will attempt to capture the time spent in each Power Zone per the data returned by Peloton. See [understanding P2G provided zones](#understanding-p2g-provided-zones). | +| DeviceInfoSettings | no | `null` | See [customizing the Garmin device associated with the workout](#customizing-the-garmin-device-associated-with-the-workout). | +| Cycling | no | `null` | Configuration specific to Cycling workouts. | +| Cycling.PreferredLapType | no | `Default` | The preferred [lap type to use](#lap-types). | +| Running | no | `null` | Configuration specific to Running workouts. | +| Running.PreferredLapType | no | `Default` | The preferred [lap type to use](#lap-types). | +| Rowing | no | `null` | Configuration specific to Rowing workouts. | +| Rowing.PreferredLapType | no | `Default` | The preferred [lap type to use](#lap-types). | +| Strength | no | `null` | Configuration specific to Strength workouts. | +| Strength.DefaultSecondsPerRep | no | `3` | For exercises that are done for time instead of reps, P2G can estimate how many reps you completed using this value. Ex. If `DefaultSecondsPerRep=3` and you do Curls for 15s, P2G will estimate you completed 5 reps. | +| WorkoutTitleTemplate | no | `{{PelotonWorkoutTitle}} with {{PelotonInstructorName}}` | Customize the workout title shown in Garmin Connect. [Read More...](#workout-title-templating) | + +## Understanding P2G Provided Zones + +!!! danger + If either custom zone setting is enable it is possible Garmin will **not** calculate Training Load, Effect, V02 Max, or related fields. Use these settings with caution. + +Garmin Connect expects that users have a registered device and they expect users have set up their HR and Power Zones on that device. However, this presents a problem if you either: + +* A) do not have a device capable of tracking Power +* B) do not have a Garmin device at all. + +The most common scenario for Peloton users is scenario `A`, where they do not own a Power capable Garmin device and therefore are not able to configure their Power Zones in Garmin Connect. If you do not have Power or HR zones configured in Garmin Connect then you are not able to view accurate `Time In Zones` charts for a given workout. + +P2G provides a work around for this by optionally enriching the workout with the `Time In Zones` data with one caveat: the chart will not display the range value for the zone. + +![Example Cycling Workout](https://github.com/philosowaffle/peloton-to-garmin/blob/master/images/missing_zone_values.png?raw=true "Example Missing Zone Values") + +This is only available when generating and uploading the [FIT](garmin.md) format. + +## Customizing the Garmin Device Associated with the workout + +Workouts uploaded to Garmin Connect must report what device they were recorded on. The device chosen impacts what additional data fields are calculated and shown by Garmin. + +For example, the device a workout is recorded on can impact: + +1. Whether or not the workout will count towards Challenges and Badges +1. Whether or not Garmin will calculate things like TSS, TE, Load, and VO2 Max + +For this reason, P2G provides [reasonable defaults](#p2g-default-devices) to ensure users get the most data possible on their workouts out of the box. + +If you choose to customize what devices are used by P2G you can do that either via the [ui](#configuring-device-info-via-the-ui) or via [config file](#configuring-device-info-via-config-file). + +### P2G Default Devices + +| Exercise Type | Default Device Used | +|---------------|---------------------| +| Default | Forerunner 945 | +| Cycling | Taxc Training App Windows | +| Rowing | Epix | + +### Configuring Device Info via the UI + +Under `Settings > Conversion > Advanced > Device Info Settings` you can see which devices will be used for each [Exercise Type](exercise-types.md). You can modify this list to suit your needs. + +The `None` [Exercise Type](exercise-types.md) serves as a global default used for any [Exercise Type](exercise-types.md) not configured. + +[Learn more about finding Device Info to use.](#discovering-garmin-devices) + +### Configuring Device Info via Config File + +This config section allows you to specificy a Device per [Exercise Type](exercise-types.md). The `None` [Exercise Type](exercise-types.md) serves as a global default used for any [Exercise Type](exercise-types.md) not configured. + +[Learn more about finding Device Info to use.](#discovering-garmin-devices) + +```json +"DeviceInfoSettings": { + "none": { + "name": "Forerunner 945", + "unitId": 1, + "productID": 3113, + "manufacturerId": 1, + "version": { + "versionMajor": 19, + "versionMinor": 2.0, + "buildMajor": 0, + "buildMinor": 0.0 + } + }, + "cycling": { + "name": "TacxTrainingAppWin", + "unitId": 1, + "productID": 20533, + "manufacturerId": 89, + "version": { + "versionMajor": 1, + "versionMinor": 30.0, + "buildMajor": 0, + "buildMinor": 0.0 + } + }, + "rowing": { + "name": "Epix", + "unitId": 3413684246, + "productID": 3943, + "manufacturerId": 1, + "version": { + "versionMajor": 10, + "versionMinor": 43.0, + "buildMajor": 0, + "buildMinor": 0.0 + } + } + } +``` + +### Discovering Garmin Devices + +You can find the Device Information for any previous workouts you have uploaded by following the below steps: + +1. Get your Garmin current device info + 1. Log on to Garmin connect and find an activity you recorded with your device + 1. In the upper right hand corner of the activity, click the gear icon and choose `Export to TCX` + 1. A TCX file will be downloaded to your computer +1. Find the TCX file you downloaded in part 1 and open it in any text editor. + 1. Use `ctrl-f` (`cmd-f` on mac) to find the ` + Garmin Sample Device - please create from exported TCX file + 00000000000 + 0000 + + 0 + 0 + 0 + 0 + + +``` + +## Lap Types + +P2G supports several different strategies for creating Laps in Garmin Connect. If a certain strategy is not available P2G will attempt to fallback to a different strategy. You can override this behavior by specifying your preferred Lap type in the config. When `PreferredLapType` is set, P2G will first attempt to generate your preferred type and then fall back to the default behavior if it is unable to. By default P2G will: + +1. First try to create laps based on `Class_Targets` +1. Then try to create laps based on `Class_Segments` +1. Finally fallback to create laps based on `Distance` + +| Strategy | Config Value | Description | +|:----------|:-------------|:------------| +| Class Targets | `Class_Targets` | If the Peloton data includes Target Cadence information, then laps will be created to match any time the Target Cadence changed. You must use this strategy if you want the Target Cadence to show up in Garmin on the Cadence chart. | +| Class Segments | `Class_Segments` | If the Peloton data includes Class Segment information, then laps will be created to match each segment: Warm Up, Cycling, Weights, Cool Down, etc. | +| Distance | `Distance` | P2G will caclulate Laps based on distance for each 1mi, 1km, or 500m (for Row only) based on your distance setting in Peloton. | + +## Workout Title Templating + +P2G allows some limited customization of the title that will be used on the workout imported to Garmin. + +By default the title is structured like: + +``` +10min HITT Ride with Ally Love +``` + +### Customizing the Title + +Title customization is provided via "templating", which allows you to provide a template that P2G should follow when constructing a workout title. The specific templating syntax P2G supports is [Handlebars](https://github.com/Handlebars-Net/Handlebars.Net). + +**The below data fields are available for use in the template:** + +* `PelotonWorkoutTitle` - Peloton provides this usually in the form of "10 min HITT Ride" +* `PelotonInstructorName` - Peloton provides this as the full instructors name: "Ally Love" + +These can be used to build a template like so: + +``` +{{PelotonWorkoutTitle}}{{#if PelotonInstructorName}} with {{PelotonInstructorName}}{{/if}} +``` + +The above template will always start with the Peloton workout title. **IF** the workout has Instructor information, then the template will add `with Instructor` after the workout title. + +Some characters are not allowed to be used in the workout titles. If you use an unsupported character then it will automatically be replaced with a dash (`-`). + +Additionally, Garmin has a limit on how long a title will be. If the title exceeds this limit (~45 characters) then the title will be truncated. + +**Note:** + +For this setting to take effect, your Garmin Connect account must be set to allow custom workout names. In the Garmin Connect web interface click on the user icon in the top right, select `Account Settings` then `Display Preferences` ([shortcut](https://connect.garmin.com/modern/settings/displayPreferences)). +Change the `Activity Name` setting to `Workout Name (when available)`. This will allow the custom workout name to sync, and should still allow the standard behavior when syncing non-P2G activities directly. diff --git a/mkdocs/docs/configuration/garmin.md b/mkdocs/docs/configuration/garmin.md new file mode 100644 index 000000000..f50af2111 --- /dev/null +++ b/mkdocs/docs/configuration/garmin.md @@ -0,0 +1,40 @@ +# Garmin Settngs + +This Garmin Settings provide settings related to uploading workouts to Garmin. + +## Settings location + +| Run Method | Location | +|------------|----------| +| Web UI | UI > Settings > Garmin Tab | +| Windows Exe | UI > Settings > Garmin Tab | +| GitHubAction | Config Section in Workflow | +| Headless (Docker or Console) | Config section in `configuration.local.json` | + +## File Configuration + +```json +"Garmin": { + "Email": "garmin@gmail.com", + "Password": "garmin", + "TwoStepVerificationEnabled": false, + "Upload": false, + "FormatToUpload": "fit" + } +``` + +!!! warning + Console or Docker Headless: Your username and password for Peloton and Garmin Connect are stored in clear text, which **is not secure**. Please be aware of the risks. +!!! success "WebUI version 3.3.0+: Credentials are stored **encrypted**." +!!! success "Windows Exe version 4.0.0+: Credentials are stored **encrypted**." +!!! success "GitHub Actions: Credentials are stored **encrypted**." + +## Settings Overview + +| Field | Required | Default | UI Setting Location | Description | +|:-----------|:---------|:--------|:--------------------|:------------| +| Email | **yes - if Upload=true** | `null` | `Garmin Tab` | Your Garmin email used to sign in. | +| Password | **yes - if Upload=true** | `null` | `Garmin Tab` | Your Garmin password used to sign in. **Note: Does not support `\` character in password** | +| TwoStepVerificationEnabled | no | `false` | `Garmin Tab` | Whether or not your Garmin account is protected by Two Step Verification | +| Upload | no | `false` | `Garmin Tab` | `true` indicates you wish downloaded Peloton workouts to be uploaded to Garmin Connect. | +| FormatToUpload | no | `fit` | `Garmin Tab > Advanced` | Valid values are `fit` or `tcx`. Ensure the format you specify here is also enabled in your [Format config](format.md) | \ No newline at end of file diff --git a/mkdocs/docs/configuration/index.md b/mkdocs/docs/configuration/index.md index 34068afab..9e51b94d6 100644 --- a/mkdocs/docs/configuration/index.md +++ b/mkdocs/docs/configuration/index.md @@ -1,15 +1,76 @@ # Configuration -P2G supports configuration via [command line arguments](command-line.md), [environment variables](environment-variables.md), [json config file](json.md), and via the user interface. By default, P2G looks for a file named `configuration.local.json` in the same directory where it is run. +## How are you running P2G? -## Example working configs +1. [I'm using the Web UI](#web-ui-configuration) +1. [I'm using the Windows GUI](#windows-ui-configuration) +1. [I'm using GitHub Actions](#config-file) +1. [I'm running Headless](#config-file) -1. [Headless config](https://github.com/philosowaffle/peloton-to-garmin/blob/master/configuration.example.json) -1. [WebUI configs](https://github.com/philosowaffle/peloton-to-garmin/tree/master/docker/webui) +## Web UI Configuration -## Config Precedence +Most of the most common settings can be configured via the UI itself. Additional lower level settings can be provided via config file. -The following defines the precedence in which config definitions are honored. With the first item overriding any below it. +1. Settings + 1. [App Settings](app.md) + 1. [Conversion Settings](format.md) + 1. [Peloton Settings](peloton.md) + 1. [Garmin Settings](garmin.md) +1. Low Level Settings + 1. [Api Configuration](api.md) + 1. [Web UI Configuration](webui.md) + +## Windows UI Configuration + +Most of the most common settings can be configured via the UI itself. + +1. Settings + 1. [App Settings](app.md) + 1. [Conversion Settings](format.md) + 1. [Peloton Settings](peloton.md) + 1. [Garmin Settings](garmin.md) + +## Config File + +When using a flavor of P2G that does not provide a user interface, all settings are provided via a JSON config file. + +P2G looks for a file named `configuration.local.json` in the same directory where it is run to load its settings. + +The structure of this file is as follows: + +```json +{ + "App": { /**(1)!**/ }, + "Format": { /**(2)!**/ }, + "Peloton": { /**(3)!**/ }, + "Garmin": { /**(4)!**/ }, + "Observability": { /**(5)!**/ } +} +``` + +1. Go to [App Settings Documentation](app.md) +2. Go to [Format Settings Documentation](format.md) +3. Go to [Peloton Settings Documentation](peloton.md) +4. Go to [Garmin Settings Documentation](garmin.md) +5. Go to [Observability Settings Documentation](observability.md) + +!!! tip + P2G provides an [example config](https://github.com/philosowaffle/peloton-to-garmin/blob/master/configuration.example.json) to get you started. + +## Additional Configuration Options + +P2G supports configuration via + +1. [command line arguments](#command-line-configuration) +1. [environment variables](#environment-variable-configuration) +1. [json config file](#config-file) +1. [via the user interface](#windows-ui-configuration) + +By default, P2G looks for a file named `configuration.local.json` in the same directory where it is run. + +### Config Precedence + +The following defines the precedence in which config definitions are honored. With the first items having higher precendence than the next items. 1. Command Line 1. Environment Variables @@ -20,3 +81,43 @@ For example, if you defined your Peloton credentials ONLY in the Config file, th If you defined your credentials in both the Config file AND the Environment variables, then the Environment variable credentials will be used. If you defined credentials using all 3 methods (config file, env, and command line), then the credentials provided via the command line will be used. + +### Command Line Configuration + +All of the values defined in the [Json config file](#config-file) can also be defined as command line arguments. This functionality is provided by the default dotnet [IConfiguration interface](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0#command-line-1). + +### Environment Variable Configuration + +All of the values defined in the [Json config file](#config-file) can also be defined as environment variables. This functionality is provided by the default dotnet [IConfiguration interface](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0#environment-variables-1). + +The variables use the following convention, note the use of both single and double underscores: + +```bash +P2G_CONFIGSECTION__CONFIGPROPERTY=value +``` + +#### Example App Config + +```bash +P2G_APP__WORKINGDIRECTORY +P2G_APP__ENABLEPOLLING +P2G_APP__POLLINGINTERVALSECONDS +P2G_APP__PYTHONANDGUPLOADINSTALLED +``` + +#### Example Arrays + +```bash +P2G_PELOTON__EXCLUDEWORKOUTTYPES__0="meditation" +P2G_PELOTON__EXCLUDEWORKOUTTYPES__1="stretching" +P2G_PELOTON__EXCLUDEWORKOUTTYPES__2="yoga" +...and so on +``` + +#### Example Nested Sections + +For nested config sections, continue to use the same naming convention of defining the entire json path using `__` double underscores, and 0 based indexing for array values. + +```bash +P2G_OBSERVABILITY__SERILOG__WRITETO__0__NAME="File" +``` diff --git a/mkdocs/docs/configuration/json.md b/mkdocs/docs/configuration/json.md deleted file mode 100644 index 16e7bbb7e..000000000 --- a/mkdocs/docs/configuration/json.md +++ /dev/null @@ -1,405 +0,0 @@ - -# Json Config File - -Based on your installation method, configuration may be provided via a `configuration.local.json` or it may be done via the user interface. In the below documentation you will see the information for both the JSON config file, and the Web UI. - -By default, P2G looks for a file named `configuration.local.json` in the same directory where the program is run. - -The config file is written in JSON and supports hot-reload for all fields except the following: - -1. `App.PollingintervalSeconds` -1. `Observability` Section - -The config file is organized into the below sections. - -| Section | Platforms | Description | -|:-------------|:----------|:------------------| -| [Api Config](#api-config) | Web UI | This section provides global settings for the P2G Api. | -| [WebUI Config](#webui-config) | Web UI | This section provides global settings for the P2G Web UI. | -| [App Config](#app-config) | Headless | This section provides global settings for the P2G application. | -| [Format Config](#format-config) | Headless | This section provides settings related to conversions and what formats should be created/saved. | -| [Peloton Config](#peloton-config) | Headless | This section provides settings related to fetching workouts from Peloton. | -| [Garmin Config](#garmin-config) | Headless | This section provides settings related to uploading workouts to Garmin. | -| [Observability Config](#observability-config) | All | This section provides settings related to Metrics, Logs, and Traces for monitoring purposes. | - -## Api Config - -If you aren't running the Web UI version of P2G you can ignore this section. - -This section lives in `webui.local.json`. - -```json - "Api": { - "HostUrl": "http://p2g-api:8080" - } -``` - -| Field | Required | Default | UI Setting Location | Description | -|:-----------|:---------|:--------|:--------------------|:------------| -| HostUrl | yes | `null` | none | The host and port for the Web UI to communicate with the Api. | - -### Advanced usage - -Typically this section is only needed in the `webui.local.json` so that the Web UI knows where to find the running Api. However, if you have a unique setup and need to modify the Host and Port the Api binds to, then you can also provide this config section in the `api.local.json`. - -```json - "Api": { - "HostUrl": "http://*:8080" - } -``` - -| Field | Required | Default | UI Setting Location | Description | -|:-----------|:---------|:--------|:--------------------|:------------| -| HostUrl | no | `http://localhost:8080` | none | The host and port the Api should bind to and listen on. | - -## WebUI Config - -If you aren't running the Web UI version of P2G you can ignore this section. - -You can provide this config section in the `webui.local.json`. - -```json - "WebUI": { - "HostUrl": "http://*:8080" - } -``` - -| Field | Required | Default | UI Setting Location | Description | -|:-----------|:---------|:--------|:--------------------|:------------| -| HostUrl | no | `http://localhost:8080` | none | The host and port the WebUI should bind to and listen on. | - -## App Config - -This section provides global settings for the P2G application. - -```json - "App": { - "EnablePolling": true, - "PollingIntervalSeconds": 86400, - "CheckForUpdates": true - } -``` - -| Field | Required | Default | UI Setting Location | Description | -|:-----------|:---------|:--------|:--------------------|:------------| -| EnablePolling | no | `true` | `App Tab` | `true` if you wish P2G to run continuously and poll Peloton for new workouts. | -| PollingIntervalSeconds | no | 86400 | `App Tab` | The polling interval in seconds determines how frequently P2G should check for new workouts. Be warned, that setting this to a frequency of hourly or less may get you flagged by Peloton as a bad actor and they may reset your password. The default is set to Daily. | -| CheckForUpdates | no | `true` | `App Tab` | `true` if P2G should check for updates and write a log message if a new release is available. If using the UI this message will display there as well. | - -## Format Config - -This section provides settings related to conversions and what formats should be created/saved. P2G supports converting Peloton workouts into a variety of different formats. P2G also lets you choose whether or not you wish to save a local copy when the conversion is completed. This can be useful if you wish to backup your workouts or upload them manually to a different service other than Garmin. - -```json -"Format": { - "Fit": true, - "Json": false, - "Tcx": false, - "SaveLocalCopy": false, - "IncldudeTimeInHRZones": false, - "IncludeTimeInPowerZones": false, - "DeviceInfoPath": "./deviceInfo.xml", - "Cycling": { - "PreferredLapType": "Class_Targets" - }, - "Running": { - "PreferredLapType": "Distance" - }, - "Rowing": { - "PreferredLapType": "Class_Segments" - }, - "Strength": { - "DefaultSecondsPerRep": 3 - }, - "WorkoutTitleTemplate": "{{PelotonWorkoutTitle}} with {{PelotonInstructorName}}" - } -``` - -| Field | Required | Default | UI Setting Location | Description | -|:-----------|:---------|:--------|:--------------------|:------------| -| Fit | no | `false` | `Conversion Tab` | `true` indicates you wish downloaded workouts to be converted to FIT | -| Json | no | `false` | `Conversion Tab` | `true` indicates you wish downloaded workouts to be converted to JSON. This will automatically save a local copy when enabled. | -| Tcx | no | `false` | `Conversion Tab` | `true` indicates you wish downloaded workouts to be converted to TCX | -| SaveLocalCopy | no | `false` | `Conversion > Advanced` | `true` will save any converted workouts to your specified [OutputDirectory](#app-config) | -| IncludeTimeInHRZones | no | `false` | `Conversion > Advanced` | **Only use this if you are unable to configure your Max HR on Garmin Connect.** When set to True, P2G will attempt to capture the time spent in each HR Zone per the data returned by Peloton. See [understanding custom zones](#understanding-custom-zones). -| IncludePowerInHRZones | no | `false` | `Conversion > Advanced` | **Only use this if you are unable to configure your FTP and Power Zones on Garmin Connect.** When set to True, P2G will attempt to capture the time spent in each Power Zone per the data returned by Peloton. See [understanding custom zones](#understanding-custom-zones). | -| DeviceInfoPath | no | `null` | `Conversion > Advanced` | The path to your `deviceInfo.xml` file. See [providing device info](#custom-device-info) | -| Cycling | no | `null` | none | Configuration specific to Cycling workouts. | -| Cycling.PreferredLapType | no | `Default` | `Conversion Tab` | The preferred [lap type to use](#lap-types). | -| Running | no | `null` | none | Configuration specific to Running workouts. | -| Running.PreferredLapType | no | `Default` | `Conversion Tab` | The preferred [lap type to use](#lap-types). | -| Rowing | no | `null` | none | Configuration specific to Rowing workouts. | -| Rowing.PreferredLapType | no | `Default` | `Conversion Tab` | The preferred [lap type to use](#lap-types). | -| Strength | no | `null` | `Conversion Tab` | Configuration specific to Strength workouts. | -| Strength.DefaultSecondsPerRep | no | `3` | `Conversion Tab` | For exercises that are done for time instead of reps, P2G can estimate how many reps you completed using this value. Ex. If `DefaultSecondsPerRep=3` and you do Curls for 15s, P2G will estimate you completed 5 reps. | -| WorkoutTitleTemplate | no | `{{PelotonWorkoutTitle}} with {{PelotonInstructorName}}` | `Conversion Tab` | Allows you to customize how your workout title will appear in Garmin Connect using [Handlebars templates](https://github.com/Handlebars-Net/Handlebars.Net). [Read More...](#workout-title-templating) | - -### Understanding Custom Zones - -Garmin Connect expects that users have a registered device and they expect users have set up their HR and Power Zones on that device. However, this presents a problem if you either: - -* A) do not have a device capable of tracking Power -* B) do not have a Garmin device at all. - -The most common scenario for Peloton users is scenario `A`, where they do not own a Power capable Garmin device and therefore are not able to configure their Power Zones in Garmin Connect. If you do not have Power or HR zones configured in Garmin Connect then you are not able to view accurate `Time In Zones` charts for a given workout. - -P2G provides a work around for this by optionally enriching the workout with the `Time In Zones` data with one caveat: the chart will not display the range value for the zone. - -![Example Cycling Workout](https://github.com/philosowaffle/peloton-to-garmin/blob/master/images/missing_zone_values.png?raw=true "Example Missing Zone Values") - -This is only available when generating and uploading the [FIT](#garmin-config) format. - -### Custom Device Info - -By default, P2G using a custom device when converting and upload workouts. This device information is needed in order to count your Peloton workouts towards Challenges and Badges on Garmin. However, you may observe on Garmin Connect that your Peloton workouts will show a device image that does not match your personal device. - -If you choose, you can provide P2G with your personal Device Info which will cause the Garmin workout to show the correct to device. Note, **this is completely optional and is only for cosmetic preference**, your workout will be converted, uploaded, and counted towards challenges regardless of whether this matches your personal device. - -See [configuring device info](providing-device-info.md) for detailed steps on how to create your `deviceInfo.xml`. - -### Lap Types - -P2G supports several different strategies for creating Laps in Garmin Connect. If a certain strategy is not available P2G will attempt to fallback to a different strategy. You can override this behavior by specifying your preferred Lap type in the config. When `PreferredLapType` is set, P2G will first attempt to generate your preferred type and then fall back to the default behavior if it is unable to. By default P2G will: - -1. First try to create laps based on `Class_Targets` -1. Then try to create laps based on `Class_Segments` -1. Finally fallback to create laps based on `Distance` - -| Strategy | Config Value | Description | -|:----------|:-------------|:------------| -| Class Targets | `Class_Targets` | If the Peloton data includes Target Cadence information, then laps will be created to match any time the Target Cadence changed. You must use this strategy if you want the Target Cadence to show up in Garmin on the Cadence chart. | -| Class Segments | `Class_Segments` | If the Peloton data includes Class Segment information, then laps will be created to match each segment: Warm Up, Cycling, Weights, Cool Down, etc. | -| Distance | `Distance` | P2G will caclulate Laps based on distance for each 1mi, 1km, or 500m (for Row only) based on your distance setting in Peloton. | - -### Workout Title Templating - -Some characters are not allowed to be used in the workout titles. If you use these characters in your configuration they will automatically be replaced with `-`. Additionally, Garmin has a limit on how long a title will be. If the title exceeds this limit (~45 characters) then the title will be truncated. - -The below data fields are available for use in the template: - -* `PelotonWorkoutTitle` - Peloton provides this usually in the form of "10 min HITT Ride" -* `PelotonInstructorName` - Peloton provides this as the full instructors name: "Ally Love" - -**Note:** - -For this setting to take effect, your Garmin Connect account must be set to allow custom workout names. In the Garmin Connect web interface click on the user icon in the top right, select `Account Settings` then `Display Preferences` ([shortcut](https://connect.garmin.com/modern/settings/displayPreferences)). -Change the `Activity Name` setting to `Workout Name (when available)`. This will allow the custom workout name to sync, and should still allow the standard behavior when syncing non-P2G activities directly. - -## Peloton Config - -This section provides settings related to fetching workouts from Peloton. - -```json -"Peloton": { - "Email": "peloton@gmail.com", - "Password": "peloton", - "NumWorkoutsToDownload": 1, - "ExcludeWorkoutTypes": [ "meditation" ] - } -``` - -!!! warning - - Console or Docker Headless: Your username and password for Peloton and Garmin Connect are stored in clear text, which **is not secure**. Please be aware of the risks. - - -!!! success "WebUI version 3.3.0: Credentials are stored **encrypted**." - -!!! success "GitHub Actions: Credentials are stored **encrypted**." - -| Field | Required | Default | UI Setting Location | Description | -|:-----------|:---------|:--------|:--------------------|:------------| -| Email | **yes** | `null` | `Peloton Tab` | Your Peloton email used to sign in | -| Password | **yes** | `null` | `Peloton Tab` | Your Peloton password used to sign in. **Note: Does not support `\` character in password** | -| NumWorkoutsToDownload | no | 5 | `Peloton Tab` | The default number of workouts to download. See [choosing number of workouts to download](#choosing-number-of-workouts-to-download). Set this to `0` if you would like P2G to prompt you each time for a number to download. | -| ExcludeWorkoutTypes | no | none | `Peloton Tab` | A comma separated list of workout types that you do not want P2G to download/convert/upload. See [example use cases](#exclude-workout-types) below. | - -### Choosing Number of Workouts To Download - -When choosing the number of workouts P2G should download each polling cycle its important to keep your configured [PollingInterval](#app-config) in mind. If, for example, your polling interval is set to hourly, then you may want to set `NumWorkoutsToDownload` to 4 or greater. This ensures if you did four 15min workouts during that hour they would all be captured. - -### Exclude Workout Types - -Example use cases: - -1. You take a wide variety of Peloton classes, including meditation and you want to skip uploading meditation classes. -1. You want to avoid double-counting activities you already track directly on a Garmin device, such as outdoor running workouts. - -The available values are: - -```json - Cycling - Outdoor Cycling - BikeBootcamp - TreadmillRunning - OutdoorRunning - TreadmillWalking - OutdoorWalking - Cardio - Circuit - Strength - Stretching - Yoga - Meditation -``` - -## Garmin Config - -This section provides settings related to uploading workouts to Garmin. - -```json -"Garmin": { - "Email": "garmin@gmail.com", - "Password": "garmin", - "TwoStepVerificationEnabled": false, - "Upload": false, - "FormatToUpload": "fit" - } -``` - -!!! warning - - Console or Docker Headless: Your username and password for Peloton and Garmin Connect are stored in clear text, which **is not secure**. Please be aware of the risks. - -!!! success "WebUI version 3.3.0: Credentials are stored **encrypted**." - -!!! success "GitHub Actions: Credentials are stored **encrypted**." - -| Field | Required | Default | UI Setting Location | Description | -|:-----------|:---------|:--------|:--------------------|:------------| -| Email | **yes - if Upload=true** | `null` | `Garmin Tab` | Your Garmin email used to sign in | -| Password | **yes - if Upload=true** | `null` | `Garmin Tab` | Your Garmin password used to sign in. **Note: Does not support `\` character in password** | -| TwoStepVerificationEnabled | no | `false` | `Garmin Tab` | Whether or not your Garmin account is protected by Two Step Verification | -| Upload | no | `false` | `Garmin Tab` | `true` indicates you wish downloaded workouts to be automatically uploaded to Garmin for you. | -| FormatToUpload | no | `fit` | `Garmin Tab > Advanced` | Valid values are `fit` or `tcx`. Ensure the format you specify here is also enabled in your [Format config](#format-config) | - -## Observability Config - -P2G supports publishing OpenTelemetry Metrics, Logs, and Trace. This section provides settings related to those pillars. - -The Observability config section contains three main sub-sections: - -1. [Prometheus](#prometheus-config) - Metrics -1. [Jaeger](#jaeger-config) - Traces -1. [Serilog](#serilog-config) - Logs - -```json -"Observability": { - - "Prometheus": { - "Enabled": false, - "Port": 4000 - }, - - "Jaeger": { - "Enabled": false, - "AgentHost": "localhost", - "AgentPort": 6831 - }, - - "Serilog": { - "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], - "MinimumLevel": "Information", - "WriteTo": [ - { "Name": "Console" }, - { - "Name": "File", - "Args": { - "path": "./output/log.txt", - "rollingInterval": "Day", - "retainedFileCountLimit": 7 - } - } - ] - } - } -``` - -### Prometheus Config - -```json -"Prometheus": { - "Enabled": false, - "Port": 4000 - } -``` - -| Field | Required | Default | Description | -|:-----------|:---------|:--------|:------------| -| Enabled | no | `false` | Whether or not to expose metrics. Metrics will be available at `http://localhost:{port}/metrics` | -| Port | no | `80` | The port the metrics endpoint should be served on. Only valid for Console mode, not Api/WebUI | - -If you are using Docker, ensure you have exposed the port from your container. - -#### Example Prometheus scraper config - -```yaml -- job_name: 'p2g' - scrape_interval: 60s - static_configs: - - targets: [:] - tls_config: - insecure_skip_verify: true -``` - -### Jaeger Config - -```json -"Jaeger": { - "Enabled": false, - "AgentHost": "localhost", - "AgentPort": 6831 - } -``` - -| Field | Required | Default | Description | -|:-----------|:---------|:--------|:------------| -| Enabled | no | `false` | Whether or not to generate traces. | -| AgentHost | **yes - if Enalbed=true** | `null` | The host address for your trace collector. | -| AgentPort | **yes - if Enabled=true** | `null` | The port for your trace collector. | - -### Serilog Config - -```json -"Serilog": { - "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Grafana.Loki" ], - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Error", - "System": "Error" - } - }, - "WriteTo": [ - { "Name": "Console" }, - { - "Name": "File", - "Args": { - "path": "./output/log.txt", - "rollingInterval": "Day", - "retainedFileCountLimit": 7 - } - }, - { - "Name": "GrafanaLoki", - "Args": { - "uri": "http://192.168.1.95:3100", - "textFormatter": "Serilog.Sinks.Grafana.Loki.LokiJsonTextFormatter, Serilog.Sinks.Grafana.Loki", - "labels": [ - { - "key": "app", - "value": "p2g" - } - ] - } - }] -} -``` - -| Field | Required | Default | Description | -|:-----------|:---------|:--------|:------------| -| Using | no | `null` | A list of sinks you would like use. The valid sinks are listed in the examplea above. | -| MinimumLevel | no | `null` | The minimum level to write. `[Verbose, Debug, Information, Warning, Error, Fatal]` | -| WriteTo | no | `null` | Additional config for various sinks you are writing to. | - -More detailed information about configuring Logging can be found on the [Serilog Config Repo](https://github.com/serilog/serilog-settings-configuration#serilogsettingsconfiguration--). diff --git a/mkdocs/docs/configuration/observability.md b/mkdocs/docs/configuration/observability.md new file mode 100644 index 000000000..27a68cc35 --- /dev/null +++ b/mkdocs/docs/configuration/observability.md @@ -0,0 +1,130 @@ + +# Observability File Configuration + +!!! tip + + These are advanced settings for those who like to play around with Logs, Metrics, and Traces. + +## Overview + +P2G supports publishing Open Telemetry metrics. P2G publishes the following: + +1. Logs via Serilog. Logs can be sunk to a variety of sources including Console, File, ElasticSearch, and Grafana Loki. +1. Metrics via Prometheus. Metrics are exposed on a standard `/metrics` endpoint and the port is configurable. +1. Traces via Jaeger. Traces can be collected via an agent of your choice. Some options include Jaeger Agent/Jaeger Query, or Grafana Tempo. +1. P2G also provides a sample Grafana dashboard which can be found [in the repository](https://github.com/philosowaffle/peloton-to-garmin/tree/master/grafana). + +The grafana dashboard assumes you have the following datasources setup but can be easily modified to meet your needs: + +1. Prometheus +1. Loki +1. If running as a docker image a docker metrics exporter + +![Grafana Dashboard](https://github.com/philosowaffle/peloton-to-garmin/raw/master/images/grafana_dashboard.png?raw=true "Grafana Dashboard") + +## Observability Config + +P2G looks for a file named `configuration.local.json` in the same directory where it is run. Within this file, P2G supports configuring an `Observability` section, as seen below. + +```json +"Observability": { + + "Prometheus": { /**(1)!**/ }, // Metrics + "Jaeger": { /**(2)!**/ }, // Traces + "Serilog": { /**(3)!**/ } // Logs + } +``` + +1. Jump to [Prometheus Config Documentation](#prometheus-config) +2. Jump to [Jaeger Config Documentation](#jaeger-config) +3. Jump to [Serilog Config Documentation](#serilog-config) + +### Prometheus Config + +```json +"Prometheus": { + "Enabled": false, + "Port": 4000 + } +``` + +| Field | Required | Default | Description | +|:-----------|:---------|:--------|:------------| +| Enabled | no | `false` | Whether or not to expose metrics. Metrics will be available at `http://localhost:{port}/metrics` | +| Port | no | `80` | The port the metrics endpoint should be served on. Only valid for Console mode, not Api/WebUI | + +!!! tip + If you are using Docker, ensure you have exposed the port from your container. + +#### Example Prometheus scraper config + +```yaml +- job_name: 'p2g' + scrape_interval: 60s + static_configs: + - targets: [:] + tls_config: + insecure_skip_verify: true +``` + +### Jaeger Config + +```json +"Jaeger": { + "Enabled": false, + "AgentHost": "localhost", + "AgentPort": 6831 + } +``` + +| Field | Required | Default | Description | +|:-----------|:---------|:--------|:------------| +| Enabled | no | `false` | Whether or not to generate traces. | +| AgentHost | **yes - if Enalbed=true** | `null` | The host address for your trace collector. | +| AgentPort | **yes - if Enabled=true** | `null` | The port for your trace collector. | + +### Serilog Config + +```json +"Serilog": { + "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Grafana.Loki" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Error", + "System": "Error" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "./output/log.txt", + "rollingInterval": "Day", + "retainedFileCountLimit": 7 + } + }, + { + "Name": "GrafanaLoki", + "Args": { + "uri": "http://192.168.1.95:3100", + "textFormatter": "Serilog.Sinks.Grafana.Loki.LokiJsonTextFormatter, Serilog.Sinks.Grafana.Loki", + "labels": [ + { + "key": "app", + "value": "p2g" + } + ] + } + }] +} +``` + +| Field | Required | Default | Description | +|:-----------|:---------|:--------|:------------| +| Using | no | `null` | A list of sinks you would like use. The valid sinks are listed in the examplea above. | +| MinimumLevel | no | `null` | The minimum level to write. `[Verbose, Debug, Information, Warning, Error, Fatal]` | +| WriteTo | no | `null` | Additional config for various sinks you are writing to. | + +More detailed information about configuring Logging can be found on the [Serilog Config Repo](https://github.com/serilog/serilog-settings-configuration#serilogsettingsconfiguration--). \ No newline at end of file diff --git a/mkdocs/docs/configuration/peloton.md b/mkdocs/docs/configuration/peloton.md new file mode 100644 index 000000000..11798bb32 --- /dev/null +++ b/mkdocs/docs/configuration/peloton.md @@ -0,0 +1,55 @@ +# Peloton Settings + +The Peloton Settings provide settings related to how P2G should fetch workouts from Peloton. + +## Settings location + +| Run Method | Location | +|------------|----------| +| Web UI | UI > Settings > Peloton Tab | +| Windows Exe | UI > Settings > Peloton Tab | +| GitHubAction | Config Section in Workflow | +| Headless (Docker or Console) | Config section in `configuration.local.json` | + +## File Configuration + +```json +"Peloton": { + "Email": "peloton@gmail.com", + "Password": "peloton", + "NumWorkoutsToDownload": 1, + "ExcludeWorkoutTypes": [ "meditation" ] + } +``` + +!!! warning + Console or Docker Headless: Your username and password for Peloton and Garmin Connect are stored in clear text, which **is not secure**. Please be aware of the risks. +!!! success "WebUI version 3.3.0+: Credentials are stored **encrypted**." +!!! success "Windows Exe version 4.0.0+: Credentials are stored **encrypted**." +!!! success "GitHub Actions: Credentials are stored **encrypted**." + +## Settings Overview + +| Field | Required | Default | Description | +|:-----------|:---------|:--------|:------------| +| Email | **yes** | `null` | Your Peloton email used to sign in | +| Password | **yes** | `null` | Your Peloton password used to sign in. **Note: Does not support `\` character in password** | +| NumWorkoutsToDownload | no | 5 | The default number of workouts to download. See [choosing number of workouts to download](#choosing-number-of-workouts-to-download). Set this to `0` if you would like P2G to prompt you each time for a number to download. | +| ExcludeWorkoutTypes | no | none | A comma separated list of workout types that you do not want P2G to download/convert/upload. [Read more...](#exclude-workout-types) | + +## Choosing Number of Workouts To Download + +When choosing the number of workouts P2G should download each polling cycle its important to keep your configured [Polling Interval](app.md) in mind. If, for example, your polling interval is set to hourly, then you may want to set `NumWorkoutsToDownload` to 4 or greater. This ensures if you did four 15min workouts during that hour they would all be captured. + +Garmin is capable of rejecting duplicate workouts, so it is safe for P2G to attempt to sync a workout that may have been previously synced. + +## Exclude Workout Types + +If there are [Exercise Types](exercise-types.md) that you do not want P2G to sync, then you can specify those in the settings. + +Some example use cases include: + +1. You take a wide variety of Peloton classes, including meditation and you want to skip uploading meditation classes. +1. You want to avoid double-counting activities you already track directly on a Garmin device, such as outdoor running workouts. + +The list of valid values are any [Exercise Type](exercise-types.md). diff --git a/mkdocs/docs/configuration/providing-device-info.md b/mkdocs/docs/configuration/providing-device-info.md deleted file mode 100644 index 0ca78a9e7..000000000 --- a/mkdocs/docs/configuration/providing-device-info.md +++ /dev/null @@ -1,57 +0,0 @@ - -# Device Info - -A given workout must be associated with a Garmin device in order for it to count towards Challenges and Badges on Garmin. Additionaly, certain devices also unlock additional data fields and measurements on Garmin Connect. The default devices used by P2G have been chosen specifically to ensure you get the most data possible out of your Peloton workouts. - -By default, P2G uses the TACX App device type for Cycling activities. At this time, TACX is the only device that is known to unlock the cycling VO2 Max calculation on Garmin Connect. For all other workout types, P2G defaults to using a Fenix 6 device. - -This means on Garmin Connect your Peloton workouts will show a device image that does not match your personal Garmin device. - -## VO2 Max - -Garmin _unlocks_ certain workout metrics and fields based on the Garmin device you personally own, one of those metrics is VO2 Max. This means that if your personal device supports VO2 Max calucations, then your Peloton workouts will also generate VO2 Max when using the default P2G device settings. If your personal device does not support VO2 Max calculations, then unfortunately your Peloton workouts will also not generate any VO2 Max data. - -You can check the [Owners Manual](https://support.garmin.com/en-US/ql/?focus=manuals) for your personal device to see if it already supports the VO2 max field. - -## Custom Device Info - -If you choose, you can provide P2G with your personal Device Info which will cause the workouts to show the same device you normally use. - -**Note:** - -* Setting your personal device is completely optional, P2G will work just fine without this extra information -* Setting your personal device *may* cause you to not see certain fields on Garmin (see notes about VO2 max above) -* Setting your personal device will mean it is applied on **all** workout types from Peloton - -### Steps - -1. Get your Garmin current device info - 1. Log on to Garmin connect and find an activity you recorded with your device - 1. In the upper right hand corner of the activity, click the gear icon and choose `Export to TCX` - 1. A TCX file will be downloaded to your computer -1. Prepare your device info for P2G - 1. Find the TCX file you downloaded in part 1 and open it in any text editor. - 1. Use `ctrl-f` (`cmd-f` on mac) to find the ``, delete the `xsi...` so that the final structure matches the example below - 1. Save the file as `deviceInfo.xml` -1. Configure P2G to use the device info file - 1. Move your prepared `deviceInfo.xml` file so that it is in your P2G folder - 1. Modify the [DeviceInfoPath](json.md#format-config) to point to the location of your `deviceInfo.xml` - 1. If you are using Docker, ensure you have mounted the files location into the container - -### Example - -```xml - - Garmin Sample Device - please create from exported TCX file - 00000000000 - 0000 - - 0 - 0 - 0 - 0 - - -``` diff --git a/mkdocs/docs/configuration/webui.md b/mkdocs/docs/configuration/webui.md new file mode 100644 index 000000000..0ed0df336 --- /dev/null +++ b/mkdocs/docs/configuration/webui.md @@ -0,0 +1,50 @@ +# Web UI File Configuration + +!!! tip + + These settings only apply if you are running an Instance of the Web UI. P2G provides some [recommended config files](https://github.com/philosowaffle/peloton-to-garmin/tree/master/docker/webui) to get you started. + +Some lower level configuration cannot be provided via the web user interface and can only be provided by config file. + +The Web UI looks for a file named `configuration.local.json` in the same directory where it is run. Below is an example of the structure of this config file. + +```json linenums="1" +{ + "Api": { /** (1)! **/ }, + "WebUI": { /** (2)! **/ }, + "Observability": { /** (3)! **/ } +} +``` + +1. Jump to [Api Config Documentation](#api-config) +2. Jump to [Web UI Config Documentation](#web-ui-config) +3. Go to [Observability Config Documentation](observability.md#observability-config) + +## Api Config + +This section helps inform the Web UI where to find the P2G Api. + +```json + "Api": { + "HostUrl": "http://p2g-api:8080" + } +``` + +| Field | Required | Default | UI Setting Location | Description | +|:-----------|:---------|:--------|:--------------------|:------------| +| HostUrl | yes | `null` | none | The host and port for the Web UI to communicate with the Api. | + +## Web UI Config + +!!! warning + Optional - most users should not need to change this setting. + +```json + "WebUI": { + "HostUrl": "http://*:8080" + } +``` + +| Field | Required | Default | UI Setting Location | Description | +|:-----------|:---------|:--------|:--------------------|:------------| +| HostUrl | no | `http://localhost:8080` | none | The host and port the WebUI should bind to and listen on. | \ No newline at end of file diff --git a/mkdocs/docs/faq.md b/mkdocs/docs/faq.md index ac4ddb017..02c588047 100644 --- a/mkdocs/docs/faq.md +++ b/mkdocs/docs/faq.md @@ -4,30 +4,16 @@ Below are a list of commonly asked questions. For even more help head on over to ## VO2 Max and TSS +Garmin _unlocks_ certain workout metrics and fields based on the Garmin device you personally own, one of those metrics is VO2 Max. This means that if your personal device supports VO2 Max calucations, then your Peloton workouts will also generate VO2 Max when using the default P2G device settings. If your personal device does not support VO2 Max calculations, then unfortunately your Peloton workouts will also not generate any VO2 Max data. + +You can check the [Owners Manual](https://support.garmin.com/en-US/ql/?focus=manuals) for your personal device to see if it already supports the VO2 max field. + Garmin will only generate a VO2 max for your workouts if all of the following criteria are met: 1. Your personal Garmin device already supports VO2 Max Calculations -1. You have not configured a [custom device info file](configuration/providing-device-info.md) (i.e. you are using the defaults) -1. You have met all of [Garmin's VO2 requirements](https://support.garmin.com/en-SG/?faq=MyIZ05OMpu6wSl95UVUjp7) for your workout type +1. The workout is associated with an [allowed device](https://support.garmin.com/en-US/?faq=lWqSVlq3w76z5WoihLy5f8) +1. You have met all of [Garmin's VO2 requirements](https://support.garmin.com/en-US/?faq=lWqSVlq3w76z5WoihLy5f8) for your workout type ## Garmin Two Step Verification Only some [install options have support](install/index.md) for Garmin Two Step Verification. In all cases, automatic-syncing is never supported when your Garmin account is protected by two step verification. - -## Garmin Upload Not Working - -Sometimes, auth failures with Garmin are only temporary. For this reason, a failure to upload will not kill your sync job. Instead, P2G will stage the files and try to upload them again on the next sync interval. You can also always manually upload your files, you do not need to worry about duplicates. - -If the problem persists head on over to the [discussion forum](https://github.com/philosowaffle/peloton-to-garmin/discussions) to see if others are experiencing the same issue and to track any open issues. - -## My config file is not working - -1. Windows Executable - Ensure you config is saved in a file named `configuration.local.json` that is in the same directory as the executable. -1. Docker - Ensure you are correctly passing in or mounting a config file named `configuration.local.json` -1. Verify your config file is in valid json format. You can copy paste the contents into this [online tool](https://jsonlint.com/?code=) to verify. - -If the problem persists head on over to the [discussion forum](https://github.com/philosowaffle/peloton-to-garmin/discussions) to get help. - -## My Zones are missing the range values in Garmin Connect - -See [Understanding custom zones](configuration/json.md#understanding-custom-zones). diff --git a/mkdocs/docs/install/docker.md b/mkdocs/docs/install/docker.md index 181dc5faf..efb3c2b6d 100644 --- a/mkdocs/docs/install/docker.md +++ b/mkdocs/docs/install/docker.md @@ -1,7 +1,7 @@ # Docker -The recommended installation method is with Docker. If you're not familiar with Docker but would like to try it check out the [quick start guide](#quick-start-guide). +The recommended installation method is with Docker. If you're not familiar with Docker you can [learn more here](#more-about-docker). P2G offers two main flavors of docker images: @@ -47,7 +47,7 @@ The P2G images run the process under the user and group `p2g:p2g` with uid and g 1. Create a group on the local machine `p2g` with group id `1015` 1. Add your user on the local machine to the `p2g` group -## Quick Start Guide +## More about Docker Docker provides an easy and consistent way to install, update, and uninstall applications across multiple Operating Systems. Docker is extremely popular in the self-hosted community, a group interested in minimizing dependencies on Cloud providers in favor of attempting to keep their data local, private, and free. You can learn more about the ever growing list of self-hosted applications on the [awesome-selfhosted list](https://github.com/awesome-selfhosted/awesome-selfhosted). diff --git a/mkdocs/docs/migration/migrate-v1-v2.md b/mkdocs/docs/migration/migrate-v1-v2.md index 1f2f378cb..b7b95b8b8 100644 --- a/mkdocs/docs/migration/migrate-v1-v2.md +++ b/mkdocs/docs/migration/migrate-v1-v2.md @@ -33,9 +33,9 @@ WorkoutTypes = cycling, strength | Property | New Config | Notes | |:-------------|:------------------|-------| -| Email | [Peloton Config](../configuration/json.md#peloton-config).Email | | -| Password | [Peloton Config](../configuration/json.md#peloton-config).Password | | -| WorkoutTypes | [Peloton Config](../configuration/json.md#peloton-config).ExcludeWorkoutTypes | In v1 this was a list of workout types to **include**, in v2 this changes to a list of workout types to **exclude**. | +| Email | [Peloton Config](../configuration/peloton.md).Email | | +| Password | [Peloton Config](../configuration/peloton.md).Password | | +| WorkoutTypes | [Peloton Config](../configuration/peloton.md).ExcludeWorkoutTypes | In v1 this was a list of workout types to **include**, in v2 this changes to a list of workout types to **exclude**. | #### Garmin section @@ -48,9 +48,9 @@ Password = garminPassword | Property | New Config | Notes | |:-------------|:------------------|-------| -| Email | [Garmin Config](../configuration/json.md#garmin-config).Email | | -| Password | [Garmin Config](../configuration/json.md#garmin-config).Password | | -| UploadEnabled | [Garmin Config](../configuration/json.md#peloton-config).Upload | You will additionally need to specify `FormatToUpload` if you have this enabled. | +| Email | [Garmin Config](../configuration/garmin.md).Email | | +| Password | [Garmin Config](../configuration/garmin.md).Password | | +| UploadEnabled | [Garmin Config](../configuration/garmin.md).Upload | You will additionally need to specify `FormatToUpload` if you have this enabled. | #### PTOG Section @@ -62,8 +62,8 @@ PollingIntervalSeconds = 600 | Property | New Config | Notes | |:-------------|:------------------|-------| -| EnablePolling | [App Config](../configuration/json.md#app-config).EnablePolling | | -| PollingIntervalSeconds | [App Config](../configuration/json.md#app-config).PollingIntervalSeconds | | +| EnablePolling | [App Config](../configuration/app.md).EnablePolling | | +| PollingIntervalSeconds | [App Config](../configuration/app.md).PollingIntervalSeconds | | #### Output Section @@ -81,12 +81,12 @@ ArchiveByType = true | Property | New Config | Notes | |:-------------|:------------------|-------| | Directory | none | | -| WorkingDirectory | [App Config](../configuration/json.md#app-config).WorkingDirectory | | -| ArchiveDirectory | [App Config](../configuration/json.md#app-config).OutputDirectory | | -| RetainFiles | [Format Config](../configuration/json.md#app-config).SaveLocalCopy | | +| WorkingDirectory | [App Config](../configuration/app.md).WorkingDirectory | | +| ArchiveDirectory | [App Config](../configuration/app.md).OutputDirectory | | +| RetainFiles | [Format Config](../configuration/app.md).SaveLocalCopy | | | ArchiveFiles | none | | | SkipDownload | none | | -| ArchiveByType | [Format Config](../configuration/json.md#app-config).[Fit,Tcx,Json] | Set the formats you want to save to true and then set `SaveLocalCopy: true` | +| ArchiveByType | [Format Config](../configuration/app.md).[Fit,Tcx,Json] | Set the formats you want to save to true and then set `SaveLocalCopy: true` | #### Logger Section @@ -98,8 +98,8 @@ LogLevel = INFO | Property | New Config | Notes | |:-------------|:------------------|-------| -| LogFile | [Observability Config](../configuration/json.md#observability-config).Serilog.WriteTo.Args.Path | | -| LogLevel | [Observability Config](../configuration/json.md#observability-config).Serilog.MinimumLevel | | +| LogFile | [Observability Config](../configuration/observability.md).Serilog.WriteTo.Args.Path | | +| LogLevel | [Observability Config](../configuration/observability.md).Serilog.MinimumLevel | | For the general use case, the below config should be sufficient. @@ -133,4 +133,4 @@ PauseOnFinish = true | Property | New Config | Notes | |:-------------|:------------------|-------| -| PauseOnFinish | [App Config](../configuration/json.md#app-config).CloseWindowOnFinish | | +| PauseOnFinish | [App Config](../configuration/app.md).CloseWindowOnFinish | | diff --git a/mkdocs/docs/observability.md b/mkdocs/docs/observability.md deleted file mode 100644 index 35e5de855..000000000 --- a/mkdocs/docs/observability.md +++ /dev/null @@ -1,17 +0,0 @@ - -# Observability - -P2G supports publishing Open Telemetry metrics. These metrics can be setup and configured in the [Observability config section](configuration/json.md#observability-config). P2G publishes the following: - -1. Logs via Serilog. Logs can be sunk to a variety of sources including Console, File, ElasticSearch, and Grafana Loki. -1. Metrics via Prometheus. Metrics are exposed on a standard `/metrics` endpoint and the port is configurable. -1. Traces via Jaeger. Traces can be collected via an agent of your choice. Some options include Jaeger Agent/Jaeger Query, or Grafana Tempo. -1. P2G also provides a sample Grafana dashboard which can be found [in the repository](https://github.com/philosowaffle/peloton-to-garmin/tree/master/grafana). - -The grafana dashboard assumes you have the following datasources setup but can be easily modified to meet your needs: - -1. Prometheus -1. Loki -1. If running as a docker image a docker metrics exporter - -![Grafana Dashboard](https://github.com/philosowaffle/peloton-to-garmin/raw/master/images/grafana_dashboard.png?raw=true "Grafana Dashboard") diff --git a/mkdocs/mkdocs.yml b/mkdocs/mkdocs.yml index cec294e5d..ac2993287 100644 --- a/mkdocs/mkdocs.yml +++ b/mkdocs/mkdocs.yml @@ -22,6 +22,8 @@ theme: - navigation.top - naviation.sections - navigation.indexes + - content.code.annotate + - content.code.copy markdown_extensions: - admonition @@ -42,8 +44,16 @@ nav: - 'GitHub Actions': install/github-action.md - Configuration: - Configuration: configuration/index.md - - 'User Interface and Json': configuration/json.md - - Observability: observability.md + - Settings: + - 'App Settings': configuration/app.md + - 'Format Settings': configuration/format.md + - 'Garmin Settings': configuration/garmin.md + - 'Peloton Settings': configuration/peloton.md + - 'Low Level Settings': + - 'Api File Configuration': configuration/api.md + - 'WebUI File Configuration': configuration/webui.md + - 'Observability File Configuration': configuration/observability.md + - 'Exercise Types': configuration/exercise-types.md - Help: - Help: help.md - 'F.A.Q': faq.md diff --git a/src/ClientUI/MainPage.xaml.cs b/src/ClientUI/MainPage.xaml.cs index 3615c0fa5..0e4274576 100644 --- a/src/ClientUI/MainPage.xaml.cs +++ b/src/ClientUI/MainPage.xaml.cs @@ -1,10 +1,29 @@ -namespace ClientUI +using Common.Database; + +namespace ClientUI { public partial class MainPage : ContentPage { public MainPage() { InitializeComponent(); + + IServiceProvider serviceProvider = null; +#if WINDOWS10_0_17763_0_OR_GREATER + serviceProvider = MauiWinUIApplication.Current.Services; +#elif ANDROID + serviceProvider = MauiApplication.Current.Services; +#elif IOS || MACCATALYST + serviceProvider = MauiUIApplicationDelegate.Current.Services; +#else + serviceProvider = null; +#endif + + /////////////////////////////////////////////////////////// + /// MIGRATIONS + /////////////////////////////////////////////////////////// + var migrationService = serviceProvider.GetService(); + migrationService!.MigrateDeviceInfoFileToListAsync().GetAwaiter().GetResult(); } } } \ No newline at end of file diff --git a/src/ClientUI/MauiProgram.cs b/src/ClientUI/MauiProgram.cs index e4d6a4dab..deb9dac86 100644 --- a/src/ClientUI/MauiProgram.cs +++ b/src/ClientUI/MauiProgram.cs @@ -4,6 +4,7 @@ using Common.Stateful; using Microsoft.Extensions.Configuration; using SharedStartup; +using Common.Database; namespace ClientUI; diff --git a/src/Common/Database/DbMigrations.cs b/src/Common/Database/DbMigrations.cs index 3c030958a..6acbaee5f 100644 --- a/src/Common/Database/DbMigrations.cs +++ b/src/Common/Database/DbMigrations.cs @@ -1,6 +1,9 @@ -using Common.Observe; +using Common.Dto; +using Common.Dto.Garmin; +using Common.Observe; using Serilog; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -8,7 +11,8 @@ namespace Common.Database; public interface IDbMigrations { - public Task PreformMigrations(); + Task PreformMigrations(); + Task MigrateDeviceInfoFileToListAsync(); } public class DbMigrations : IDbMigrations @@ -18,20 +22,26 @@ public class DbMigrations : IDbMigrations private readonly ISettingsDb _settingsDb; private readonly IUsersDb _usersDb; private readonly ISyncStatusDb _syncStatusDb; + private readonly IFileHandling _fileHandler; - public DbMigrations(ISettingsDb settingsDb, IUsersDb usersDb, ISyncStatusDb syncStatusDb) + public DbMigrations(ISettingsDb settingsDb, IUsersDb usersDb, ISyncStatusDb syncStatusDb, IFileHandling fileHandler) { _settingsDb = settingsDb; _usersDb = usersDb; _syncStatusDb = syncStatusDb; + _fileHandler = fileHandler; } public async Task PreformMigrations() { await MigrateToAdminUserAsync(); await MigrateToEncryptedCredentialsAsync(); + await MigrateDeviceInfoFileToListAsync(); } + /// + /// P2G 3.3.0 + /// public async Task MigrateToAdminUserAsync() { #pragma warning disable CS0612 // Type or member is obsolete @@ -73,6 +83,9 @@ public async Task MigrateToAdminUserAsync() #pragma warning restore CS0612 // Type or member is obsolete } + /// + /// P2G 3.3.0 + /// public async Task MigrateToEncryptedCredentialsAsync() { var admin = (await _usersDb.GetUsersAsync()).First(); @@ -95,4 +108,54 @@ public async Task MigrateToEncryptedCredentialsAsync() } } + + /// + /// P2G 4.2.0 + /// + public async Task MigrateDeviceInfoFileToListAsync() + { + var admin = (await _usersDb.GetUsersAsync()).First(); + var settings = await _settingsDb!.GetSettingsAsync(admin.Id); + +#pragma warning disable CS0618 // Type or member is obsolete + if (string.IsNullOrWhiteSpace(settings.Format.DeviceInfoPath)) + return; + + _logger.Information($"[MIGRATION] Migrating {settings.Format.DeviceInfoPath} to new settings format."); + + try + { + GarminDeviceInfo deviceInfo = null; + _fileHandler.TryDeserializeXml(settings.Format.DeviceInfoPath, out deviceInfo); + + if (deviceInfo != null) + { + settings.Format.DeviceInfoSettings.Clear(); + settings.Format.DeviceInfoSettings.Add(WorkoutType.None, deviceInfo); + settings.Format.DeviceInfoPath = null; + + await _settingsDb.UpsertSettingsAsync(admin.Id, settings); + } + else + { + _logger.Warning($"[MIGRATION] Failed to parse {settings.Format.DeviceInfoPath}, migrating to P2G default device settings instead."); + settings.Format.DeviceInfoSettings = new Dictionary() + { + { WorkoutType.None, GarminDevices.Forerunner945 }, + { WorkoutType.Cycling, GarminDevices.TACXDevice }, + { WorkoutType.Rowing, GarminDevices.EpixDevice }, + }; + settings.Format.DeviceInfoPath = null; + + await _settingsDb.UpsertSettingsAsync(admin.Id, settings); + } + + _logger.Information($"[MIGRATION] Successfully migrated {settings.Format.DeviceInfoPath} to new settings format."); + + } catch (Exception e) + { + _logger.Error(e, $"[MIGRATION] Failed to migrated {settings.Format.DeviceInfoPath} to new settings format."); + } +#pragma warning restore CS0618 // Type or member is obsolete + } } \ No newline at end of file diff --git a/src/Common/Database/SyncStatusDb.cs b/src/Common/Database/SyncStatusDb.cs index 99ca92f49..9dc8fc5a9 100644 --- a/src/Common/Database/SyncStatusDb.cs +++ b/src/Common/Database/SyncStatusDb.cs @@ -24,7 +24,24 @@ public class SyncStatusDb : DbBase, ISyncStatusDb public SyncStatusDb(IFileHandling fileHandling) : base("SyncStatus", fileHandling) { - _db = new DataStore(DbPath); + _db = new DataStore(DbPath); + Init(); + } + + private void Init() + { + try + { + var settings = _db.GetItem("1"); + } + catch (KeyNotFoundException) + { + var success = _db.InsertItem("1", _defaultSyncServiceStatus); + if (!success) + { + _logger.Error($"Failed to init default Sync Status to Db for default user."); + } + } } public Task DeleteLegacySyncStatusAsync() diff --git a/src/Common/Dto/Garmin/GarminDeviceInfo.cs b/src/Common/Dto/Garmin/GarminDeviceInfo.cs index 1b67a508b..8b48ba9c0 100644 --- a/src/Common/Dto/Garmin/GarminDeviceInfo.cs +++ b/src/Common/Dto/Garmin/GarminDeviceInfo.cs @@ -6,7 +6,7 @@ public class GarminDeviceInfo public uint UnitId { get; set; } public ushort ProductID { get; set; } public ushort ManufacturerId { get; set; } = 1; - public GarminDeviceVersion Version { get; set; } + public GarminDeviceVersion Version { get; set; } = new (); } public class GarminDeviceVersion @@ -16,4 +16,52 @@ public class GarminDeviceVersion public int BuildMajor { get; set; } public double BuildMinor { get; set; } } + + public static class GarminDevices + { + public static readonly GarminDeviceInfo TACXDevice = new GarminDeviceInfo() + { + Name = "TacxTrainingAppWin", // Max 20 Chars + ProductID = 20533, // GarminProduct.TacxTrainingAppWin, + UnitId = 1, + ManufacturerId = 89, // Tacx + Version = new GarminDeviceVersion() + { + VersionMajor = 1, + VersionMinor = 30, + BuildMajor = 0, + BuildMinor = 0, + } + }; + + public static readonly GarminDeviceInfo EpixDevice = new GarminDeviceInfo() + { + Name = "Epix", // Max 20 Chars + ProductID = 3943, // GarminProduct.EpixGen2, + UnitId = 3413684246, + ManufacturerId = 1, // Garmin + Version = new GarminDeviceVersion() + { + VersionMajor = 10, + VersionMinor = 43, + BuildMajor = 0, + BuildMinor = 0, + } + }; + + public static readonly GarminDeviceInfo Forerunner945 = new GarminDeviceInfo() + { + Name = "Forerunner 945", // Max 20 Chars + ProductID = 3113, // GarminProduct.Fr945, + UnitId = 1, + ManufacturerId = 1, // Garmin + Version = new GarminDeviceVersion() + { + VersionMajor = 19, + VersionMinor = 2, + BuildMajor = 0, + BuildMinor = 0, + } + }; + } } diff --git a/src/Common/Dto/P2GWorkout.cs b/src/Common/Dto/P2GWorkout.cs index dbb8efdc8..61a2c222b 100644 --- a/src/Common/Dto/P2GWorkout.cs +++ b/src/Common/Dto/P2GWorkout.cs @@ -47,6 +47,28 @@ public static WorkoutType GetWorkoutType(this Workout workout) }; } + public static (FitnessDiscipline fitnessDiscipline, bool isOutdoor) ToFitnessDiscipline(this WorkoutType workoutType) + { + return workoutType switch + { + WorkoutType.None => (FitnessDiscipline.None, false), + WorkoutType.BikeBootcamp => (FitnessDiscipline.Bike_Bootcamp, false), + WorkoutType.Cardio => (FitnessDiscipline.Cardio, false), + WorkoutType.Circuit => (FitnessDiscipline.Circuit, false), + WorkoutType.Cycling => (FitnessDiscipline.Cycling, false), + WorkoutType.OutdoorCycling => (FitnessDiscipline.Cycling, true), + WorkoutType.Meditation => (FitnessDiscipline.Meditation, false), + WorkoutType.Rowing => (FitnessDiscipline.Caesar, false), + WorkoutType.OutdoorRunning => (FitnessDiscipline.Running, true), + WorkoutType.TreadmillRunning => (FitnessDiscipline.Running, false), + WorkoutType.Strength => (FitnessDiscipline.Strength, false), + WorkoutType.Stretching => (FitnessDiscipline.Stretching, false), + WorkoutType.TreadmillWalking => (FitnessDiscipline.Walking, false), + WorkoutType.OutdoorWalking => (FitnessDiscipline.Walking, true), + WorkoutType.Yoga => (FitnessDiscipline.Yoga, false), + _ => (FitnessDiscipline.None, false), + }; + } } public record P2GExercise diff --git a/src/Common/Dto/Settings.cs b/src/Common/Dto/Settings.cs index 08c43895f..0309f6ae7 100644 --- a/src/Common/Dto/Settings.cs +++ b/src/Common/Dto/Settings.cs @@ -1,6 +1,9 @@ -using Common.Stateful; +using Common.Dto.Garmin; +using Common.Stateful; +using System; using System.Collections.Generic; using System.IO; +using System.Text.Json.Serialization; namespace Common.Dto; @@ -57,13 +60,23 @@ public Format() Strength = new Strength(); } + [JsonIgnore] + public static readonly Dictionary DefaultDeviceInfoSettings = new Dictionary() + { + { WorkoutType.None, GarminDevices.Forerunner945 }, + { WorkoutType.Cycling, GarminDevices.TACXDevice }, + { WorkoutType.Rowing, GarminDevices.EpixDevice }, + }; + public bool Fit { get; set; } public bool Json { get; set; } public bool Tcx { get; set; } public bool SaveLocalCopy { get; set; } public bool IncludeTimeInHRZones { get; set; } public bool IncludeTimeInPowerZones { get; set; } + [Obsolete("Use DeviceInfoSettings instead. Will be removed in P2G v5.")] public string DeviceInfoPath { get; set; } + public Dictionary DeviceInfoSettings { get; set; } public string WorkoutTitleTemplate { get; set; } = "{{PelotonWorkoutTitle}}{{#if PelotonInstructorName}} with {{PelotonInstructorName}}{{/if}}"; public Cycling Cycling { get; set; } public Running Running { get; set; } diff --git a/src/Common/Observe/Metrics.cs b/src/Common/Observe/Metrics.cs index a4aa6e840..8037cef0e 100644 --- a/src/Common/Observe/Metrics.cs +++ b/src/Common/Observe/Metrics.cs @@ -1,129 +1,131 @@ using Common.Dto; -using Common.Stateful; using Prometheus; using Prometheus.DotNetRuntime; using Serilog; using System; using PromMetrics = Prometheus.Metrics; -namespace Common.Observe +namespace Common.Observe; + +public static class Metrics { - public static class Metrics + public static IMetricServer EnableMetricsServer(Prometheus config) { - public static IMetricServer EnableMetricsServer(Prometheus config) + IMetricServer metricsServer = null; + if (config.Enabled) { - IMetricServer metricsServer = null; - if (config.Enabled) - { - var port = config.Port ?? 4000; - metricsServer = new KestrelMetricServer(port: port); - metricsServer.Start(); - - Log.Information("Metrics Server started and listening on: http://localhost:{0}/metrics", port); - } + var port = config.Port ?? 4000; + metricsServer = new KestrelMetricServer(port: port); + metricsServer.Start(); - return metricsServer; + Log.Information("Metrics Server started and listening on: http://localhost:{0}/metrics", port); } - public static void ValidateConfig(Observability config) - { - if (!config.Prometheus.Enabled) return; + return metricsServer; + } - if (config.Prometheus.Port.HasValue && config.Prometheus.Port <= 0) - { - Log.Error("Prometheus Port must be a valid port: {@ConfigSection}.{@ConfigProperty}.", nameof(config), nameof(config.Prometheus.Port)); - throw new ArgumentException("Prometheus port must be greater than 0.", nameof(config.Prometheus.Port)); - } - } + public static void ValidateConfig(Observability config) + { + if (!config.Prometheus.Enabled) return; - public static IDisposable EnableCollector(Prometheus config) + if (config.Prometheus.Port.HasValue && config.Prometheus.Port <= 0) { - if (config.Enabled) - return DotNetRuntimeStatsBuilder - .Customize() - .WithContentionStats() - .WithJitStats() - .WithThreadPoolStats() - .WithGcStats() - .WithExceptionStats() - //.WithDebuggingMetrics(true) - .WithErrorHandler(ex => Log.Error(ex, "Unexpected exception occurred in prometheus-net.DotNetRuntime")) - .StartCollecting(); - - return null; + Log.Error("Prometheus Port must be a valid port: {@ConfigSection}.{@ConfigProperty}.", nameof(config), nameof(config.Prometheus.Port)); + throw new ArgumentException("Prometheus port must be greater than 0.", nameof(config.Prometheus.Port)); } + } - public static void CreateAppInfo() + public static IDisposable EnableCollector(Prometheus config) + { + if (config.Enabled) + return DotNetRuntimeStatsBuilder + .Customize() + .WithContentionStats() + .WithJitStats() + .WithThreadPoolStats() + .WithGcStats() + .WithExceptionStats() + //.WithDebuggingMetrics(true) + .WithErrorHandler(ex => Log.Error(ex, "Unexpected exception occurred in prometheus-net.DotNetRuntime")) + .StartCollecting(); + + return null; + } + + public static void CreateAppInfo() + { + PromMetrics.CreateGauge("p2g_build_info", "Build info for the running instance.", new GaugeConfiguration() { - PromMetrics.CreateGauge("p2g_build_info", "Build info for the running instance.", new GaugeConfiguration() - { - LabelNames = new[] { Label.Version, Label.Os, Label.OsVersion, Label.DotNetRuntime, Label.RunningInDocker } - }).WithLabels(Constants.AppVersion, SystemInformation.OS, SystemInformation.OSVersion, SystemInformation.RunTimeVersion, SystemInformation.RunningInDocker.ToString()) + LabelNames = new[] { Label.Version, Label.Os, Label.OsVersion, Label.DotNetRuntime, Label.RunningInDocker } + }).WithLabels(Constants.AppVersion, SystemInformation.OS, SystemInformation.OSVersion, SystemInformation.RunTimeVersion, SystemInformation.RunningInDocker.ToString()) .Set(1); - } + } - public static class Label - { - public static string HttpMethod = "http_method"; - public static string HttpHost = "http_host"; - public static string HttpRequestPath = "http_request_path"; - public static string HttpRequestQuery = "http_request_query"; - public static string HttpStatusCode = "http_status_code"; - public static string HttpMessage = "http_message"; + public static class Label + { + public static string HttpMethod = "http_method"; + public static string HttpHost = "http_host"; + public static string HttpRequestPath = "http_request_path"; + public static string HttpRequestQuery = "http_request_query"; + public static string HttpStatusCode = "http_status_code"; + public static string HttpMessage = "http_message"; - public static string DbMethod = "db_method"; - public static string DbQuery = "db_query"; + public static string DbMethod = "db_method"; + public static string DbQuery = "db_query"; - public static string FileType = "file_type"; + public static string CacheMethod = "cache_method"; + public static string CacheKey = "cache_key"; + public static string CacheMiss = "cache_miss"; - public static string Count = "count"; + public static string FileType = "file_type"; - public static string Os = "os"; - public static string OsVersion = "os_version"; - public static string Version = "version"; - public static string DotNetRuntime = "dotnet_runtime"; - public static string RunningInDocker = "is_docker"; - public static string LatestVersion = "latest_version"; + public static string Count = "count"; - public static string ReflectionMethod = "reflection_method"; - } + public static string Os = "os"; + public static string OsVersion = "os_version"; + public static string Version = "version"; + public static string DotNetRuntime = "dotnet_runtime"; + public static string RunningInDocker = "is_docker"; + public static string LatestVersion = "latest_version"; - public static class HealthStatus - { - public static int Healthy = 2; - public static int UnHealthy = 1; - public static int Dead = 0; - } + public static string ReflectionMethod = "reflection_method"; } - public static class DbMetrics + public static class HealthStatus { - public static readonly Histogram DbActionDuration = PromMetrics.CreateHistogram(TagValue.P2G + "_db_duration_seconds", "Counter of db actions.", new HistogramConfiguration() - { - LabelNames = new[] { Metrics.Label.DbMethod, Metrics.Label.DbQuery } - }); + public static int Healthy = 2; + public static int UnHealthy = 1; + public static int Dead = 0; } +} + +public static class DbMetrics +{ + public static readonly Histogram DbActionDuration = PromMetrics.CreateHistogram(TagValue.P2G + "_db_duration_seconds", "Counter of db actions.", new HistogramConfiguration() + { + LabelNames = new[] { Metrics.Label.DbMethod, Metrics.Label.DbQuery } + }); +} - public static class AppMetrics +public static class AppMetrics +{ + public static readonly Gauge UpdateAvailable = PromMetrics.CreateGauge("p2g_update_available", "Indicates a newer version of P2G is availabe.", new GaugeConfiguration() { - public static readonly Gauge UpdateAvailable = PromMetrics.CreateGauge("p2g_update_available", "Indicates a newer version of P2G is availabe.", new GaugeConfiguration() - { - LabelNames = new[] { Metrics.Label.Version, Metrics.Label.LatestVersion } - }); + LabelNames = new[] { Metrics.Label.Version, Metrics.Label.LatestVersion } + }); - public static void SyncUpdateAvailableMetric(bool isUpdateAvailable, string latestVersion) + public static void SyncUpdateAvailableMetric(bool isUpdateAvailable, string latestVersion) + { + if (isUpdateAvailable) + { + UpdateAvailable + .WithLabels(Constants.AppVersion, latestVersion ?? string.Empty) + .Set(1); + } else { - if (isUpdateAvailable) - { - UpdateAvailable - .WithLabels(Constants.AppVersion, latestVersion ?? string.Empty) - .Set(1); - } else - { - UpdateAvailable - .WithLabels(Constants.AppVersion, Constants.AppVersion) - .Set(0); - } + UpdateAvailable + .WithLabels(Constants.AppVersion, Constants.AppVersion) + .Set(0); } } } diff --git a/src/Common/Service/FileBasedSettingsService.cs b/src/Common/Service/FileBasedSettingsService.cs index a8d8eb5a9..9f8e96672 100644 --- a/src/Common/Service/FileBasedSettingsService.cs +++ b/src/Common/Service/FileBasedSettingsService.cs @@ -1,5 +1,6 @@ using Common.Dto; using Common.Dto.Garmin; +using Common.Dto.Peloton; using Common.Observe; using Common.Stateful; using Microsoft.Extensions.Caching.Memory; @@ -61,7 +62,16 @@ public PelotonApiAuthentication GetPelotonApiAuthentication(string pelotonEmail) public Task GetSettingsAsync() { var settings = new Settings(); - ConfigurationSetup.LoadConfigValues(_configurationLoader, settings); + ConfigurationSetup.LoadConfigValues(_configurationLoader, settings); + + if (settings.Format is null) + settings.Format = new Settings().Format; + + if (settings.Format.DeviceInfoSettings is null) + settings.Format.DeviceInfoSettings = Format.DefaultDeviceInfoSettings; + + if (!settings.Format.DeviceInfoSettings.TryGetValue(WorkoutType.None, out var _)) + settings.Format.DeviceInfoSettings.Add(WorkoutType.None, Format.DefaultDeviceInfoSettings[WorkoutType.None]); return Task.FromResult(settings); } @@ -91,31 +101,44 @@ public Task GetAppConfigurationAsync() return _next.GetAppConfigurationAsync(); } - - public async Task GetCustomDeviceInfoAsync(string garminEmail) - { + + public async Task GetCustomDeviceInfoAsync(Workout workout) + { using var tracing = Tracing.Trace($"{nameof(FileBasedSettingsService)}.{nameof(GetCustomDeviceInfoAsync)}"); + var workoutType = WorkoutType.None; + if (workout is object) + workoutType = workout.GetWorkoutType(); + GarminDeviceInfo userProvidedDeviceInfo = null; var settings = await GetSettingsAsync(); - var userDevicePath = settings.Format.DeviceInfoPath; +#pragma warning disable CS0618 // Type or member is obsolete + var userDevicePath = settings?.Format?.DeviceInfoPath; +#pragma warning restore CS0618 // Type or member is obsolete - if (string.IsNullOrEmpty(userDevicePath)) - return null; + _fileHandler.TryDeserializeXml(userDevicePath, out userProvidedDeviceInfo); - lock (_lock) + if (userProvidedDeviceInfo != null) return userProvidedDeviceInfo; + + if (settings?.Format?.DeviceInfoSettings is object) + { + settings.Format.DeviceInfoSettings.TryGetValue(workoutType, out userProvidedDeviceInfo); + + if (userProvidedDeviceInfo is null) + settings.Format.DeviceInfoSettings.TryGetValue(WorkoutType.None, out userProvidedDeviceInfo); + } + + if (userProvidedDeviceInfo is null) { - var key = $"{GarminDeviceInfoKey}:{garminEmail}"; - return _cache.GetOrCreate(key, (cacheEntry) => - { - cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5); - if (_fileHandler.TryDeserializeXml(userDevicePath, out userProvidedDeviceInfo)) - return userProvidedDeviceInfo; - - return null; - }); - } + Format.DefaultDeviceInfoSettings.TryGetValue(workoutType, out userProvidedDeviceInfo); + + if (userProvidedDeviceInfo is null) + Format.DefaultDeviceInfoSettings.TryGetValue(WorkoutType.None, out userProvidedDeviceInfo); + } + + return userProvidedDeviceInfo; + } } } diff --git a/src/Common/Service/ISettingsService.cs b/src/Common/Service/ISettingsService.cs index 9a153fa58..234f01686 100644 --- a/src/Common/Service/ISettingsService.cs +++ b/src/Common/Service/ISettingsService.cs @@ -1,5 +1,6 @@ using Common.Dto; using Common.Dto.Garmin; +using Common.Dto.Peloton; using Common.Stateful; using System.Threading.Tasks; @@ -12,7 +13,7 @@ public interface ISettingsService Task GetAppConfigurationAsync(); - Task GetCustomDeviceInfoAsync(string garminEmail); + Task GetCustomDeviceInfoAsync(Workout workout); PelotonApiAuthentication GetPelotonApiAuthentication(string pelotonEmail); void SetPelotonApiAuthentication(PelotonApiAuthentication authentication); diff --git a/src/Common/Service/SettingsService.cs b/src/Common/Service/SettingsService.cs index 6a1476f76..3b7259681 100644 --- a/src/Common/Service/SettingsService.cs +++ b/src/Common/Service/SettingsService.cs @@ -1,6 +1,7 @@ using Common.Database; using Common.Dto; using Common.Dto.Garmin; +using Common.Dto.Peloton; using Common.Observe; using Common.Stateful; using Microsoft.Extensions.Caching.Memory; @@ -36,7 +37,18 @@ public async Task GetSettingsAsync() { using var tracing = Tracing.Trace($"{nameof(SettingsService)}.{nameof(GetSettingsAsync)}"); - return (await _db.GetSettingsAsync(1)) ?? new Settings(); // hardcode to admin user for now + var settings = (await _db.GetSettingsAsync(1)) ?? new Settings(); // hardcode to admin user for now + + if (settings.Format is null) + settings.Format = new Settings().Format; + + if (settings.Format.DeviceInfoSettings is null) + settings.Format.DeviceInfoSettings = Format.DefaultDeviceInfoSettings; + + if (!settings.Format.DeviceInfoSettings.TryGetValue(WorkoutType.None, out var _)) + settings.Format.DeviceInfoSettings.Add(WorkoutType.None, Format.DefaultDeviceInfoSettings[WorkoutType.None]); + + return settings; } public async Task UpdateSettingsAsync(Settings updatedSettings) @@ -56,9 +68,6 @@ public async Task UpdateSettingsAsync(Settings updatedSettings) ClearGarminAuthentication(originalSettings.Garmin.Email); ClearGarminAuthentication(originalSettings.Garmin.Password); - - ClearCustomDeviceInfoAsync(originalSettings.Garmin.Email); - ClearCustomDeviceInfoAsync(updatedSettings.Garmin.Email); await _db.UpsertSettingsAsync(1, updatedSettings); // hardcode to admin user for now } @@ -139,40 +148,41 @@ public Task GetAppConfigurationAsync() return Task.FromResult(appConfiguration); } - public async Task GetCustomDeviceInfoAsync(string garminEmail) + public async Task GetCustomDeviceInfoAsync(Workout workout) { using var tracing = Tracing.Trace($"{nameof(SettingsService)}.{nameof(GetCustomDeviceInfoAsync)}"); + var workoutType = WorkoutType.None; + if (workout is object) + workoutType = workout.GetWorkoutType(); + GarminDeviceInfo userProvidedDeviceInfo = null; var settings = await GetSettingsAsync(); - var userDevicePath = settings.Format.DeviceInfoPath; +#pragma warning disable CS0618 // Type or member is obsolete + var userDevicePath = settings?.Format?.DeviceInfoPath; +#pragma warning restore CS0618 // Type or member is obsolete - if (string.IsNullOrEmpty(userDevicePath)) - return null; + _fileHandler.TryDeserializeXml(userDevicePath, out userProvidedDeviceInfo); - lock (_lock) + if (userProvidedDeviceInfo != null) return userProvidedDeviceInfo; + + if (settings?.Format?.DeviceInfoSettings is object) { - var key = $"{GarminDeviceInfoKey}:{garminEmail}"; - return _cache.GetOrCreate(key, (cacheEntry) => - { - cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5); - if (_fileHandler.TryDeserializeXml(userDevicePath, out userProvidedDeviceInfo)) - return userProvidedDeviceInfo; - - return null; - }); - } - } + settings.Format.DeviceInfoSettings.TryGetValue(workoutType, out userProvidedDeviceInfo); - private void ClearCustomDeviceInfoAsync(string garminEmail) - { - using var tracing = Tracing.Trace($"{nameof(SettingsService)}.{nameof(ClearCustomDeviceInfoAsync)}"); + if (userProvidedDeviceInfo is null) + settings.Format.DeviceInfoSettings.TryGetValue(WorkoutType.None, out userProvidedDeviceInfo); + } - lock (_lock) + if (userProvidedDeviceInfo is null) { - var key = $"{GarminDeviceInfoKey}:{garminEmail}"; - _cache.Remove(key); + Format.DefaultDeviceInfoSettings.TryGetValue(workoutType, out userProvidedDeviceInfo); + + if (userProvidedDeviceInfo is null) + Format.DefaultDeviceInfoSettings.TryGetValue(WorkoutType.None, out userProvidedDeviceInfo); } + + return userProvidedDeviceInfo; } } diff --git a/src/Conversion/FitConverter.cs b/src/Conversion/FitConverter.cs index 6d58f8d03..e46420f33 100644 --- a/src/Conversion/FitConverter.cs +++ b/src/Conversion/FitConverter.cs @@ -65,7 +65,7 @@ protected override async Task>> ConvertInternalA var title = WorkoutHelper.GetTitle(workout, settings.Format); var sport = GetGarminSport(workout); var subSport = GetGarminSubSport(workout); - var deviceInfo = await GetDeviceInfoAsync(workout.Fitness_Discipline, settings); + var deviceInfo = await GetDeviceInfoAsync(workout); if (sport == Sport.Invalid) { diff --git a/src/Conversion/IConverter.cs b/src/Conversion/IConverter.cs index cf344f71f..8ec09b67a 100644 --- a/src/Conversion/IConverter.cs +++ b/src/Conversion/IConverter.cs @@ -32,51 +32,6 @@ public abstract class Converter : IConverter private static readonly ILogger _logger = LogContext.ForClass>(); - private static readonly GarminDeviceInfo RowingDevice = new GarminDeviceInfo() - { - Name = "Epix", // Max 20 Chars - ProductID = GarminProduct.EpixGen2, - UnitId = 3413684246, - ManufacturerId = 1, // Garmin - Version = new GarminDeviceVersion() - { - VersionMajor = 10, - VersionMinor = 43, - BuildMajor = 0, - BuildMinor = 0, - } - }; - - private static readonly GarminDeviceInfo CyclingDevice = new GarminDeviceInfo() - { - Name = "TacxTrainingAppWin", // Max 20 Chars - ProductID = GarminProduct.TacxTrainingAppWin, - UnitId = 1, - ManufacturerId = 89, // Tacx - Version = new GarminDeviceVersion() - { - VersionMajor = 1, - VersionMinor = 30, - BuildMajor = 0, - BuildMinor = 0, - } - }; - - private static readonly GarminDeviceInfo DefaultDevice = new GarminDeviceInfo() - { - Name = "Forerunner 945", // Max 20 Chars - ProductID = GarminProduct.Fr945, - UnitId = 1, - ManufacturerId = 1, // Garmin - Version = new GarminDeviceVersion() - { - VersionMajor = 19, - VersionMinor = 2, - BuildMajor = 0, - BuildMinor = 0, - } - }; - public static readonly float _metersPerMile = 1609.34f; public FileFormat Format { get; init; } @@ -563,22 +518,11 @@ protected static Metric GetMetric(string slug, WorkoutSamples workoutSamples) return metric; } - protected async Task GetDeviceInfoAsync(FitnessDiscipline sport, Settings settings) + protected async Task GetDeviceInfoAsync(Workout workout) { - GarminDeviceInfo deviceInfo = null; - deviceInfo = await _settingsService.GetCustomDeviceInfoAsync(settings.Garmin.Email); - - if (deviceInfo is null) - { - if (sport == FitnessDiscipline.Cycling) - deviceInfo = CyclingDevice; - else if (sport == FitnessDiscipline.Caesar) - deviceInfo = RowingDevice; - else - deviceInfo = DefaultDevice; - } + GarminDeviceInfo deviceInfo = await _settingsService.GetCustomDeviceInfoAsync(workout); - _logger.Debug("Using device: {@DeviceName}, {@DeviceProdId}, {@DeviceManufacturerId}, {@DeviceVersion}", deviceInfo.Name, deviceInfo.ProductID, deviceInfo.ManufacturerId, deviceInfo.Version); + _logger.Debug("Using device: {@DeviceName}, {@DeviceProdId}, {@DeviceManufacturerId}, {@DeviceVersion}", deviceInfo?.Name, deviceInfo?.ProductID, deviceInfo?.ManufacturerId, deviceInfo?.Version); return deviceInfo; } diff --git a/src/Conversion/TcxConverter.cs b/src/Conversion/TcxConverter.cs index 5db76585b..d0643efe8 100644 --- a/src/Conversion/TcxConverter.cs +++ b/src/Conversion/TcxConverter.cs @@ -53,7 +53,7 @@ protected override async Task ConvertInternalAsync(P2GWorkout workoutD var hrSummary = GetHeartRateSummary(samples); var cadenceSummary = GetCadenceSummary(samples, GetGarminSport(workout)); var resistanceSummary = GetResistanceSummary(samples); - var deviceInfo = await GetDeviceInfoAsync(workout.Fitness_Discipline, settings); + var deviceInfo = await GetDeviceInfoAsync(workout); var lx = new XElement(activityExtensions + "TPX"); lx.Add(new XElement(activityExtensions + "TotalPower", workout?.Total_Work)); diff --git a/src/GitHub/ApiClient.cs b/src/GitHub/ApiClient.cs deleted file mode 100644 index 064816052..000000000 --- a/src/GitHub/ApiClient.cs +++ /dev/null @@ -1,24 +0,0 @@ -using GitHub.Dto; -using Flurl.Http; - -namespace GitHub; - -public interface IGitHubApiClient -{ - Task GetLatestReleaseAsync(); -} - -public class ApiClient : IGitHubApiClient -{ - private const string BASE_URL = "https://api.github.com"; - private const string USER = "philosowaffle"; - private const string REPO = "peloton-to-garmin"; - - public Task GetLatestReleaseAsync() - { - return $"{BASE_URL}/repos/{USER}/{REPO}/releases/latest" - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:105.0) Gecko/20100101 Firefox/105.0") - .GetJsonAsync(); - } -} diff --git a/src/GitHub/Dto/GitHubLatestRelease.cs b/src/GitHub/Dto/GitHubLatestRelease.cs deleted file mode 100644 index 3ecc4f9a2..000000000 --- a/src/GitHub/Dto/GitHubLatestRelease.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace GitHub.Dto; - -public class GitHubLatestRelease -{ - public string? Html_Url { get; set; } - public string? Tag_Name { get; set; } - public DateTime Published_At { get; set; } - public string? Body { get; set; } -} diff --git a/src/GitHub/Dto/P2GLatestRelease.cs b/src/GitHub/Dto/P2GLatestRelease.cs deleted file mode 100644 index 2bc71b6b4..000000000 --- a/src/GitHub/Dto/P2GLatestRelease.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace GitHub.Dto -{ - public record P2GLatestRelease - { - public string? LatestVersion { get; set; } - public DateTime ReleaseDate { get; set; } - public string? ReleaseUrl { get; set; } - public string? Description { get; set; } - public bool IsReleaseNewerThanInstalledVersion { get; set; } - } -} diff --git a/src/GitHub/GitHub.csproj b/src/GitHub/GitHub.csproj deleted file mode 100644 index d99e2f126..000000000 --- a/src/GitHub/GitHub.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net6.0 - enable - true - $(NoWarn);1591 - enable - - - - - - - - - - - - diff --git a/src/GitHub/GitHubService.cs b/src/GitHub/GitHubService.cs deleted file mode 100644 index fe69a302c..000000000 --- a/src/GitHub/GitHubService.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Common; -using Common.Observe; -using GitHub.Dto; -using Microsoft.Extensions.Caching.Memory; -using Serilog; - -namespace GitHub; - -public interface IGitHubService -{ - Task GetLatestReleaseAsync(); -} - -public class GitHubService : IGitHubService -{ - private static readonly ILogger _logger = LogContext.ForClass(); - private static readonly object _lock = new object(); - - private const string LatestReleaseKey = "GithubLatestRelease"; - - private readonly IGitHubApiClient _apiClient; - private readonly IMemoryCache _cache; - - public GitHubService(IGitHubApiClient apiClient, IMemoryCache cache) - { - _apiClient = apiClient; - _cache = cache; - } - - public Task GetLatestReleaseAsync() - { - using var tracing = Tracing.Trace($"{nameof(GitHubService)}.{nameof(GetLatestReleaseAsync)}"); - - lock (_lock) - { - return _cache.GetOrCreateAsync(LatestReleaseKey, async (cacheEntry) => - { - cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); - - try - { - var latestVersionInformation = await _apiClient.GetLatestReleaseAsync(); - var newVersionAvailable = IsReleaseNewerThanInstalledVersion(latestVersionInformation.Tag_Name, Constants.AppVersion); - - AppMetrics.SyncUpdateAvailableMetric(newVersionAvailable, latestVersionInformation.Tag_Name); - - return new P2GLatestRelease() - { - LatestVersion = latestVersionInformation.Tag_Name, - ReleaseDate = latestVersionInformation.Published_At, - ReleaseUrl = latestVersionInformation.Html_Url, - Description = latestVersionInformation.Body, - IsReleaseNewerThanInstalledVersion = newVersionAvailable - }; - } catch (Exception e) - { - _logger.Error(e, "Error occurred while checking for P2G updates."); - cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30); - return new P2GLatestRelease(); - } - }); - } - } - - private static bool IsReleaseNewerThanInstalledVersion(string? releaseVersion, string currentVersion) - { - if (string.IsNullOrEmpty(releaseVersion)) - { - _logger.Verbose("Latest Release version from GitHub was null"); - return false; - } - - if (string.IsNullOrEmpty(currentVersion)) - { - _logger.Verbose("Current install version is null"); - return false; - } - - var standardizedInstallVersion = currentVersion.Trim().ToLower(); - var isInstalledVersionRC = standardizedInstallVersion.Contains("-rc"); - var installedVersionCleaned = standardizedInstallVersion.Replace("-rc", string.Empty); - - var cleanedReleaseVersion = releaseVersion.Trim().ToLower().Replace("v", string.Empty); - - if (!Version.TryParse(cleanedReleaseVersion, out var latestVersion)) - { - _logger.Verbose("Failed to parse latest release version: {@Version}", cleanedReleaseVersion); - return false; - } - - if (!Version.TryParse(installedVersionCleaned, out var installedVersion)) - { - _logger.Verbose("Failed to parse installed version: {@Version}", installedVersionCleaned); - return false; - } - - if (isInstalledVersionRC) - return latestVersion >= installedVersion; - - return latestVersion > installedVersion; - } -} diff --git a/src/SharedUI/Pages/Settings.razor b/src/SharedUI/Pages/Settings.razor index f934f5f8d..5f91fdc03 100644 --- a/src/SharedUI/Pages/Settings.razor +++ b/src/SharedUI/Pages/Settings.razor @@ -60,6 +60,6 @@ var settings = await _apiClient.SettingsGetAsync(); var systemInfo = await _apiClient.SystemInfoGetAsync(new SystemInfoGetRequest() { CheckForUpdate = settings.App.CheckForUpdates }); - configDocumentation = systemInfo.Documentation + "/configuration/json"; + configDocumentation = systemInfo.Documentation + "/configuration"; } } \ No newline at end of file diff --git a/src/SharedUI/Shared/AppSettingsForm.razor b/src/SharedUI/Shared/AppSettingsForm.razor index 381c07156..d0ef9ceb8 100644 --- a/src/SharedUI/Shared/AppSettingsForm.razor +++ b/src/SharedUI/Shared/AppSettingsForm.razor @@ -54,7 +54,7 @@ appSettings = settings.App; var systemInfo = await _apiClient.SystemInfoGetAsync(new SystemInfoGetRequest() { CheckForUpdate = settings.App.CheckForUpdates }); - configDocumentation = systemInfo.Documentation + "/configuration/json/#app-config"; + configDocumentation = systemInfo.Documentation + "/configuration/app"; } protected async Task SaveAppSettings() diff --git a/src/SharedUI/Shared/DeviceInfoModal.razor b/src/SharedUI/Shared/DeviceInfoModal.razor new file mode 100644 index 000000000..fb40397fc --- /dev/null +++ b/src/SharedUI/Shared/DeviceInfoModal.razor @@ -0,0 +1,107 @@ +@using System.Globalization; +@using Common.Dto.Garmin; +@using System.ComponentModel.DataAnnotations; +@inject IHxMessengerService _toaster; + + + + + + + + + + + + + + + Submit + + + +@code { + private ICollection AvailableWorkoutTypes = Enum.GetValues(typeof(WorkoutType)).Cast().ToList(); + + [Required] + private WorkoutType ExerciseType = WorkoutType.None; + private GarminDeviceInfo DeviceInfo = new GarminDeviceInfo(); + + private HxModal? Modal; + + private Func, Task> _submitCallback = NoOpAsync; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + } + + public async Task ShowAsync(KeyValuePair? editDeviceInfo, ICollection alreadyConfiguredTypes, Func, Task> submitCallback) + { + using var tracing = Tracing.ClientTrace($"{nameof(DeviceInfoModal)}.{nameof(ShowAsync)}", kind: ActivityKind.Client); + + _submitCallback = submitCallback; + + AvailableWorkoutTypes = Enum.GetValues(typeof(WorkoutType)).Cast().Except(alreadyConfiguredTypes).ToList(); + ExerciseType = AvailableWorkoutTypes.FirstOrDefault(); + + if (editDeviceInfo is object) + { + AvailableWorkoutTypes.Add(editDeviceInfo.Value.Key); + ExerciseType = editDeviceInfo.Value.Key; + DeviceInfo = editDeviceInfo.Value.Value; + } + + await Modal!.ShowAsync(); + } + + protected async Task SubmitAsync() + { + using var tracing = Tracing.ClientTrace($"{nameof(GarminMfaModal)}.{nameof(SubmitAsync)}", kind: ActivityKind.Client); + + try + { + await _submitCallback.Invoke(new KeyValuePair(ExerciseType, DeviceInfo)); + await Modal!.HideAsync(); + } + catch (Exception e) + { + Log.Error("UI - Failed to submit Garmin Device Info.", e); + _toaster.AddInformation("Failed to update Garmin Device list.", "See logs for more info."); + } + } + + protected void OnClosed() + { + _submitCallback = NoOpAsync; + ExerciseType = WorkoutType.None; + DeviceInfo = new GarminDeviceInfo(); + } + + public static Task NoOpAsync(KeyValuePair deviceInfo) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/SharedUI/Shared/FormatSettingsForm.razor b/src/SharedUI/Shared/FormatSettingsForm.razor index dda1bc3c4..79f622b88 100644 --- a/src/SharedUI/Shared/FormatSettingsForm.razor +++ b/src/SharedUI/Shared/FormatSettingsForm.razor @@ -1,7 +1,10 @@ -@using HandlebarsDotNet; +@using Common.Dto.Garmin; +@using HandlebarsDotNet; @inject IApiClient _apiClient @inject IHxMessengerService _toaster; + +
@@ -39,7 +42,7 @@ - +
@@ -106,24 +109,102 @@

-
+ Advanced - - ? - -
- - - + + + Most users should not need to modify these settings. Please be sure you've read the documentation before changing. + +
+ + + Device Info Settings + + ? + + + + @if (!string.IsNullOrWhiteSpace(formatSettings.DeviceInfoPath)) + { + +
+ } + + + + Add + + Toggle Dropdown@* OPTIONAL (for accessibility) *@ + + + TACX + Epix + Forerunner 945 + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Custom Zone Handling + + ? + + + + + + + +
+ + + Misc + + + + +
@@ -137,9 +218,13 @@ private static ICollection lapTypes = Enum.GetValues(typeof(PreferredLapType)).Cast().ToList(); private Format formatSettings; + private DeviceInfoModal? _deviceInfoModal; + private HxGrid> _gridComponent; private string workoutTemplateExample; private string configDocumentation; + private string configDocumentationBase; + private string outputDirectory; public FormatSettingsForm() { @@ -162,10 +247,12 @@ var settings = await _apiClient.SettingsGetAsync(); formatSettings = settings.Format; - ValueChanged(formatSettings!.WorkoutTitleTemplate); + TitleTemplate_ValueChanged(formatSettings!.WorkoutTitleTemplate); var systemInfo = await _apiClient.SystemInfoGetAsync(new SystemInfoGetRequest() { CheckForUpdate = settings.App.CheckForUpdates }); - configDocumentation = systemInfo.Documentation + "/configuration/json/#format-config"; + configDocumentationBase = systemInfo.Documentation; + configDocumentation = systemInfo.Documentation + "/configuration/format"; + outputDirectory = systemInfo.OutputDirectory; } protected async Task SaveFormatSettings() @@ -190,7 +277,7 @@ } } - protected void ValueChanged(string newValue) + protected void TitleTemplate_ValueChanged(string newValue) { formatSettings.WorkoutTitleTemplate = newValue; @@ -212,9 +299,69 @@ workoutTemplateExample = $"Example: {titleExample}"; } + private async Task DeviceInfo_HandleCreatedEditedItem(KeyValuePair deviceInfo) + { + using var tracing = Tracing.ClientTrace($"{nameof(FormatSettingsForm)}.{nameof(DeviceInfo_HandleCreatedEditedItem)}", kind: ActivityKind.Client); + + if (formatSettings.DeviceInfoSettings.TryGetValue(deviceInfo.Key, out var existingDeviceConfig)) + { + formatSettings.DeviceInfoSettings[deviceInfo.Key] = deviceInfo.Value; + await _gridComponent.RefreshDataAsync(); + return; + } + + formatSettings.DeviceInfoSettings.Add(deviceInfo.Key, deviceInfo.Value); + await _gridComponent.RefreshDataAsync(); + } + + private Task>> DeviceInfo_GetGridData(GridDataProviderRequest> request) + { + var items = formatSettings.DeviceInfoSettings + .AsEnumerable() + .OrderBy(i => i.Key); + + return Task.FromResult(new GridDataProviderResult>() + { + Data = items, + TotalCount = items.Count() + }); + } + + private Task DeviceInfo_HandleEditClick(KeyValuePair deviceInfo) + { + return _deviceInfoModal!.ShowAsync(deviceInfo, formatSettings?.DeviceInfoSettings?.Keys?.ToList() ?? new List(0), DeviceInfo_HandleCreatedEditedItem); + } + + private Task DeviceInfo_HandleCreateFromDeviceClick(GarminDeviceInfo deviceInfo) + { + var remainingTypes = Enum.GetValues(typeof(WorkoutType)).Cast().Except(formatSettings.DeviceInfoSettings.Keys).ToList(); + var copy = new KeyValuePair(remainingTypes.FirstOrDefault(), deviceInfo); + return _deviceInfoModal!.ShowAsync(copy, formatSettings?.DeviceInfoSettings?.Keys?.ToList() ?? new List(0), DeviceInfo_HandleCreatedEditedItem); + } + + private Task DeviceInfo_HandleCopyClick(KeyValuePair deviceInfo) + { + var remainingTypes = Enum.GetValues(typeof(WorkoutType)).Cast().Except(formatSettings.DeviceInfoSettings.Keys).ToList(); + var copy = new KeyValuePair(remainingTypes.FirstOrDefault(), deviceInfo.Value); + return _deviceInfoModal!.ShowAsync(copy, formatSettings?.DeviceInfoSettings?.Keys?.ToList() ?? new List(0), DeviceInfo_HandleCreatedEditedItem); + } + + private async Task DeviceInfo_HandleDeleteClick(KeyValuePair deviceInfo) + { + formatSettings?.DeviceInfoSettings?.Remove(deviceInfo.Key); + await _gridComponent.RefreshDataAsync(); + } + + private Task DeviceInfo_HandleNewItemClicked() + { + return _deviceInfoModal!.ShowAsync(null, formatSettings?.DeviceInfoSettings?.Keys?.ToList() ?? new List(0), DeviceInfo_HandleCreatedEditedItem); + } + private string FormatSettingsDocumentation => $"
  • FIT is the recommended format
  • FIT and TCX are the only types that can be uploaded to Garmin
  • If you enable JSON, you'll also need to enable saving a local copy in the Advanced settings below.

Documentation
(click the ? to pin this window)"; - private string WorkoutTitleDocumentation => $"Allows you to customize how your workout title will appear in Garmin Connect using Handlebars templates. Some characters are not allowed, these characters will automatically be replaced with `-`. Below are the data fields you can use in the template:

  • PelotonWorkoutTitle
  • PelotonInstructorName

Documentation
(click the ? to pin this window)"; + private string WorkoutTitleDocumentation => $"Allows you to customize how your workout title will appear in Garmin Connect using Handlebars templates. Some characters are not allowed, these characters will automatically be replaced with `-`. Below are the data fields you can use in the template:

  • PelotonWorkoutTitle
  • PelotonInstructorName

Documentation
(click the ? to pin this window)"; private string LapTypesDocumentation => $"Lap type defines how/when P2G will create a new Lap within a workout. This can be customized per Cardio type. To read more about each Lap Type please see the documentation.

Documentation
(click the ? to pin this window)"; private string StrengthDocumentation => $"Some Strength workouts have you do an exercise for time rather than for a specific number of reps. This setting allows you to customize how P2G should estimate the number of reps that were done in a time based exercise.

Documentation
(click the ? to pin this window)"; - private string AdvancedDocumentation => $"Most users should not need to modify these settings. These settings should only be modified if you are trying to solve a specific problem. Please be sure you've read the documentation about these before modifying.

Documentation
(click the ? to pin this window)"; + private string CustomZoneHandlingDocumentation => $"If you are able to set your HR and Power Zones in Garmin Connect, then you do not need to enable these settings, Garmin will automatically calculate your time in zones correctly. If you are not able to configure your zones in Garmin Connect, then you can enable these settings to have P2G calculate time in zone information.

If these are enabled, its likely Garmin will not calculate Training Load, Effect, or related data points.

Documentation
(click the ? to pin this window)"; + private string CustomDeviceInfoDocumentation => $"The device used for a given exercise type can impact what additional data Garmin Connect will calculate (like TE, TSS, and VO2). P2G provides reasonable defaults, but you can customize what device you would like used here.

The None Exercise Type serves as a global default. Meaning P2G will default to using this device if no more specific override is configured for a given Exercise type.

If you delete all devices, P2G will restore its internal defaults.

Documentation
(click the ? to pin this window)"; + private string SaveLocalCopyHint => $"Files will be saved to: {outputDirectory}."; } diff --git a/src/UnitTests/Common/Service/SettingServiceTests.cs b/src/UnitTests/Common/Service/SettingServiceTests.cs new file mode 100644 index 000000000..1e00f677d --- /dev/null +++ b/src/UnitTests/Common/Service/SettingServiceTests.cs @@ -0,0 +1,147 @@ +using Common; +using Common.Database; +using Common.Dto; +using Common.Dto.Peloton; +using Common.Dto.Garmin; +using Common.Service; +using FluentAssertions; +using Moq; +using Moq.AutoMock; +using NUnit.Framework; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace UnitTests.Common.Service; +#pragma warning disable CS0618 // Type or member is obsolete +public class SettingServiceTests +{ + [Test] + public async Task GetCustomDeviceInfoAsync_Chooses_LegacyDeviceFile_First() + { + // SETUP + var mocker = new AutoMocker(); + var settingsService = mocker.CreateInstance(); + + + var settings = new Settings() + { + Format = new Format() + { + DeviceInfoPath = "./some/path/to.xml", + DeviceInfoSettings = Format.DefaultDeviceInfoSettings + } + }; + mocker.GetMock().Setup(x => x.GetSettingsAsync(It.IsAny())).ReturnsAsync(settings); + + var userDeviceInfo = new GarminDeviceInfo() + { + Name = "ThisDevice" + }; + mocker.GetMock().Setup(x => x.TryDeserializeXml("./some/path/to.xml", out userDeviceInfo)).Returns(true); + + // ACT + var chosenDeviceInfo = await settingsService.GetCustomDeviceInfoAsync(null); + + // ASSERT + chosenDeviceInfo.Name.Should().Be("ThisDevice", because: "If the user still has a device file registered then we should honor that to stay backwards compatible."); + } + + [Test] + public async Task GetCustomDeviceInfoAsync_When_LegacyDeviceFile_Fails_FallBackTo_NewSettings_IfAvailable() + { + // SETUP + var mocker = new AutoMocker(); + var settingsService = mocker.CreateInstance(); + + var settings = new Settings() + { + Format = new Format() + { + DeviceInfoPath = "./some/path/to.xml", + DeviceInfoSettings = new Dictionary() { { WorkoutType.None, GarminDevices.TACXDevice } } + } + }; + mocker.GetMock().Setup(x => x.GetSettingsAsync(It.IsAny())).ReturnsAsync(settings); + + GarminDeviceInfo userDeviceInfo = null; + mocker.GetMock().Setup(x => x.TryDeserializeXml("./some/path/to.xml", out userDeviceInfo)).Returns(false); + + // ACT + var chosenDeviceInfo = await settingsService.GetCustomDeviceInfoAsync(null); + + // ASSERT + chosenDeviceInfo.Name.Should().Be("TacxTrainingAppWin", because: "If the legacy device fails to deserialize then we should fall back to the new Settings."); + } + + [Test] + public async Task GetCustomDeviceInfoAsync_When_LegacyDeviceFile_Fails_And_NoNewSettings_FallsBackToDefaults() + { + // SETUP + var mocker = new AutoMocker(); + var settingsService = mocker.CreateInstance(); + + var settings = new Settings() + { + Format = new Format() + { + DeviceInfoPath = "./some/path/to.xml", + } + }; + mocker.GetMock().Setup(x => x.GetSettingsAsync(It.IsAny())).ReturnsAsync(settings); + + GarminDeviceInfo userDeviceInfo = null; + mocker.GetMock().Setup(x => x.TryDeserializeXml("./some/path/to.xml", out userDeviceInfo)).Returns(false); + + // ACT + var chosenDeviceInfo = await settingsService.GetCustomDeviceInfoAsync(null); + + // ASSERT + chosenDeviceInfo.Name.Should().Be("Forerunner 945", because: "If all fails we should fall back to the default Settings."); + } + + [Test] + public async Task GetCustomDeviceInfoAsync_Choose_CorrectDevice_For_WorkoutType([Values] WorkoutType workoutType) + { + // SETUP + var mocker = new AutoMocker(); + var settingsService = mocker.CreateInstance(); + + var deviceInfoSettings = new Dictionary() + { + { WorkoutType.None, new GarminDeviceInfo() { Name = "MyDefaultDevice" } }, + { WorkoutType.Circuit, GarminDevices.Forerunner945 }, + { WorkoutType.Cycling, GarminDevices.TACXDevice }, + { WorkoutType.Meditation, GarminDevices.EpixDevice }, + }; + + var settings = new Settings() + { + Format = new Format() + { + DeviceInfoSettings = deviceInfoSettings + } + }; + mocker.GetMock().Setup(x => x.GetSettingsAsync(It.IsAny())).ReturnsAsync(settings); + + GarminDeviceInfo userDeviceInfo = null; + mocker.GetMock().Setup(x => x.TryDeserializeXml("./some/path/to.xml", out userDeviceInfo)).Returns(false); + + // ACT + var workout = new Workout + { + Fitness_Discipline = workoutType.ToFitnessDiscipline().fitnessDiscipline, + Is_Outdoor = workoutType.ToFitnessDiscipline().isOutdoor, + }; + var chosenDeviceInfo = await settingsService.GetCustomDeviceInfoAsync(workout); + + // ASSERT + if (deviceInfoSettings.TryGetValue(workoutType, out var expectedDeviceInfo)) + { + chosenDeviceInfo.Should().Be(expectedDeviceInfo); + } else + { + chosenDeviceInfo.Should().Be(deviceInfoSettings[WorkoutType.None]); + } + } +} +#pragma warning restore CS0618 // Type or member is obsolete \ No newline at end of file diff --git a/src/UnitTests/Conversion/ConverterTests.cs b/src/UnitTests/Conversion/ConverterTests.cs index bfd3b447b..469212ba1 100644 --- a/src/UnitTests/Conversion/ConverterTests.cs +++ b/src/UnitTests/Conversion/ConverterTests.cs @@ -4,9 +4,7 @@ using Common.Dto.Peloton; using Common.Service; using Conversion; -using Dynastream.Fit; using FluentAssertions; -using Moq; using Moq.AutoMock; using NUnit.Framework; using System; @@ -451,137 +449,6 @@ public void GetHeartRateSummary_CalorieSlug_ReturnsSummary() hr.Average_Value.Should().Be(50); } - [TestCase(FitnessDiscipline.Bike_Bootcamp)] - [TestCase(FitnessDiscipline.Circuit)] - [TestCase(FitnessDiscipline.Cardio)] - [TestCase(FitnessDiscipline.Cycling)] - [TestCase(FitnessDiscipline.Meditation)] - [TestCase(FitnessDiscipline.Running)] - [TestCase(FitnessDiscipline.Strength)] - [TestCase(FitnessDiscipline.Stretching)] - [TestCase(FitnessDiscipline.Walking)] - [TestCase(FitnessDiscipline.Yoga)] - public async Task GetDeviceInfo_ChoosesUserDevice_WhenProvided(FitnessDiscipline sport) - { - // SETUP - var mocker = new AutoMocker(); - var converter = mocker.CreateInstance(); - var settingsService = mocker.GetMock(); - - GarminDeviceInfo outDevice = new GarminDeviceInfo() - { - Name = "UserDevice", // Max 20 Chars - ProductID = GarminProduct.Amx, - UnitId = 1, - Version = new GarminDeviceVersion() - { - VersionMajor = 11, - VersionMinor = 10, - BuildMajor = 0, - BuildMinor = 0, - } - }; - settingsService.Setup(s => s.GetCustomDeviceInfoAsync(It.IsAny())).ReturnsAsync(outDevice); - - // ACT - var deviceInfo = await converter.GetDeviceInfo1(sport, new Settings()); - - // ASSERT - deviceInfo.Name.Should().Be("UserDevice"); - deviceInfo.ProductID.Should().Be(GarminProduct.Amx); - deviceInfo.UnitId.Should().Be(1); - deviceInfo.Version.Should().NotBeNull(); - deviceInfo.Version.VersionMajor.Should().Be(11); - deviceInfo.Version.VersionMinor.Should().Be(10); - deviceInfo.Version.BuildMajor.Should().Be(0); - deviceInfo.Version.BuildMinor.Should().Be(0); - } - - [Test] - public async Task GetDeviceInfo_FallsBackToDefault_WhenUserDeviceFailsToDeserialize() - { - // SETUP - var mocker = new AutoMocker(); - var config = new Settings() { Format = new Format() { DeviceInfoPath = "somePath" } }; - mocker.Use(config); - - var converter = mocker.CreateInstance(); - - var fileHandler = mocker.GetMock(); - GarminDeviceInfo outDevice = null; - fileHandler.Setup(x => x.TryDeserializeXml("somePath", out outDevice)) - .Callback(() => - { - outDevice = new GarminDeviceInfo(); ; - }) - .Returns(false); - - // ACT - var deviceInfo = await converter.GetDeviceInfo1(FitnessDiscipline.Bike_Bootcamp, config); - - // ASSERT - deviceInfo.Name.Should().Be("Forerunner 945"); - deviceInfo.ProductID.Should().Be(GarminProduct.Fr945); - deviceInfo.UnitId.Should().Be(1); - deviceInfo.Version.Should().NotBeNull(); - deviceInfo.Version.VersionMajor.Should().Be(19); - deviceInfo.Version.VersionMinor.Should().Be(2); - deviceInfo.Version.BuildMajor.Should().Be(0); - deviceInfo.Version.BuildMinor.Should().Be(0); - } - - [Test] - public async Task GetDeviceInfo_ForCycling_ShouldReturn_CyclingDevice() - { - // SETUP - var mocker = new AutoMocker(); - var converter = mocker.CreateInstance(); - var config = new Settings(); - - // ACT - var deviceInfo = await converter.GetDeviceInfo1(FitnessDiscipline.Cycling, config); - - // ASSERT - deviceInfo.Name.Should().Be("TacxTrainingAppWin"); - deviceInfo.ProductID.Should().Be(GarminProduct.TacxTrainingAppWin); - deviceInfo.UnitId.Should().Be(1); - deviceInfo.Version.Should().NotBeNull(); - deviceInfo.Version.VersionMajor.Should().Be(1); - deviceInfo.Version.VersionMinor.Should().Be(30); - deviceInfo.Version.BuildMajor.Should().Be(0); - deviceInfo.Version.BuildMinor.Should().Be(0); - } - - [TestCase(FitnessDiscipline.Bike_Bootcamp)] - [TestCase(FitnessDiscipline.Circuit)] - [TestCase(FitnessDiscipline.Cardio)] - [TestCase(FitnessDiscipline.Meditation)] - [TestCase(FitnessDiscipline.Running)] - [TestCase(FitnessDiscipline.Strength)] - [TestCase(FitnessDiscipline.Stretching)] - [TestCase(FitnessDiscipline.Walking)] - [TestCase(FitnessDiscipline.Yoga)] - public async Task GetDeviceInfo_ForNonCycling_ShouldReturn_DefaultDevice(FitnessDiscipline sport) - { - // SETUP - var mocker = new AutoMocker(); - var converter = mocker.CreateInstance(); - var config = new Settings(); - - // ACT - var deviceInfo = await converter.GetDeviceInfo1(sport, config); - - // ASSERT - deviceInfo.Name.Should().Be("Forerunner 945"); - deviceInfo.ProductID.Should().Be(GarminProduct.Fr945); - deviceInfo.UnitId.Should().Be(1); - deviceInfo.Version.Should().NotBeNull(); - deviceInfo.Version.VersionMajor.Should().Be(19); - deviceInfo.Version.VersionMinor.Should().Be(2); - deviceInfo.Version.BuildMajor.Should().Be(0); - deviceInfo.Version.BuildMinor.Should().Be(0); - } - // Workout Object // manual source // workout source @@ -712,9 +579,9 @@ public Metric GetHeartRateSummary1(WorkoutSamples workoutSamples) { return base.GetHeartRateSummary(workoutSamples); } - public Task GetDeviceInfo1(FitnessDiscipline sport, Settings settings) + public Task GetDeviceInfo1(Workout workout) { - return base.GetDeviceInfoAsync(sport, settings); + return base.GetDeviceInfoAsync(workout); } public ushort? GetCyclingFtp1(Workout workout, UserData userData) diff --git a/src/UnitTests/Conversion/FitConverterTests.cs b/src/UnitTests/Conversion/FitConverterTests.cs index a4a5daa39..12ee6050d 100644 --- a/src/UnitTests/Conversion/FitConverterTests.cs +++ b/src/UnitTests/Conversion/FitConverterTests.cs @@ -1,10 +1,12 @@ using Common; using Common.Dto; using Common.Dto.Garmin; +using Common.Dto.Peloton; using Common.Service; using Conversion; using Dynastream.Fit; using FluentAssertions; +using Moq; using Moq.AutoMock; using NUnit.Framework; using System.Collections.Generic; @@ -97,7 +99,11 @@ public async Task Fit_Converter_Creates_Valid_Fit(string filename, PreferredLapT }; var autoMocker = new AutoMocker(); - var converter = autoMocker.CreateInstance(); + var converter = autoMocker.CreateInstance(); + + autoMocker.GetMock() + .Setup(x => x.GetCustomDeviceInfoAsync(It.IsAny())) + .ReturnsAsync(GarminDevices.TACXDevice); var convertedMesgs = await converter.ConvertForTest(workoutPath, settings); diff --git a/vNextReleaseNotes.md b/vNextReleaseNotes.md index 710445d81..2ef097497 100644 --- a/vNextReleaseNotes.md +++ b/vNextReleaseNotes.md @@ -4,6 +4,8 @@ ## Features - [#610] UI - Add more workout data to Sync page +- [#606] Robust support for configuring what Devices are used on uploaded Garmin Workouts to increase flexibility for users to fix issues with TE/TSS/V02 not updating on Garmin + - If you have previously configured a custom `Format.DeviceInfoPath`, on startup this device config will be migrated to the new settings format automatically ## Fixes @@ -14,15 +16,15 @@ - Console - `console-stable` - `console-latest` - - `console-v4.1.0` + - `console-v4.2.0` - `console-v4` - Api - `api-stable` - `api-latest` - - `api-v4.1.0` + - `api-v4.2.0` - `api-v4` - WebUI - `webui-stable` - `webui-latest` - - `webui-v4.1.0` + - `webui-v4.2.0` - `webui-v4`