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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,18 @@ update_garmin:
update_garmin_activities:
$(GARMINDB_CLI) --activities --download --import --analyze --latest

download_garmin_golf:
$(GARMINDB_CLI) --golf --download

redownload_garmin_golf:
$(GARMINDB_CLI) --golf --download --overwrite

build_garmin_golf:
$(GARMINDB_CLI) --golf --import

update_garmin_golf:
$(GARMINDB_CLI) --golf --download --import --latest

copy_garmin_latest:
$(GARMINDB_CLI) --all --copy --import --analyze --latest

Expand All @@ -290,6 +302,9 @@ clean_garmin_monitoring_dbs:
clean_garmin_activities_dbs:
$(GARMINDB_CLI) --delete_db --activities

clean_garmin_golf_dbs:
$(GARMINDB_CLI) --delete_db --golf


#
# FitBit target
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ What they can do:
* Automatically download and import Garmin daily monitoring files (all day heart rate, activity, climb/descend, stress, and intensity minutes) from the user's Garmin Connect "Daily Summary" page.
* Extract sleep, weight, and resting heart rate data from Garmin Connect, store it as JSON files, and import it into the DB.
* Download and import activity files from Garmin Connect. A summary table for all activities and more detailed data for some activity types. Lap and record entries for activities.
* Download and import Golf files from Garmin Connect, including scorecard summaries, holes, and detailed shot types.
* Summarizing data into a DB with tables containing daily, weekly, monthly, and yearly summaries.
* Graph your data from the commandline or with Jupyter notebooks.
* Retain downloaded JSON and FIT files so that the DB can be regenerated without connecting to or redownloading data from Garmin Connect.
Expand Down
1 change: 1 addition & 0 deletions garmindb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@
from .activities_fit_data import GarminActivitiesFitData
from .garmin_tcx_data import GarminTcxData
from .garmin_json_data import GarminJsonSummaryData, GarminJsonDetailsData
from .garmin_golf_data import GarminGolfScorecardData, GarminGolfScorecardDetailData, GarminGolfShotData
51 changes: 51 additions & 0 deletions garmindb/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ class Download():
garmin_connect_daily_hydration_url = garmin_connect_usersummary_url + "/hydration/allData"
garmin_connect_hrv_url = "/hrv-service/hrv"

garmin_connect_golf_url = "/gcs-golfcommunity/api/v2"
garmin_connect_golf_scorecard_summary = garmin_connect_golf_url + "/scorecard/summary"
garmin_connect_golf_scorecard_detail = garmin_connect_golf_url + "/scorecard/detail"
garmin_connect_golf_shot = garmin_connect_golf_url + "/shot/scorecard"

# https://connect.garmin.com/modern/proxy/usersummary-service/usersummary/hydration/allData/2019-11-29

download_days_overlap = 3 # Existing donloaded data will be redownloaded and overwritten if it is within this number of days of now.
Expand Down Expand Up @@ -334,3 +339,49 @@ def get_hrv(self, directory, date, days, overwrite):
"""Download the heart rate variability (HRV) data from Garmin Connect and save to a JSON file."""
root_logger.info("Getting hrv: %s (%d)", date, days)
self.__get_stat(self.__get_hrv_day, directory, date, days, overwrite)

def __get_golf_scorecard_summaries(self):
root_logger.info("get_golf_scorecard_summaries")
try:
return self.garth.connectapi(self.garmin_connect_golf_scorecard_summary)
except GarthHTTPError as e:
root_logger.error("Exception getting golf summaries: %s", e)

def __save_golf_scorecard_details(self, directory, scorecard_id_str, overwrite):
root_logger.debug("save_golf_scorecard_details")
json_filename = f'{directory}/scorecard_detail_{scorecard_id_str}'
try:
url = f'{self.garmin_connect_golf_scorecard_detail}/{scorecard_id_str}'
self.save_json_to_file(json_filename, self.garth.connectapi(url), overwrite)
except GarthHTTPError as e:
root_logger.error("Exception getting golf details %s", e)

def __save_golf_shot_data(self, directory, scorecard_id_str, overwrite):
root_logger.debug("save_golf_shot_data")
json_filename = f'{directory}/scorecard_shot_{scorecard_id_str}'
try:
url = f'{self.garmin_connect_golf_shot}/{scorecard_id_str}/hole'
self.save_json_to_file(json_filename, self.garth.connectapi(url), overwrite)
except GarthHTTPError as e:
root_logger.error("Exception getting golf shot data %s", e)

def get_golf_scorecards(self, directory, overwrite=False):
"""Download golf scorecards files from Garmin Connect and save the JSON files."""
logger.info("Getting golf scorecards to: '%s'", directory)
summary_response = self.__get_golf_scorecard_summaries()
summaries = summary_response.get("scorecardSummaries", []) if summary_response else []
for scorecard in tqdm(summaries or [], unit='scorecards'):
scorecard_id_str = str(scorecard.get("id"))
if not scorecard_id_str:
continue

root_logger.info("get_golf_scorecards: %s", scorecard_id_str)
json_filename = f'{directory}/scorecard_summary_{scorecard_id_str}'

if not os.path.isfile(json_filename + '.json') or overwrite:
self.save_json_to_file(json_filename, scorecard)
self.__save_golf_scorecard_details(directory, scorecard_id_str, overwrite)
self.__save_golf_shot_data(directory, scorecard_id_str, overwrite)
time.sleep(1)
else:
root_logger.info("get_golf_scorecards: skipping %s", scorecard_id_str)
6 changes: 5 additions & 1 deletion garmindb/garmin_connect_config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ def get_rhr_dir(self):
"""Return the configured directory of where the resting heart rate files will be stored."""
return self.__create_dir_if_needed(self.get_base_dir() + os.sep + 'RHR')

def get_golf_dir(self):
"""Return the configured directory of where the golf files will be stored."""
return self.__create_dir_if_needed(self.get_base_dir() + os.sep + 'Golf')

def get_fitbit_dir(self):
"""Return the configured directory of where the FitBit will be stored."""
return self.__create_dir_if_needed(self.get_base_dir() + os.sep + 'FitBitFiles')
Expand Down Expand Up @@ -262,7 +266,7 @@ def is_stat_enabled(self, statistic):
def enabled_stats(self):
"""Return all enabled statistics as a list of string names."""
if not self.enabled_statistics:
json_enabled_stats_dict = self.config.get('enabled_stats', {stat_name: True for stat_name in list(Statistics)})
json_enabled_stats_dict = self.config.get('enabled_stats', {stat_name: True for stat_name in list(Statistics.__members__)})
self.enabled_statistics = [Statistics.from_string(stat_name) for stat_name, stat_enabled in json_enabled_stats_dict.items() if stat_enabled]
return self.enabled_statistics

Expand Down
105 changes: 105 additions & 0 deletions garmindb/garmin_golf_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Objects for importing Garmin golf data from Garmin Connect downloads."""

__author__ = "Tom Goetz"
__copyright__ = "Copyright Tom Goetz"
__license__ = "GPL"

import logging
import dateutil.parser

from idbutils import JsonFileProcessor

from .garmindb import GarminDb, GolfScorecard, GolfHole, GolfShot

logger = logging.getLogger(__file__)


class GarminGolfScorecardData(JsonFileProcessor):
"""Class for importing Garmin golf data from JSON formatted Garmin Connect downloads."""

def __init__(self, db_params, input_dir, latest, debug):
super().__init__(r'scorecard_summary_\d*\.json', input_dir=input_dir, latest=latest, debug=debug)
self.garmin_db = GarminDb(db_params, self.debug - 1)
self.conversions = {}

def _process_json(self, json_data):
with self.garmin_db.managed_session() as session:
start_time = dateutil.parser.parse(self._get_field(json_data, 'startTime'), ignoretz=True) if json_data.get('startTime') else None
end_time = dateutil.parser.parse(self._get_field(json_data, 'endTime'), ignoretz=True) if json_data.get('endTime') else None

scorecard = {
'id': json_data.get('id'),
'start_time': start_time,
'end_time': end_time,
'course_name': self._get_field(json_data, 'courseName'),
'strokes': self._get_field(json_data, 'strokes', int),
'score_without_handicap': self._get_field(json_data, 'scoreWithoutHandicap', int),
'holes_completed': self._get_field(json_data, 'holesCompleted', int),
'round_type': self._get_field(json_data, 'roundType'),
'score_type': self._get_field(json_data, 'scoreType'),
'handicapped_strokes': self._get_field(json_data, 'handicappedStrokes', int)
}
GolfScorecard.s_insert_or_update(session, scorecard, ignore_none=True)
return 1


class GarminGolfScorecardDetailData(JsonFileProcessor):
def __init__(self, db_params, input_dir, latest, debug):
super().__init__(r'scorecard_detail_\d*\.json', input_dir=input_dir, latest=latest, debug=debug)
self.garmin_db = GarminDb(db_params, self.debug - 1)
self.conversions = {}

def _process_json(self, json_data):
with self.garmin_db.managed_session() as session:
scorecard_id = json_data.get('scorecardId') or json_data.get('id')
if not scorecard_id:
return 0
holes = json_data.get('holes', [])
count = 0
for hole in holes:
hole_data = {
'scorecard_id': scorecard_id,
'hole_number': self._get_field(hole, 'holeNumber', int),
'par': self._get_field(hole, 'par', int),
'strokes': self._get_field(hole, 'strokes', int),
'putts': self._get_field(hole, 'putts', int)
}
GolfHole.s_insert_or_update(session, hole_data, ignore_none=True)
count += 1
return count


class GarminGolfShotData(JsonFileProcessor):
def __init__(self, db_params, input_dir, latest, debug):
super().__init__(r'scorecard_shot_\d*\.json', input_dir=input_dir, latest=latest, debug=debug)
self.garmin_db = GarminDb(db_params, self.debug - 1)
self.conversions = {}

def _process_json(self, json_data):
with self.garmin_db.managed_session() as session:
scorecard_id = json_data.get('scorecardId')
holes = json_data.get('holeShots', json_data.get('holes', []))
if not holes and type(json_data) is list:
holes = json_data

count = 0
for hole in holes:
hole_num = hole.get('holeNumber')
if not hole_num:
continue
shots = hole.get('shots', [])
for shot in shots:
shot_data = {
'id': shot.get('id'),
'scorecard_id': shot.get('scorecardId', hole.get('scorecardId', scorecard_id)),
'hole_number': hole_num,
'shot_number': self._get_field(shot, 'shotOrder', int),
'club_name': self._get_field(shot, 'clubName'),
'distance_meters': self._get_field(shot, 'meters', float) or self._get_field(shot, 'distanceInMeters', float),
'shot_type': self._get_field(shot, 'shotType')
}
if not shot_data['scorecard_id']:
continue
GolfShot.s_insert_or_update(session, shot_data, ignore_none=True)
count += 1
return count
1 change: 1 addition & 0 deletions garmindb/garmindb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
from .activities_db import ActivitiesDb, Activities, ActivityLaps, ActivityRecords, ActivitiesDevices, ActivitySplits, SportActivities, StepsActivities, \
PaddleActivities, CycleActivities, ClimbingActivities
from .garmin_summary_db import GarminSummaryDb, Summary, YearsSummary, MonthsSummary, WeeksSummary, DaysSummary, IntensityHR
from .golf_db import GolfScorecard, GolfHole, GolfShot
63 changes: 63 additions & 0 deletions garmindb/garmindb/golf_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Objects representing golf data from a Garmin device."""

__author__ = "Tom Goetz"
__copyright__ = "Copyright Tom Goetz"
__license__ = "GPL"

from sqlalchemy import Column, Integer, DateTime, String, Float, ForeignKey

import idbutils
from .garmin_db import GarminDb


class GolfScorecard(GarminDb.Base, idbutils.DbObject):
"""Class representing a Garmin golf scorecard."""

__tablename__ = 'golf_scorecards'

db = GarminDb
table_version = 1

id = Column(Integer, primary_key=True)
start_time = Column(DateTime)
end_time = Column(DateTime)
course_name = Column(String)
strokes = Column(Integer)
score_without_handicap = Column(Integer)
holes_completed = Column(Integer)
round_type = Column(String)
score_type = Column(String)
handicapped_strokes = Column(Integer)


class GolfHole(GarminDb.Base, idbutils.DbObject):
"""Class representing a Garmin golf hole within a scorecard."""

__tablename__ = 'golf_holes'

db = GarminDb
table_version = 1

id = Column(Integer, primary_key=True, autoincrement=True)
scorecard_id = Column(Integer, ForeignKey('golf_scorecards.id'), nullable=False)
hole_number = Column(Integer)
par = Column(Integer)
strokes = Column(Integer)
putts = Column(Integer)


class GolfShot(GarminDb.Base, idbutils.DbObject):
"""Class representing a Garmin golf shot."""

__tablename__ = 'golf_shots'

db = GarminDb
table_version = 1

id = Column(Integer, primary_key=True, autoincrement=True)
scorecard_id = Column(Integer, ForeignKey('golf_scorecards.id'), nullable=False)
hole_number = Column(Integer)
shot_number = Column(Integer)
club_name = Column(String)
distance_meters = Column(Float)
shot_type = Column(String)
1 change: 1 addition & 0 deletions garmindb/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Statistics(enum.Enum):
weight = 6
activities = 7
hrv = 8
golf = 9

@classmethod
def from_string(cls, string):
Expand Down
23 changes: 23 additions & 0 deletions scripts/garmindb_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class GarminDbMain():
Statistics.rhr : GarminDb,
Statistics.weight : GarminDb,
Statistics.hrv : GarminDb,
Statistics.golf : GarminDb,
Statistics.activities : ActivitiesDb
}

Expand Down Expand Up @@ -168,6 +169,12 @@ def download_data(self, overwrite, latest, stats):
download.get_hrv(hrv_dir, date, days, overwrite)
root_logger.info("Saved hrv files for %s (%d) to %s for processing", date, days, hrv_dir)

if Statistics.golf in stats:
golf_dir = self.gc_config.get_golf_dir()
root_logger.info("Downloading golf data to %s", golf_dir)
download.get_golf_scorecards(golf_dir, overwrite)
root_logger.info("Saved golf files to %s for processing", golf_dir)


def import_data(self, debug, latest, stats):
"""Import previously downloaded Garmin data into the database."""
Expand Down Expand Up @@ -256,6 +263,21 @@ def import_data(self, debug, latest, stats):
if gfd.file_count() > 0:
gfd.process_files(ActivityFitFileProcessor(self.gc_config.get_db_params(), self.plugin_manager, debug))

if Statistics.golf in stats:
from garmindb import GarminGolfScorecardData, GarminGolfScorecardDetailData, GarminGolfShotData
golf_dir = self.gc_config.get_golf_dir()
ggsd = GarminGolfScorecardData(self.gc_config.get_db_params(), golf_dir, latest, debug)
if ggsd.file_count() > 0:
ggsd.process()

ggsdd = GarminGolfScorecardDetailData(self.gc_config.get_db_params(), golf_dir, latest, debug)
if ggsdd.file_count() > 0:
ggsdd.process()

ggshd = GarminGolfShotData(self.gc_config.get_db_params(), golf_dir, latest, debug)
if ggshd.file_count() > 0:
ggshd.process()


def analyze_data(self, debug):
"""Analyze the downloaded and imported Garmin data and create summary tables."""
Expand Down Expand Up @@ -330,6 +352,7 @@ def main(argv):
stats_group.add_argument("-m", "--monitoring", help="Download and/or import monitoring data.", dest='stats', action='append_const', const=Statistics.monitoring)
stats_group.add_argument("-r", "--rhr", help="Download and/or import resting heart rate data.", dest='stats', action='append_const', const=Statistics.rhr)
stats_group.add_argument("--hrv", help="Download and/or import heart rate variability data.", dest='stats', action='append_const', const=Statistics.hrv)
stats_group.add_argument("--golf", help="Download and/or import golf data.", dest='stats', action='append_const', const=Statistics.golf)
stats_group.add_argument("-s", "--sleep", help="Download and/or import sleep data.", dest='stats', action='append_const', const=Statistics.sleep)
stats_group.add_argument("-w", "--weight", help="Download and/or import weight data.", dest='stats', action='append_const', const=Statistics.weight)
modifiers_group = parser.add_argument_group('Modifiers')
Expand Down
9 changes: 6 additions & 3 deletions test/test_garmin_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import fitfile

from garmindb import GarminConnectConfigManager, GarminSleepFitData, SleepFitFileProcessor
from garmindb.garmindb import GarminDb, Attributes, Device, DeviceInfo, File, Weight, Stress, Sleep, SleepEvents, RestingHeartRate, Hrv
from garmindb.garmindb import GarminDb, Attributes, Device, DeviceInfo, File, Weight, Stress, Sleep, SleepEvents, RestingHeartRate, Hrv, GolfScorecard, GolfHole, GolfShot

from test_db_base import TestDBBase

Expand Down Expand Up @@ -44,9 +44,12 @@ def setUpClass(cls):
'sleep_table': Sleep,
'sleep_events_table': SleepEvents,
'resting_heart_rate_table': RestingHeartRate,
'hrv_table': Hrv
'hrv_table': Hrv,
'golf_scorecards_table': GolfScorecard,
'golf_holes_table': GolfHole,
'golf_shots_table': GolfShot
}
super().setUpClass(cls.garmin_db, table_dict, table_can_be_empty=['hrv_table'])
super().setUpClass(cls.garmin_db, table_dict, table_can_be_empty=['hrv_table', 'golf_scorecards_table', 'golf_holes_table', 'golf_shots_table'])

def check_col_stat(self, value_name, value, bounds):
min_value, max_value = bounds
Expand Down