feat: complete export of activity summary and table creation

This commit is contained in:
thomasabishop 2025-03-16 19:04:45 +00:00
commit 02b3dee41a
16 changed files with 446 additions and 0 deletions

164
.gitignore vendored Normal file
View file

@ -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/

19
README.md Normal file
View file

@ -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 |

27
src/app.py Normal file
View file

@ -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")

7
src/constants.py Normal file
View file

@ -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"

23
src/models/models.py Normal file
View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

29
src/sql/create_tables.py Normal file
View file

@ -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
)
"""

5
src/sql/create_views.py Normal file
View file

@ -0,0 +1,5 @@
CREATE_RUNS_VIEW = """
CREATE VIEW IF NOT EXISTS runs AS
SELECT * FROM activity
WHERE activity = "Run"
"""

View file

@ -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"
)

12
src/utils/create_views.py Normal file
View file

@ -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")

View file

@ -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

18
src/utils/dump_metrics.py Normal file
View file

@ -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)

21
src/utils/parsers.py Normal file
View file

@ -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", ""),
}