commit 02b3dee41a30cd886b82ba43932ec8588c63e201 Author: thomasabishop Date: Sun Mar 16 19:04:45 2025 +0000 feat: complete export of activity summary and table creation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3a1e54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +db/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e59a148 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Garmin Exporter + +A Python application that exports activity and health metrics from my Garmin +Instinct 2 smart-watch and stores them in an SQLite database. + +## Metrics + +| Metric | Database unit | +| ----------------- | ------------------------------------ | +| `duration` | decimal minutes | +| `total_distance` | kilometeters (to two decimal places) | +| `avg_heart_rate` | beats/minute | +| `max_heart_rate` | beats/minute | +| `calories_burned` | integer | +| `cadence` | steps per minute | +| `latitude` | !conversion needed | +| `longitude` | !conversion needed | +| `altitude` | meters above sea level | +| `speed` | meters per second | diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..a679dbc --- /dev/null +++ b/src/app.py @@ -0,0 +1,27 @@ +import os + +from constants import ACTIVITY, MNT_POINT +from utils.create_tables import create_tables +from utils.create_views import create_views +from utils.decode_fit_file import decode_fit_file +from utils.parsers import parse_activity_summary + +if __name__ == "__main__": + is_mounted = os.path.isdir(MNT_POINT) + if is_mounted: + print(f"INFO Garmin device is mounted at {MNT_POINT}") + create_tables() + create_views() + with os.scandir(ACTIVITY) as entries: + files = [ + entry.path + for entry in entries + if entry.is_file() and entry.name.endswith("fit") + ] + for index, file in enumerate(files): + decoded = decode_fit_file(file) + decoded_dict = decoded[0] + activity_summary = parse_activity_summary(decoded_dict) + # print(activity_summary) + else: + print(f"ERROR No Garmin device mounted") diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..1b81a16 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,7 @@ +from pathlib import Path + +DIR = Path(__file__).parent.absolute().parent.absolute() +DB_PATH = DIR / "db" +DB_NAME = "garmin" +MNT_POINT = Path("/run/media/thomas/GARMIN/GARMIN") +ACTIVITY = MNT_POINT / "Activity" diff --git a/src/models/models.py b/src/models/models.py new file mode 100644 index 0000000..623bb22 --- /dev/null +++ b/src/models/models.py @@ -0,0 +1,23 @@ +from typing import TypedDict + + +class IActivitySummary(TypedDict): + timestamp: int + activity: str + date: str + time: str + duration: float + total_distance: float + avg_heart_rate: int + max_heart_rate: int + calories_burned: int + + +class IRun(TypedDict): + timestamp: int + latitude: int + longitude: int + heart_rate: int + speed: float + cadence: int + altitude: int diff --git a/src/services/database_connection.py b/src/services/database_connection.py new file mode 100644 index 0000000..e543e87 --- /dev/null +++ b/src/services/database_connection.py @@ -0,0 +1,44 @@ +import os +import sqlite3 +from typing import Optional + +from constants import DB_NAME, DB_PATH + + +class DatabaseConnection: + def __init__(self): + self.db_name = DB_NAME + self.db_path = DB_PATH + self.connection: Optional[sqlite3.Connection] = None + + def connect(self) -> Optional[sqlite3.Connection]: + if self.connection is not None: + return self.connection + + try: + if not os.path.exists(self.db_path): + os.makedirs(self.db_path) + print("INFO Created database directory") + self.connection = sqlite3.connect(f"{self.db_path}/{self.db_name}.db") + self.connection.execute("PRAGMA foreign_keys = ON") + return self.connection + + except Exception as e: + raise Exception(f"ERROR Problem connecting to database: {e}") + + def disconnect(self) -> None: + try: + if self.connection is not None: + self.connection.close() + self.connection = None + except Exception as e: + raise Exception(f"ERROR Problem disconnecting from database: {e}") + + def __enter__(self) -> sqlite3.Connection: + connection = self.connect() + if connection is None: + raise RuntimeError("Failed to establish database connection") + return connection + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.disconnect() diff --git a/src/services/sqlite_service.py b/src/services/sqlite_service.py new file mode 100644 index 0000000..9c92b48 --- /dev/null +++ b/src/services/sqlite_service.py @@ -0,0 +1,35 @@ +from typing import Optional + + +class SqliteService: + def __init__(self, db_connection): + self.connection = db_connection + self.cursor = db_connection.cursor() + + def _execute(self, sql, params=None, error_message: Optional[str] = None): + """Use for CREATE, INSERT, UPDATE, DELETE""" + try: + if params: + self.cursor.execute(sql, params) + else: + self.cursor.execute(sql) + self.connection.commit() + + except Exception as e: + if error_message: + raise Exception(f"ERROR {error_message}: {e}") + raise + + def _query(self, sql, params=None, error_message: Optional[str] = None): + """Use for SELECT""" + try: + if params: + self.cursor.execute(sql, params) + else: + self.cursor.execute(sql) + return self.cursor.fetchall() + + except Exception as e: + if error_message: + raise Exception(f"ERROR {error_message}: {e}") + raise diff --git a/src/services/table_service.py b/src/services/table_service.py new file mode 100644 index 0000000..ed94196 --- /dev/null +++ b/src/services/table_service.py @@ -0,0 +1,12 @@ +from services.sqlite_service import SqliteService + + +class TableService(SqliteService): + def __init__(self, db_connection): + super().__init__(db_connection) + + def create_table(self, sql, error_message): + self._execute(sql, error_message=error_message) + + def create_view(self, sql, error_message): + self._execute(sql, error_message=error_message) diff --git a/src/services/view_service.py b/src/services/view_service.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sql/create_tables.py b/src/sql/create_tables.py new file mode 100644 index 0000000..309247b --- /dev/null +++ b/src/sql/create_tables.py @@ -0,0 +1,29 @@ +CREATE_ACTIVITY_TABLE = """ +CREATE TABLE IF NOT EXISTS activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER UNIQUE, + activity STRING, + date STRING, + time STRING, + duration REAL, + total_distance REAL, + avg_heart_rate INTEGER, + max_heart_rate INTEGER, + calories_burned INTEGER +) +""" + +CREATE_RUN_POINTS_TABLE = """ +CREATE TABLE IF NOT EXISTS run_points ( + point_id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER, + timestamp INTEGER, + latitude INTEGER, + longitude INTEGER, + heart_rate INTEGER, + speed REAL, + cadence INTEGER, + altitude INTEGER +) + +""" diff --git a/src/sql/create_views.py b/src/sql/create_views.py new file mode 100644 index 0000000..6d7cf77 --- /dev/null +++ b/src/sql/create_views.py @@ -0,0 +1,5 @@ +CREATE_RUNS_VIEW = """ +CREATE VIEW IF NOT EXISTS runs AS + SELECT * FROM activity + WHERE activity = "Run" +""" diff --git a/src/utils/create_tables.py b/src/utils/create_tables.py new file mode 100644 index 0000000..e227826 --- /dev/null +++ b/src/utils/create_tables.py @@ -0,0 +1,20 @@ +from services.database_connection import DatabaseConnection +from services.table_service import TableService +from sql.create_tables import CREATE_ACTIVITY_TABLE, CREATE_RUN_POINTS_TABLE + + +def create_tables(): + with DatabaseConnection() as connection: + table_service = TableService(connection) + + print("INFO Creating `activity` table if not exists:") + print(CREATE_ACTIVITY_TABLE) + table_service.create_table( + CREATE_ACTIVITY_TABLE, "Problem creating `activity` table" + ) + + print("INFO Creating `run_points` table if not exists:") + print(CREATE_RUN_POINTS_TABLE) + table_service.create_table( + CREATE_RUN_POINTS_TABLE, "Problem creating `run_points` table" + ) diff --git a/src/utils/create_views.py b/src/utils/create_views.py new file mode 100644 index 0000000..3d5d362 --- /dev/null +++ b/src/utils/create_views.py @@ -0,0 +1,12 @@ +from services.database_connection import DatabaseConnection +from services.table_service import TableService +from sql.create_views import CREATE_RUNS_VIEW + + +def create_views(): + with DatabaseConnection() as connection: + table_service = TableService(connection) + + print("INFO Creating `runs` view if not exists:") + print(CREATE_RUNS_VIEW) + table_service.create_view(CREATE_RUNS_VIEW, "Problem creating `runs` view") diff --git a/src/utils/decode_fit_file.py b/src/utils/decode_fit_file.py new file mode 100644 index 0000000..98fd89b --- /dev/null +++ b/src/utils/decode_fit_file.py @@ -0,0 +1,10 @@ +from garmin_fit_sdk import Decoder, Stream + + +def decode_fit_file(file): + stream = Stream.from_file(file) + decoder = Decoder(stream) + messages = decoder.read( + convert_datetimes_to_dates=True, + ) + return messages diff --git a/src/utils/dump_metrics.py b/src/utils/dump_metrics.py new file mode 100644 index 0000000..70172fe --- /dev/null +++ b/src/utils/dump_metrics.py @@ -0,0 +1,18 @@ +import json +import os + +from garmin_fit_sdk import Decoder, Stream + + +def dump_metrics(mnt_path): + with os.scandir(mnt_path) as entries: + files = [entry.path for entry in entries if entry.is_file()] + fit_file = files[12] + stream = Stream.from_file(fit_file) + decoder = Decoder(stream) + messages, errors = decoder.read( + convert_datetimes_to_dates=False, + ) + with open("/home/thomas/Desktop/activity.json", "w") as f: + json.dump(messages, f, indent=4) + print(messages) diff --git a/src/utils/parsers.py b/src/utils/parsers.py new file mode 100644 index 0000000..348960e --- /dev/null +++ b/src/utils/parsers.py @@ -0,0 +1,21 @@ +from models.models import IActivitySummary + + +def parse_activity_summary(activity) -> IActivitySummary: + summary_tuple = activity["session_mesgs"] + summary_dict = summary_tuple[0] + garmin_ts = summary_dict["timestamp"] + unix_ts = int(garmin_ts.timestamp()) + readable_date = garmin_ts.strftime("%a %d %B %Y") + readable_time = garmin_ts.strftime("%H:%M") + return { + "activity": summary_dict.get("sport_profile_name", ""), + "timestamp": unix_ts, + "date": readable_date, + "time": readable_time, + "duration": round(summary_dict.get("total_elapsed_time", 0) / 60, 2), + "total_distance": round(summary_dict.get("total_distance", 0) / 1000, 2), + "avg_heart_rate": summary_dict.get("avg_heart_rate", ""), + "max_heart_rate": summary_dict.get("max_heart_rate", ""), + "calories_burned": summary_dict.get("total_calories", ""), + }