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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,5 @@ log.txt
*.broken
test_*coverage*.py
test_remaining*.py
test_final*.py
test_final*.py
src/health.db
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "garmy"
Expand Down
13 changes: 8 additions & 5 deletions src/garmy/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,19 +248,22 @@ class TimestampMixin:

@staticmethod
def timestamp_to_datetime(timestamp: int) -> datetime:
"""Convert Unix timestamp (milliseconds) to datetime object.
"""Convert Unix timestamp (milliseconds) to a timezone-aware UTC datetime object.

Args:
timestamp: Unix timestamp in milliseconds.

Returns:
Corresponding datetime object.
Corresponding timezone-aware UTC datetime object.

Example:
>>> TimestampMixin.timestamp_to_datetime(1640995200000)
datetime.datetime(2022, 1, 1, 0, 0)
>>> from datetime import timezone
>>> ts = TimestampMixin.timestamp_to_datetime(1640995200000)
>>> ts.year == 2022 and ts.tzinfo == timezone.utc
True
"""
return datetime.fromtimestamp(timestamp / 1000)
from datetime import timezone
return datetime.fromtimestamp(timestamp / 1000, tz=timezone.utc)

@staticmethod
def iso_to_datetime(iso_string: Optional[str]) -> Optional[datetime]:
Expand Down
45 changes: 25 additions & 20 deletions src/garmy/localdb/activities_iterator.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,31 +33,33 @@ def initialize(self):
self._advance_to_next_activity()

def _load_next_batch(self) -> bool:
"""Load next batch of activities from API."""
"""Load next batch of activities from API in ascending order (oldest first)."""
if not self.has_more_data:
return False

try:
batch_size = self.sync_config.activities_batch_size
# Request activities in ascending order (oldest first) to match sync order
activities_batch = self.api_client.metrics.get('activities').list(
limit=batch_size,
start=self.batch_offset
start=self.batch_offset,
sort_order='asc'
)

if not activities_batch or len(activities_batch) == 0:
self.has_more_data = False
return False

# Append to cache and update offset
self.activities_cache.extend(activities_batch)
self.batch_offset += len(activities_batch)

# Check if we got less than requested (indicates end of data)
if len(activities_batch) < batch_size:
self.has_more_data = False

return True

except Exception as e:
self.progress.warning(f"Failed to load activities batch at offset {self.batch_offset}: {e}")
self.has_more_data = False
Expand Down Expand Up @@ -112,37 +114,40 @@ def _extract_activity_date(self, activity) -> Optional[date]:
return None

def get_activities_for_date(self, target_date: date) -> List[Any]:
"""Get all activities for a specific date."""
"""Get all activities for a specific date.

Note: Activities are loaded in ascending order (oldest first) from the API.
"""
activities = []

# Ensure we have a current activity
if self.current_activity is None:
if not self._advance_to_next_activity():
return activities
# Process activities while they match or are newer than target_date

# Process activities while they match or are older than target_date
while self.current_activity is not None:
if self.current_activity_date is None:
# Skip activities without dates
if not self._advance_to_next_activity():
break
continue
if self.current_activity_date > target_date:
# Activity is newer than target - skip it

if self.current_activity_date < target_date:
# Activity is older than target - skip it
if not self._advance_to_next_activity():
break
continue

elif self.current_activity_date == target_date:
# Activity matches target date - collect it
activities.append(self.current_activity)
if not self._advance_to_next_activity():
break
continue
else: # self.current_activity_date < target_date
# Activity is older than target - we're done for this date

else: # self.current_activity_date > target_date
# Activity is newer than target - we're done for this date
break

return activities
19 changes: 11 additions & 8 deletions src/garmy/localdb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,27 @@ def cmd_sync(args) -> int:
start_date = end_date - timedelta(days=6)

print(f"Syncing data from {start_date} to {end_date}")

# Get credentials
email, password = get_credentials()


# Setup progress reporter
progress_reporter = ProgressReporter(use_tqdm=args.progress == 'tqdm')

# Initialize sync manager
config = LocalDBConfig()
manager = SyncManager(
db_path=args.db_path,
config=config,
progress_reporter=progress_reporter
)
# Initialize with credentials

# Try to initialize with saved tokens first
print("Connecting to Garmin Connect...")
manager.initialize(email, password)
try:
manager.initialize()
print("Using saved authentication tokens")
except RuntimeError:
# No valid tokens, prompt for credentials
email, password = get_credentials()
manager.initialize(email, password)

# Parse metrics
metrics = parse_metrics(args.metrics) if args.metrics else list(MetricType)
Expand Down
18 changes: 12 additions & 6 deletions src/garmy/localdb/extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ def extract_timeseries_data(self, data: Any, metric_type: MetricType) -> List[Tu
for reading in data.heart_rate_values_array:
if isinstance(reading, (list, tuple)) and len(reading) >= 2:
timestamp, heart_rate = reading[0], reading[1]
timeseries_data.append((timestamp, heart_rate, {}))
if heart_rate is not None:
timeseries_data.append((timestamp, heart_rate, {}))

elif metric_type == MetricType.RESPIRATION:
# Respiration might have different format - check if it has readings
Expand All @@ -219,11 +220,16 @@ def extract_timeseries_data(self, data: Any, metric_type: MetricType) -> List[Tu
return timeseries_data

def _extract_steps_data(self, data: Any) -> Dict[str, Any]:
"""Extract steps data."""
return {
'total_steps': getattr(data, 'total_steps', None),
'step_goal': getattr(data, 'step_goal', None)
}
"""Extract steps data for the most recent day in the data object."""
if hasattr(data, 'daily_steps') and data.daily_steps:
# The 'data' object can contain multiple daily summaries.
# We assume the last one corresponds to the single 'sync_date' used for the sync operation.
latest_day = data.daily_steps[-1]
return {
'total_steps': getattr(latest_day, 'total_steps', None),
'step_goal': getattr(latest_day, 'step_goal', None)
}
return {}

def _extract_calories_data(self, data: Any) -> Dict[str, Any]:
"""Extract calories data."""
Expand Down
4 changes: 4 additions & 0 deletions src/garmy/localdb/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ def info(self, message: str):
def error(self, message: str):
"""Log error message."""
self.logger.error(message)

def warning(self, message: str):
"""Log warning message."""
self.logger.warning(message)

def end_sync(self):
"""End sync progress tracking."""
Expand Down
31 changes: 27 additions & 4 deletions src/garmy/localdb/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,34 @@ def __init__(self,
self.api_client = None
self.activities_iterator = None

def initialize(self, email: str, password: str):
"""Initialize with Garmin credentials."""
def initialize(self, email: Optional[str] = None, password: Optional[str] = None):
"""Initialize with Garmin credentials or saved tokens.

Args:
email: Garmin account email (optional if tokens are saved)
password: Garmin account password (optional if tokens are saved)
"""
try:
from garmy import AuthClient, APIClient

auth_client = AuthClient()
auth_client.login(email, password)

# Check if already authenticated with saved tokens
if not auth_client.is_authenticated:
if auth_client.needs_refresh:
self.progress.info("Refreshing authentication tokens...")
auth_client.refresh_tokens()
elif email and password:
auth_client.login(
email,
password,
prompt_mfa=lambda: input("MFA code: "),
)
else:
raise RuntimeError(
"No valid saved tokens found. Please provide email and password."
)

self.api_client = APIClient(auth_client=auth_client)

self.activities_iterator = ActivitiesIterator(
Expand Down Expand Up @@ -187,6 +208,8 @@ def _sync_activities_for_date(self, user_id: int, sync_date: date, stats: Dict[s
self.db.store_activity(user_id, activity_data)
stats['completed'] += 1

# Update sync status for the entire ACTIVITIES metric for this date
self.db.update_sync_status(user_id, sync_date, MetricType.ACTIVITIES, 'completed')
self.progress.task_complete("activities", sync_date)

except Exception as e:
Expand Down Expand Up @@ -250,4 +273,4 @@ def query_timeseries(self, user_id: int, metric_type: MetricType,
'timestamp': ts,
'value': value,
'metadata': metadata
} for ts, value, metadata in data]
} for ts, value, metadata in data]
17 changes: 12 additions & 5 deletions src/garmy/metrics/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,15 @@ def __init__(self, api_client: Any) -> None:
self.api_client = api_client
self.parse_func = parse_activities_data

def raw(self, limit: int = 20, start: int = 0) -> Any:
"""Get raw API response for activities data."""
endpoint = f"/activitylist-service/activities/search/activities?limit={limit}&start={start}"
def raw(self, limit: int = 20, start: int = 0, sort_order: str = "desc") -> Any:
"""Get raw API response for activities data.

Args:
limit: Maximum number of activities to return
start: Starting offset for pagination
sort_order: Sort order - "asc" for oldest first, "desc" for newest first (default)
"""
endpoint = f"/activitylist-service/activities/search/activities?limit={limit}&start={start}&sortOrder={sort_order}"
try:
return self.api_client.connectapi(endpoint)
except (SystemExit, KeyboardInterrupt, GeneratorExit):
Expand All @@ -262,17 +268,18 @@ def raw(self, limit: int = 20, start: int = 0) -> Any:

return handle_api_exception(e, "fetching Activities", endpoint, [])

def list(self, limit: int = 20, start: int = 0) -> List[ActivitySummary]:
def list(self, limit: int = 20, start: int = 0, sort_order: str = "desc") -> List[ActivitySummary]:
"""Get parsed activities data.

Args:
limit: Maximum number of activities to return (default: 20)
start: Starting offset for pagination (default: 0)
sort_order: Sort order - "asc" for oldest first, "desc" for newest first (default)

Returns:
List of ActivitySummary objects
"""
raw_data = self.raw(limit, start)
raw_data = self.raw(limit, start, sort_order)
if not raw_data:
return []
result = self.parse_func(raw_data)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_core_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ class TestTimestampMixin:

def test_timestamp_to_datetime(self):
"""Test timestamp_to_datetime method."""
from datetime import timezone
# Test Unix timestamp in milliseconds
timestamp = 1640995200000 # 2022-01-01 00:00:00 UTC
result = TimestampMixin.timestamp_to_datetime(timestamp)
Expand All @@ -448,6 +449,7 @@ def test_timestamp_to_datetime(self):
assert result.year == 2022
assert result.month == 1
assert result.day == 1
assert result.tzinfo == timezone.utc

def test_timestamp_to_datetime_different_values(self):
"""Test timestamp_to_datetime with different values."""
Expand Down
Loading