trying to fix activity dl

This commit is contained in:
2025-10-07 05:44:52 -07:00
parent 44776dea4b
commit 68abb6e106
7 changed files with 43441 additions and 84 deletions

101
README.md
View File

@@ -37,84 +37,105 @@ pip install weasyprint
### Basic Usage
The application uses a subcommand-based CLI structure. Here are some basic examples:
Analyze a single workout file:
```bash
python main.py --file path/to/workout.fit --report --charts
python main.py analyze --file path/to/workout.fit --report --charts
```
Analyze all workouts in a directory:
```bash
python main.py --directory path/to/workouts --summary --format html
python main.py batch --directory path/to/workouts --summary --format html
```
Download from Garmin Connect:
Download and analyze latest workout from Garmin Connect:
```bash
python main.py --garmin-connect --report --charts --summary
python main.py analyze --garmin-connect --report --charts
```
Download all cycling activities from Garmin Connect:
```bash
python main.py download --all --limit 100 --output-dir data/garmin_downloads
```
Re-analyze previously downloaded workouts:
```bash
python main.py reanalyze --input-dir data/garmin_downloads --output-dir reports/reanalysis --charts --report
```
Show current configuration:
```bash
python main.py config --show
```
### Command Line Options
For a full list of commands and options, run:
```bash
python main.py --help
python main.py [command] --help
```
usage: main.py [-h] [--config CONFIG] [--verbose]
(--file FILE | --directory DIRECTORY | --garmin-connect | --workout-id WORKOUT_ID | --download-all | --reanalyze-all)
[--ftp FTP] [--max-hr MAX_HR] [--zones ZONES] [--cog COG]
[--output-dir OUTPUT_DIR] [--format {html,pdf,markdown}]
[--charts] [--report] [--summary]
Example output for `python main.py --help`:
```
usage: main.py [-h] [--verbose] {analyze,batch,download,reanalyze,config} ...
Analyze Garmin workout data from files or Garmin Connect
positional arguments:
{analyze,batch,download,reanalyze,config}
Available commands
analyze Analyze a single workout or download from Garmin Connect
batch Analyze multiple workout files in a directory
download Download activities from Garmin Connect
reanalyze Re-analyze all downloaded activities
config Manage configuration
options:
-h, --help show this help message and exit
--config CONFIG, -c CONFIG
Configuration file path
--verbose, -v Enable verbose logging
```
Input options:
Example output for `python main.py analyze --help`:
```
usage: main.py analyze [-h] [--file FILE] [--garmin-connect] [--workout-id WORKOUT_ID]
[--ftp FTP] [--max-hr MAX_HR] [--zones ZONES] [--cog COG]
[--output-dir OUTPUT_DIR] [--format {html,pdf,markdown}]
[--charts] [--report]
Analyze a single workout or download from Garmin Connect
options:
-h, --help show this help message and exit
--file FILE, -f FILE Path to workout file (FIT, TCX, or GPX)
--directory DIRECTORY, -d DIRECTORY
Directory containing workout files
--garmin-connect Download from Garmin Connect
--garmin-connect Download and analyze latest workout from Garmin Connect
--workout-id WORKOUT_ID
Analyze specific workout by ID from Garmin Connect
--download-all Download all cycling activities from Garmin Connect (no analysis)
--reanalyze-all Re-analyze all downloaded activities and generate reports
Analysis options:
--ftp FTP Functional Threshold Power (W)
--max-hr MAX_HR Maximum heart rate (bpm)
--zones ZONES Path to zones configuration file
--cog COG Cog size (teeth) for power calculations. Auto-detected if not provided
Output options:
--output-dir OUTPUT_DIR
Output directory for reports and charts
--format {html,pdf,markdown}
Report format
--charts Generate charts
--report Generate comprehensive report
--summary Generate summary report for multiple workouts
Examples:
Analyze latest workout from Garmin Connect: python main.py --garmin-connect
Analyze specific workout by ID: python main.py --workout-id 123456789
Download all cycling workouts: python main.py --download-all
Re-analyze all downloaded workouts: python main.py --reanalyze-all
Analyze local FIT file: python main.py --file path/to/workout.fit
Analyze directory of workouts: python main.py --directory data/
Configuration:
Set Garmin credentials in .env file: GARMIN_EMAIL and GARMIN_PASSWORD
Configure zones in config/config.yaml or use --zones flag
Override FTP with --ftp flag, max HR with --max-hr flag
Output:
Reports saved to output/ directory by default
Charts saved to output/charts/ when --charts is used
```
### Configuration:
Set Garmin credentials in `.env` file: `GARMIN_EMAIL` and `GARMIN_PASSWORD`.
Configure zones in `config/config.yaml` or use `--zones` flag.
Override FTP with `--ftp` flag, max HR with `--max-hr` flag.
### Output:
Reports saved to `output/` directory by default.
Charts saved to `output/charts/` when `--charts` is used.
## Deprecation Notice
The Text User Interface (TUI) and legacy analyzer have been removed in favor of the more robust and maintainable modular command-line interface (CLI). The project now relies exclusively on the modular CLI (`main.py` and `cli.py`) for all operations. All functionality from the legacy components has been successfully migrated to the modular stack.
The Text User Interface (TUI) and legacy analyzer have been removed in favor of the more robust and maintainable modular command-line interface (CLI) implemented solely in `main.py`. The `cli.py` file has been removed. All functionality from the legacy components has been successfully migrated to the modular stack.
## Setup credentials

View File

@@ -122,7 +122,7 @@ class GarminClient:
Args:
activity_id: Garmin activity ID
file_format: File format to download (fit, tcx, gpx)
file_format: File format to download (fit, tcx, gpx, csv, original)
Returns:
Path to downloaded file or None if download failed
@@ -135,21 +135,30 @@ class GarminClient:
# Create data directory if it doesn't exist
DATA_DIR.mkdir(exist_ok=True)
# Download file
file_data = self.client.download_activity(
activity_id,
dl_fmt=file_format.upper()
)
fmt_upper = (file_format or "").upper()
logger.debug(f"download_activity_file: requested format='{file_format}' normalized='{fmt_upper}'")
# Save to file
filename = f"activity_{activity_id}.{file_format}"
file_path = DATA_DIR / filename
if fmt_upper in {"TCX", "GPX", "CSV"}:
# Direct format downloads supported by garminconnect
file_data = self.client.download_activity(activity_id, dl_fmt=fmt_upper)
with open(file_path, "wb") as f:
f.write(file_data)
# Save to file using lowercase extension
filename = f"activity_{activity_id}.{fmt_upper.lower()}"
file_path = DATA_DIR / filename
logger.info(f"Downloaded activity file: {file_path}")
return file_path
with open(file_path, "wb") as f:
f.write(file_data)
logger.info(f"Downloaded activity file: {file_path}")
return file_path
# FIT is not a direct dl_fmt in some client versions; use ORIGINAL to obtain ZIP and extract .fit
if fmt_upper in {"FIT", "ORIGINAL"} or file_format.lower() == "fit":
fit_path = self.download_activity_original(activity_id)
return fit_path
logger.error(f"Unsupported download format '{file_format}'. Valid: GPX, TCX, ORIGINAL, CSV")
return None
except Exception as e:
logger.error(f"Failed to download activity {activity_id}: {e}")
@@ -172,39 +181,196 @@ class GarminClient:
# Create data directory if it doesn't exist
DATA_DIR.mkdir(exist_ok=True)
# Download original file
file_data = self.client.download_activity(activity_id, dl_fmt='ZIP')
# Capability probe: does garminconnect client expose a native original download?
has_native_original = hasattr(self.client, 'download_activity_original')
logger.debug(f"garminconnect has download_activity_original: {has_native_original}")
file_data = None
attempts: List[str] = []
# 1) Prefer native method when available
if has_native_original:
try:
attempts.append("self.client.download_activity_original(activity_id)")
logger.debug(f"Attempting native download_activity_original for activity {activity_id}")
file_data = self.client.download_original_activity(activity_id)
except Exception as e:
logger.debug(f"Native download_activity_original failed: {e} (type={type(e).__name__})")
file_data = None
# 2) Try download_activity with 'original' format
if file_data is None and hasattr(self.client, 'download_activity'):
try:
attempts.append("self.client.download_activity(activity_id, dl_fmt='original')")
logger.debug(f"Attempting original download via download_activity(dl_fmt='original') for activity {activity_id}")
file_data = self.client.download_activity(activity_id, dl_fmt='original')
logger.debug(f"download_activity(dl_fmt='original') succeeded, got data type: {type(file_data).__name__}, length: {len(file_data) if hasattr(file_data, '__len__') else 'N/A'}")
if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0:
logger.debug(f"First 100 bytes: {file_data[:100]}")
except Exception as e:
logger.debug(f"download_activity(dl_fmt='original') failed: {e} (type={type(e).__name__})")
file_data = None
# 3) Try download_activity with positional token (older signatures)
if file_data is None and hasattr(self.client, 'download_activity'):
tokens_to_try_pos = ['ORIGINAL', 'original', 'FIT', 'fit']
for token in tokens_to_try_pos:
try:
attempts.append(f"self.client.download_activity(activity_id, '{token}')")
logger.debug(f"Attempting original download via download_activity(activity_id, '{token}') for activity {activity_id}")
file_data = self.client.download_activity(activity_id, token)
logger.debug(f"download_activity(activity_id, '{token}') succeeded, got data type: {type(file_data).__name__}, length: {len(file_data) if hasattr(file_data, '__len__') else 'N/A'}")
if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0:
logger.debug(f"First 100 bytes: {file_data[:100]}")
break
except Exception as e:
logger.debug(f"download_activity(activity_id, '{token}') failed: {e} (type={type(e).__name__})")
file_data = None
# 4) Try alternate method names commonly seen in different garminconnect variants
alt_methods_with_format = [
('download_activity_file', ['ORIGINAL', 'original', 'FIT', 'fit']),
]
alt_methods_no_format = [
'download_original_activity',
'get_original_activity',
]
if file_data is None:
for method_name, fmts in alt_methods_with_format:
if hasattr(self.client, method_name):
method = getattr(self.client, method_name)
for fmt in fmts:
try:
attempts.append(f"self.client.{method_name}(activity_id, '{fmt}')")
logger.debug(f"Attempting {method_name}(activity_id, '{fmt}') for activity {activity_id}")
file_data = method(activity_id, fmt)
logger.debug(f"{method_name}(activity_id, '{fmt}') succeeded, got data type: {type(file_data).__name__}")
break
except Exception as e:
logger.debug(f"{method_name}(activity_id, '{fmt}') failed: {e} (type={type(e).__name__})")
file_data = None
if file_data is not None:
break
if file_data is None:
for method_name in alt_methods_no_format:
if hasattr(self.client, method_name):
method = getattr(self.client, method_name)
try:
attempts.append(f"self.client.{method_name}(activity_id)")
logger.debug(f"Attempting {method_name}(activity_id) for activity {activity_id}")
file_data = method(activity_id)
logger.debug(f"{method_name}(activity_id) succeeded, got data type: {type(file_data).__name__}")
break
except Exception as e:
logger.debug(f"{method_name}(activity_id) failed: {e} (type={type(e).__name__})")
file_data = None
if file_data is None:
# 5) HTTP fallback using authenticated requests session from garminconnect client
session = None
# Try common attributes that hold a requests.Session or similar
for attr in ("session", "_session", "requests_session", "req_session", "http", "client"):
candidate = getattr(self.client, attr, None)
if candidate is not None and hasattr(candidate, "get"):
session = candidate
break
if candidate is not None and hasattr(candidate, "session") and hasattr(candidate.session, "get"):
session = candidate.session
break
if session is not None:
http_urls = [
f"https://connect.garmin.com/modern/proxy/download-service/export/original/{activity_id}",
f"https://connect.garmin.com/modern/proxy/download-service/files/activity/{activity_id}",
f"https://connect.garmin.com/modern/proxy/download-service/export/zip/activity/{activity_id}",
]
for url in http_urls:
try:
attempts.append(f"HTTP GET {url}")
logger.debug(f"Attempting HTTP fallback GET for original: {url}")
resp = session.get(url, timeout=30)
status = getattr(resp, "status_code", None)
content = getattr(resp, "content", None)
if status == 200 and content:
content_type = getattr(resp, "headers", {}).get("Content-Type", "")
logger.debug(f"HTTP fallback succeeded: status={status}, content-type='{content_type}', bytes={len(content)}")
file_data = content
break
else:
logger.debug(f"HTTP fallback GET {url} returned status={status} or empty content")
except Exception as e:
logger.debug(f"HTTP fallback GET {url} failed: {e} (type={type(e).__name__})")
if file_data is None:
logger.error(
f"Failed to obtain original/FIT data for activity {activity_id}. "
f"Attempts: {attempts}"
)
return None
# Normalize to raw bytes if response-like object returned
if hasattr(file_data, 'content'):
try:
file_data = file_data.content
except Exception:
pass
elif hasattr(file_data, 'read'):
try:
file_data = file_data.read()
except Exception:
pass
if not isinstance(file_data, (bytes, bytearray)):
logger.error(f"Downloaded data for activity {activity_id} is not bytes (type={type(file_data).__name__}); aborting")
logger.debug(f"Data content: {repr(file_data)[:200]}")
return None
# Save to temporary file first
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_file:
with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
tmp_file.write(file_data)
tmp_path = Path(tmp_file.name)
# Extract zip file
with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
# Find the first FIT file in the zip
fit_files = [f for f in zip_ref.namelist() if f.lower().endswith('.fit')]
# Determine if the response is a ZIP archive (original) or a direct FIT file
if zipfile.is_zipfile(tmp_path):
# Extract zip file
with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
# Find the first FIT file in the zip
fit_files = [f for f in zip_ref.namelist() if f.lower().endswith('.fit')]
if fit_files:
# Extract the first FIT file
fit_filename = fit_files[0]
extracted_path = DATA_DIR / f"activity_{activity_id}.fit"
if fit_files:
# Extract the first FIT file
fit_filename = fit_files[0]
extracted_path = DATA_DIR / f"activity_{activity_id}.fit"
with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target:
with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target:
target.write(source.read())
# Clean up temporary zip file
tmp_path.unlink()
logger.info(f"Downloaded original activity file: {extracted_path}")
return extracted_path
else:
logger.warning("No FIT file found in downloaded archive")
tmp_path.unlink()
return None
else:
# Treat data as direct FIT bytes
extracted_path = DATA_DIR / f"activity_{activity_id}.fit"
try:
tmp_path.rename(extracted_path)
except Exception as move_err:
logger.debug(f"Rename temp FIT to destination failed ({move_err}); falling back to copy")
with open(extracted_path, 'wb') as target, open(tmp_path, 'rb') as source:
target.write(source.read())
# Clean up temporary zip file
tmp_path.unlink()
logger.info(f"Downloaded original activity file: {extracted_path}")
return extracted_path
else:
logger.warning("No FIT file found in downloaded archive")
tmp_path.unlink()
return None
logger.info(f"Downloaded original activity file: {extracted_path}")
return extracted_path
except Exception as e:
logger.error(f"Failed to download original activity {activity_id}: {e}")
logger.error(f"Failed to download original activity {activity_id}: {e} (type={type(e).__name__})")
return None
def get_activity_summary(self, activity_id: str) -> Optional[Dict[str, Any]]:
@@ -314,3 +480,122 @@ class GarminClient:
downloaded_path.rename(file_path)
return True
return False
def download_all_workouts(self, limit: int = 50, output_dir: Path = DATA_DIR) -> List[Dict[str, Path]]:
"""Download up to 'limit' cycling workouts and save FIT files to output_dir.
Uses get_all_cycling_workouts() to list activities, then downloads each original
activity archive and extracts the FIT file via download_activity_original().
Args:
limit: Maximum number of cycling activities to download
output_dir: Directory to save downloaded FIT files
Returns:
List of dicts with 'file_path' pointing to downloaded FIT paths
"""
if not self.is_authenticated():
if not self.authenticate():
logger.error("Authentication failed; cannot download workouts")
return []
try:
output_dir.mkdir(parents=True, exist_ok=True)
activities = self.get_all_cycling_workouts(limit=limit)
total = min(limit, len(activities))
logger.info(f"Preparing to download up to {total} cycling activities into {output_dir}")
results: List[Dict[str, Path]] = []
for idx, activity in enumerate(activities[:limit], start=1):
activity_id = (
activity.get("activityId")
or activity.get("activity_id")
or activity.get("id")
)
if not activity_id:
logger.warning("Skipping activity with missing ID key (activityId/activity_id/id)")
continue
logger.debug(f"Downloading activity ID {activity_id} ({idx}/{total})")
src_path = self.download_activity_original(str(activity_id))
if src_path and src_path.exists():
dest_path = output_dir / src_path.name
try:
if src_path.resolve() != dest_path.resolve():
if dest_path.exists():
# Overwrite existing destination to keep most recent download
dest_path.unlink()
src_path.rename(dest_path)
else:
# Already in the desired location
pass
except Exception as move_err:
logger.error(f"Failed to move {src_path} to {dest_path}: {move_err}")
dest_path = src_path # fall back to original location
logger.info(f"Saved activity {activity_id} to {dest_path}")
results.append({"file_path": dest_path})
else:
logger.warning(f"Download returned no file for activity {activity_id}")
logger.info(f"Downloaded {len(results)} activities to {output_dir}")
return results
except Exception as e:
logger.error(f"Failed during batch download: {e}")
return []
def download_latest_workout(self, output_dir: Path = DATA_DIR) -> Optional[Path]:
"""Download the latest cycling workout and save FIT file to output_dir.
Uses get_latest_activity('cycling') to find the most recent cycling activity,
then downloads the original archive and extracts the FIT via download_activity_original().
Args:
output_dir: Directory to save the downloaded FIT file
Returns:
Path to the downloaded FIT file or None if download failed
"""
if not self.is_authenticated():
if not self.authenticate():
logger.error("Authentication failed; cannot download latest workout")
return None
try:
latest = self.get_latest_activity(activity_type="cycling")
if not latest:
logger.warning("No latest cycling activity found")
return None
activity_id = (
latest.get("activityId")
or latest.get("activity_id")
or latest.get("id")
)
if not activity_id:
logger.error("Latest activity missing ID key (activityId/activity_id/id)")
return None
logger.info(f"Downloading latest cycling activity ID {activity_id}")
src_path = self.download_activity_original(str(activity_id))
if src_path and src_path.exists():
output_dir.mkdir(parents=True, exist_ok=True)
dest_path = output_dir / src_path.name
try:
if src_path.resolve() != dest_path.resolve():
if dest_path.exists():
dest_path.unlink()
src_path.rename(dest_path)
except Exception as move_err:
logger.error(f"Failed to move {src_path} to {dest_path}: {move_err}")
return src_path # Return original location if move failed
logger.info(f"Saved latest activity {activity_id} to {dest_path}")
return dest_path
logger.warning(f"Download returned no file for latest activity {activity_id}")
return None
except Exception as e:
logger.error(f"Failed to download latest workout: {e}")
return None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

17
main.py
View File

@@ -280,15 +280,26 @@ class GarminAnalyser:
download_output_dir = Path(getattr(args, 'output_dir', 'data'))
download_output_dir.mkdir(parents=True, exist_ok=True)
logging.debug(f"download_workouts: all={getattr(args, 'all', False)}, workout_id={getattr(args, 'workout_id', None)}, limit={getattr(args, 'limit', 50)}, output_dir={download_output_dir}")
downloaded_activities = []
if getattr(args, 'all', False):
logging.info(f"Downloading up to {getattr(args, 'limit', 50)} cycling activities...")
downloaded_activities = client.download_all_workouts(limit=getattr(args, 'limit', 50), output_dir=download_output_dir)
elif getattr(args, 'workout_id', None):
logging.info(f"Downloading workout {args.workout_id}...")
activity_path = client.download_activity_original(str(args.workout_id), output_dir=download_output_dir)
activity_path = client.download_activity_original(str(args.workout_id))
if activity_path:
downloaded_activities.append({'file_path': activity_path})
dest_path = download_output_dir / activity_path.name
try:
if activity_path.resolve() != dest_path.resolve():
if dest_path.exists():
dest_path.unlink()
activity_path.rename(dest_path)
except Exception as move_err:
logging.error(f"Failed to move {activity_path} to {dest_path}: {move_err}")
dest_path = activity_path
downloaded_activities.append({'file_path': dest_path})
else:
logging.info("Downloading latest cycling activity...")
activity_path = client.download_latest_workout(output_dir=download_output_dir)
@@ -463,7 +474,7 @@ def main():
)
except Exception as e:
logging.error(f"Error: {e}", file=sys.stderr)
logging.error(f"Error: {e}")
if args.verbose:
logging.exception("Full traceback:")
sys.exit(1)

View File

@@ -48,7 +48,6 @@ setup(
entry_points={
"console_scripts": [
"garmin-analyser=main:main",
"garmin-analyzer-cli=cli:main",
],
},
include_package_data=True,