Skip to content

Commit

Permalink
Merge pull request #43 from noppanut15/feat/garmin
Browse files Browse the repository at this point in the history
Add Support for Garmin Dive Computers
  • Loading branch information
noppanut15 authored Jan 16, 2025
2 parents fd64e06 + 4823fa5 commit d070d1e
Show file tree
Hide file tree
Showing 18 changed files with 1,886 additions and 21 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"python.testing.pytestArgs": [
"."
".",
"--cov=src/",
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ depthviz -i <input_file> -s <source> -o <output_video.mp4>
| :----------: | ---------------------------------------------------------------------------------------------------------------------------------------------------- | :-------: | ------------------------------------------------------------------------------------------- |
| `apnealizer` | Data from [Apnealizer](https://apnealizer.com/) application. | CSV | :white_check_mark: Supported |
| `shearwater` | Data from [Shearwater](https://shearwater.com/pages/shearwater-cloud) dive computers. | XML | :white_check_mark: Supported |
| `garmin` | Data from [Garmin](https://connect.garmin.com/) dive computers. | FIT | :construction: Under Development |
| `suunto` | Data from [Suunto](https://www.suunto.com/Support/faq-articles/dm5/how-do-i-import--export-dive-logs-to-dm5/) dive computers. | - | :x: [**Samples Needed**](https://github.com/noppanut15/depthviz/issues/15) :rotating_light: |
| `garmin` | Data from [Garmin](https://github.com/noppanut15/depthviz/blob/main/docs/GARMIN.md) dive computers. | FIT | :white_check_mark: Supported |
| `suunto` | Data from [Suunto](https://www.suunto.com/Support/faq-articles/dm5/how-do-i-import--export-dive-logs-to-dm5/) dive computers. | - | :warning: [Pending](https://github.com/noppanut15/depthviz/issues/15) |
| `manual` | Manual input without a dive computer. See the [Manual Mode](#️-manual-mode-creating-depth-overlays-without-a-dive-computer) section for more details. | CSV | :white_check_mark: Supported |

**Example**:
Expand Down Expand Up @@ -166,11 +166,11 @@ To share your dive data, please follow the detailed instructions in our "[**Dona

## ⚖️ License

This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.
This project is licensed under the Apache License 2.0. See the [LICENSE](https://github.com/noppanut15/depthviz/blob/main/LICENSE) file for details.

## 📦 CycloneDX SBOM

This project provides a CycloneDX Software Bill of Materials (SBOM) in JSON format. The SBOM is generated by the [GitHub Actions workflow](.github/workflows/deploy.yaml) and is available as an artifact for each release. The SBOM is generated using the [cyclonedx-python](https://github.com/CycloneDX/cyclonedx-python) library.
This project provides a CycloneDX Software Bill of Materials (SBOM) in JSON format. The SBOM is generated by the [GitHub Actions workflow](https://github.com/noppanut15/depthviz/blob/main/.github/workflows/deploy.yaml) and is available as an artifact for each release. The SBOM is generated using the [cyclonedx-python](https://github.com/CycloneDX/cyclonedx-python) library.

## 🌟 Like depthviz?

Expand Down
Binary file added assets/garmin_activity_cursor_click.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/garmin_export_fit_cursor_click.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions docs/GARMIN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Export a Diving Activity From the Garmin Connect Website

Follow these steps to export a diving activity from the Garmin Connect website:

1. From a web browser, sign into your [**Garmin Connect**](https://connect.garmin.com/signin/) account.

2. Select **Activities** from the navigation bar on the left.

3. Select **All Activities**.

4. Select the name of the diving session you would like to export.

<img src="https://raw.githubusercontent.com/noppanut15/depthviz/main/assets/garmin_activity_cursor_click.gif" alt="GIF showing the mouse clicking on the activity name in Garmin Connect" width="500">

5. Select the settings gear in the top right corner.

<img src="https://raw.githubusercontent.com/noppanut15/depthviz/main/assets/garmin_export_fit_cursor_click.gif" alt="GIF showing the mouse clicking on the settings gear and click Export File in Garmin Connect" width="500">

6. Select **Export File** from the drop-down menu. The file will then begin to download.

> [!IMPORTANT]
> If you get a `.zip` file, please extract the `.fit` file from the `.zip` file.

## Additional Resources

- [How Do I Export Data Out of Garmin Connect?](https://support.garmin.com/en-US/?faq=W1TvTPW8JZ6LfJSfK512Q8)
- [DepthViz Documentation](https://github.com/noppanut15/depthviz)
13 changes: 12 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ classifiers = [
python = "^3.9"
moviepy = "2.1.1"
types-tqdm = "^4.67.0.20241221"
garmin-fit-sdk = "^21.158.0"

[tool.poetry.group.dev.dependencies]
pytest = "8.3.4"
Expand Down Expand Up @@ -63,6 +64,10 @@ ignore_missing_imports = true
module = ["proglog"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = ["garmin_fit_sdk"]
ignore_missing_imports = true

[tool.pytest.ini_options]
pythonpath = "src"
testpaths = ["tests"]
Expand Down
25 changes: 24 additions & 1 deletion src/depthviz/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
)
from depthviz.parsers.apnealizer.csv_parser import ApnealizerCsvParser
from depthviz.parsers.shearwater.shearwater_xml_parser import ShearwaterXmlParser
from depthviz.parsers.garmin.fit_parser import GarminFitParser
from depthviz.parsers.manual.csv_parser import ManualCsvParser
from depthviz.core import DepthReportVideoCreator, DepthReportVideoCreatorError

Expand Down Expand Up @@ -47,7 +48,7 @@ def __init__(self) -> None:
"--source",
help="Source where the dive log was downloaded from. \
This is required to correctly parse your data.",
choices=["apnealizer", "shearwater", "manual"],
choices=["apnealizer", "shearwater", "garmin", "manual"],
required=True,
)
self.required_args.add_argument(
Expand Down Expand Up @@ -101,6 +102,20 @@ def create_video(
print(f"Video successfully created: {output_path}")
return 0

def is_user_input_valid(self, args: argparse.Namespace) -> bool:
"""
Check if the user input is valid.
"""
if args.decimal_places not in [0, 1, 2]:
print("Invalid value for decimal places. Valid values: 0, 1, 2.")
return False

if args.output[-4:] != ".mp4":
print("Invalid output file extension. Please provide a .mp4 file.")
return False

return True

def main(self) -> int:
"""
Main function for the depthviz command line interface.
Expand All @@ -110,13 +125,21 @@ def main(self) -> int:
return 1

args = self.parser.parse_args(sys.argv[1:])

print(BANNER)

# Check if the user input is valid before analyzing the dive log
# This is to avoid long processing times for invalid input
if not self.is_user_input_valid(args):
return 1

divelog_parser: DiveLogParser
if args.source == "apnealizer":
divelog_parser = ApnealizerCsvParser()
elif args.source == "shearwater":
divelog_parser = ShearwaterXmlParser()
elif args.source == "garmin":
divelog_parser = GarminFitParser()
elif args.source == "manual":
divelog_parser = ManualCsvParser()
else:
Expand Down
Empty file.
186 changes: 186 additions & 0 deletions src/depthviz/parsers/garmin/fit_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""
This module contains the GarminFitParser class
which is used to parse a Garmin FIT file from Garmin Connect
"""

import math
from typing import cast, Union
from datetime import datetime, timezone
from garmin_fit_sdk import Decoder, Stream

from depthviz.parsers.generic.generic_divelog_parser import DiveLogFileNotFoundError
from depthviz.parsers.generic.fit.fit_parser import (
DiveLogFitParser,
DiveLogFitInvalidFitFileError,
DiveLogFitInvalidFitFileTypeError,
DiveLogFitDiveNotFoundError,
)


class GarminFitParser(DiveLogFitParser):
"""
A class to parse a FIT file containing depth data.
"""

def __init__(self, selected_dive_idx: int = -1) -> None:
self.__time_data: list[float] = []
self.__depth_data: list[float] = []
self.__margin_start_time = 2

# Select the dive to be parsed (in case of multiple dives in FIT file)
self.__selected_dive_idx = selected_dive_idx

def convert_fit_epoch_to_datetime(self, fit_epoch: int) -> str:
"""
A method to convert the epoch time in the FIT file to a human-readable datetime string.
"""
epoch = fit_epoch + 631065600
return datetime.fromtimestamp(epoch, timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S (GMT)"
)

def select_dive(self, dive_summary: list[dict[str, Union[int, float]]]) -> int:
"""
A method to prompt the user to select a dive from the FIT file,
if there are multiple dives in the file.
"""
if len(dive_summary) == 1:
return 0
print("Multiple dives found in the FIT file. Please select a dive to import:\n")
for idx, dive in enumerate(dive_summary):
start_time = self.convert_fit_epoch_to_datetime(
cast(int, dive.get("start_time"))
)
print(
f"[{idx + 1}]: Dive {idx + 1}: Start Time: {start_time}, "
f"Max Depth: {cast(float, dive.get('max_depth')):.1f}m, "
f"Bottom Time: {cast(float, dive.get('bottom_time')):.1f}s"
)
try:
selected_dive_idx = (
int(
input(
f"\nEnter the dive number to import [1-{len(dive_summary)}]: "
)
)
- 1
)
print()
except ValueError as e:
raise DiveLogFitDiveNotFoundError(
f"Invalid Dive: Please enter a number between 1 and {len(dive_summary)}"
) from e

if selected_dive_idx >= len(dive_summary) or selected_dive_idx < 0:
raise DiveLogFitDiveNotFoundError(
f"Invalid Dive: Please enter a number between 1 and {len(dive_summary)}"
)
return selected_dive_idx

def parse(self, file_path: str) -> None:
"""
A method to parse a FIT file containing depth data.
"""
try:
stream = Stream.from_file(file_path)
decoder = Decoder(stream)
messages, errors = decoder.read(convert_datetimes_to_dates=False)
if errors:
raise errors[0]
except RuntimeError as e:
raise DiveLogFitInvalidFitFileError(f"Invalid FIT file: {file_path}") from e
except FileNotFoundError as e:
raise DiveLogFileNotFoundError(f"File not found: {file_path}") from e

try:
file_id_mesgs = messages.get("file_id_mesgs", [])
file_type = file_id_mesgs[0].get("type")
except (TypeError, IndexError) as e:
raise DiveLogFitInvalidFitFileError(
f"Invalid FIT file: {file_path}, cannot identify FIT type."
) from e

if file_type != "activity":
raise DiveLogFitInvalidFitFileTypeError(
f"Invalid FIT file type: You must import 'activity', not '{file_type}'"
)

dive_summary = []
dive_summary_mesgs = messages.get("dive_summary_mesgs", [])

for dive_summary_mesg in dive_summary_mesgs:
if dive_summary_mesg.get("reference_mesg") != "lap":
continue
lap_idx = dive_summary_mesg.get("reference_index")
lap_mesg = messages.get("lap_mesgs")[lap_idx]
bottom_time = dive_summary_mesg.get("bottom_time")
start_time = lap_mesg.get("start_time")
end_time = math.ceil(start_time + bottom_time)
dive_summary.append(
{
"start_time": start_time,
"end_time": end_time,
"max_depth": dive_summary_mesg.get("max_depth"),
"avg_depth": dive_summary_mesg.get("avg_depth"),
"bottom_time": bottom_time,
}
)

if not dive_summary:
raise DiveLogFitDiveNotFoundError(
f"Invalid FIT file: {file_path} does not contain any dive data"
)

# A prompt to select the dive if there are multiple dives in the FIT file
if self.__selected_dive_idx == -1:
self.__selected_dive_idx = self.select_dive(dive_summary)

records = messages.get("record_mesgs", [])
first_timestamp = None

for record in records:
timestamp_now = cast(int, record.get("timestamp"))
start_time = cast(
int, dive_summary[self.__selected_dive_idx].get("start_time")
)
end_time = cast(int, dive_summary[self.__selected_dive_idx].get("end_time"))

# Skip the records before the dive starts
if timestamp_now < start_time - self.__margin_start_time:
continue
# After the dive ends, stop getting the depth data
if timestamp_now > end_time:
break

if first_timestamp is None:
first_timestamp = timestamp_now

time = float(timestamp_now - first_timestamp)
depth = cast(float, record.get("depth"))
self.__time_data.append(time)
self.__depth_data.append(depth)

# If the depth is 0, the dive is considered to be ended
if round(depth, 3) == 0:
break

if not self.__time_data or not self.__depth_data:
raise DiveLogFitDiveNotFoundError(
f"Invalid Dive Data: Dive data not found in FIT file: {file_path}"
)

def get_time_data(self) -> list[float]:
"""
Returns the time data parsed from the FIT file.
Returns:
The time data parsed from the FIT file.
"""
return self.__time_data

def get_depth_data(self) -> list[float]:
"""
Returns the depth data parsed from the FIT file.
Returns:
The depth data parsed from the FIT file.
"""
return self.__depth_data
Empty file.
Loading

0 comments on commit d070d1e

Please sign in to comment.