catence 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Callable, Mapping
|
|
8
|
+
|
|
9
|
+
from .registry import GARMIN_READ_METHODS, assert_read_only_registry
|
|
10
|
+
from .staging import StagingWriter
|
|
11
|
+
from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, write_parquet
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def as_dict(value: Any) -> dict[str, Any]:
|
|
15
|
+
return value if isinstance(value, dict) else {"value": value}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def as_items(value: Any) -> list[dict[str, Any]]:
|
|
19
|
+
if isinstance(value, list):
|
|
20
|
+
return [as_dict(item) for item in value]
|
|
21
|
+
if isinstance(value, dict):
|
|
22
|
+
for key in ("activityList", "items", "data", "meals", "foodLogEntries", "hrvSummaries", "individualStats", "results", "values", "events", "racePredictions", "predictions", "dailyPredictions"):
|
|
23
|
+
if isinstance(value.get(key), list):
|
|
24
|
+
return [as_dict(item) for item in value[key]]
|
|
25
|
+
return [value]
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def functional_threshold_power_items(value: Any) -> list[dict[str, Any]]:
|
|
30
|
+
"""Extract records from the undocumented threshold-power range response."""
|
|
31
|
+
if isinstance(value, list):
|
|
32
|
+
return [as_dict(item) for item in value]
|
|
33
|
+
if isinstance(value, dict):
|
|
34
|
+
for key in ("items", "data", "results", "stats", "series", "values"):
|
|
35
|
+
nested = value.get(key)
|
|
36
|
+
if isinstance(nested, list):
|
|
37
|
+
return [as_dict(item) for item in nested]
|
|
38
|
+
return [value]
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ftp_history_date(payload: Mapping[str, Any]) -> str | None:
|
|
43
|
+
for key in ("until", "calendarDate", "date", "updatedDate", "from"):
|
|
44
|
+
value = payload.get(key)
|
|
45
|
+
if not isinstance(value, str) or len(value) < 10:
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
return date.fromisoformat(value[:10]).isoformat()
|
|
49
|
+
except ValueError:
|
|
50
|
+
continue
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def remote_id(payload: dict[str, Any], fallback: str) -> str:
|
|
55
|
+
for key in ("activityId", "workoutId", "scheduleId", "id", "calendarDate", "date", "uuid"):
|
|
56
|
+
if payload.get(key) is not None:
|
|
57
|
+
return str(payload[key])
|
|
58
|
+
return fallback
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def payload_date(payload: Mapping[str, Any]) -> str | None:
|
|
62
|
+
"""Return the first ISO calendar day exposed by a Garmin payload."""
|
|
63
|
+
for key in ("calendarDate", "date", "until", "from", "timestamp", "timestampLocal", "startTimestampGMT"):
|
|
64
|
+
value = payload.get(key)
|
|
65
|
+
if not isinstance(value, str) or len(value) < 10:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
return date.fromisoformat(value[:10]).isoformat()
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
for key in ("generic", "cycling", "heatAltitudeAcclimation", "hrvSummary"):
|
|
72
|
+
nested = payload.get(key)
|
|
73
|
+
if isinstance(nested, Mapping):
|
|
74
|
+
nested_date = payload_date(nested)
|
|
75
|
+
if nested_date:
|
|
76
|
+
return nested_date
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GarminStagingWorker:
|
|
81
|
+
"""Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None) -> None:
|
|
84
|
+
self.api = api
|
|
85
|
+
self.writer = writer
|
|
86
|
+
self.data_dir = data_dir
|
|
87
|
+
self.known_activity_hashes = dict(known_activity_hashes or {})
|
|
88
|
+
|
|
89
|
+
def sync(
|
|
90
|
+
self,
|
|
91
|
+
daily_from_date: date | None,
|
|
92
|
+
activity_from_date: date | None,
|
|
93
|
+
to_date: date | None = None,
|
|
94
|
+
daily_to_date: date | None = None,
|
|
95
|
+
activity_to_date: date | None = None,
|
|
96
|
+
include_non_historical: bool = True,
|
|
97
|
+
) -> None:
|
|
98
|
+
assert_read_only_registry()
|
|
99
|
+
end = to_date or date.today()
|
|
100
|
+
daily_end = daily_to_date or end
|
|
101
|
+
activity_end = activity_to_date or end
|
|
102
|
+
if include_non_historical:
|
|
103
|
+
self._singletons()
|
|
104
|
+
if daily_from_date and daily_from_date <= daily_end:
|
|
105
|
+
self._daily(daily_from_date, daily_end)
|
|
106
|
+
self._range(daily_from_date, daily_end)
|
|
107
|
+
self._cycling_ftp_history(daily_from_date, daily_end)
|
|
108
|
+
self._max_metrics_history(daily_from_date, daily_end)
|
|
109
|
+
self._hrv_history(daily_from_date, daily_end)
|
|
110
|
+
self._score_history(daily_from_date, daily_end)
|
|
111
|
+
if activity_from_date and activity_from_date <= activity_end:
|
|
112
|
+
self._activities(activity_from_date, activity_end)
|
|
113
|
+
if include_non_historical:
|
|
114
|
+
self._collections()
|
|
115
|
+
|
|
116
|
+
def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
|
|
117
|
+
try:
|
|
118
|
+
payload = action()
|
|
119
|
+
raw_hash = self.writer.archive_json(endpoint, remote_id_value, payload, scope)
|
|
120
|
+
return payload, raw_hash
|
|
121
|
+
except Exception as error: # Provider availability is intentionally isolated per operation.
|
|
122
|
+
self.writer.error(endpoint, str(error), remote_id_value)
|
|
123
|
+
return None, None
|
|
124
|
+
|
|
125
|
+
def _entity(
|
|
126
|
+
self,
|
|
127
|
+
endpoint: str,
|
|
128
|
+
entity_type: str,
|
|
129
|
+
payload: Any,
|
|
130
|
+
raw_hash: str | None,
|
|
131
|
+
occurred_on: str | None = None,
|
|
132
|
+
parent_remote_id: str | None = None,
|
|
133
|
+
fallback_scope: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Stage source entities without allowing date-scoped calls to collide."""
|
|
136
|
+
for index, item in enumerate(as_items(payload)):
|
|
137
|
+
item_day = occurred_on or payload_date(item)
|
|
138
|
+
if occurred_on:
|
|
139
|
+
discriminator = str(item.get("id") or item.get("uuid") or item.get("timestamp") or item.get("inputContext") or index)
|
|
140
|
+
item_id = f"{endpoint}:{occurred_on}:{discriminator}"
|
|
141
|
+
else:
|
|
142
|
+
item_id = remote_id(item, f"{endpoint}:{fallback_scope or item_day or 'undated'}:{index}")
|
|
143
|
+
self.writer.source_entity(entity_type, item_id, item, raw_hash, parent_remote_id, item_day)
|
|
144
|
+
|
|
145
|
+
def _singletons(self) -> None:
|
|
146
|
+
calls = {
|
|
147
|
+
"user_profile": ("get_user_profile", "profile"),
|
|
148
|
+
"userprofile_settings": ("get_userprofile_settings", "profile_setting"),
|
|
149
|
+
"devices": ("get_devices", "device"),
|
|
150
|
+
"device_last_used": ("get_device_last_used", "device"),
|
|
151
|
+
"device_alarms": ("get_device_alarms", "device_alarm"),
|
|
152
|
+
"lactate_threshold": ("get_lactate_threshold", "lactate_threshold"),
|
|
153
|
+
"heart_rate_zones": ("get_heart_rate_zones", "activity_zone"),
|
|
154
|
+
"power_zones": ("get_power_zones", "activity_zone"),
|
|
155
|
+
"cycling_ftp": ("get_cycling_ftp", "training_metric"),
|
|
156
|
+
"personal_record": ("get_personal_record", "personal_record"),
|
|
157
|
+
"earned_badges": ("get_earned_badges", "badge"),
|
|
158
|
+
"available_badges": ("get_available_badges", "badge"),
|
|
159
|
+
"in_progress_badges": ("get_in_progress_badges", "badge"),
|
|
160
|
+
"training_plans": ("get_training_plans", "training_plan"),
|
|
161
|
+
"pregnancy_summary": ("get_pregnancy_summary", "pregnancy_summary"),
|
|
162
|
+
"golf_user_stats": ("get_golf_user_stats", "golf_user_stat"),
|
|
163
|
+
"golf_club_stats": ("get_golf_club_stats", "golf_club_stat"),
|
|
164
|
+
}
|
|
165
|
+
for endpoint, (method, entity_type) in calls.items():
|
|
166
|
+
payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
|
|
167
|
+
if payload is not None:
|
|
168
|
+
self._entity(endpoint, entity_type, payload, raw_hash)
|
|
169
|
+
|
|
170
|
+
def _daily(self, from_date: date, to_date: date) -> None:
|
|
171
|
+
calls = {
|
|
172
|
+
"stats": ("get_stats", "daily_health"), "user_summary": ("get_user_summary", "daily_health"),
|
|
173
|
+
"floors": ("get_floors", "daily_health"), "intensity_minutes": ("get_intensity_minutes_data", "daily_health"),
|
|
174
|
+
"steps": ("get_steps_data", "daily_health"), "heart_rates": ("get_heart_rates", "daily_health"),
|
|
175
|
+
"sleep": ("get_sleep_data", "daily_health"), "stress": ("get_stress_data", "daily_health"),
|
|
176
|
+
"all_day_stress": ("get_all_day_stress", "daily_health"), "body_battery_events": ("get_body_battery_events", "daily_health"),
|
|
177
|
+
"respiration": ("get_respiration_data", "daily_health"), "spo2": ("get_spo2_data", "daily_health"),
|
|
178
|
+
"all_day_events": ("get_all_day_events", "health_event"),
|
|
179
|
+
"training_readiness": ("get_training_readiness", "daily_health"),
|
|
180
|
+
"morning_training_readiness": ("get_morning_training_readiness", "daily_health"), "training_status": ("get_training_status", "training_status"),
|
|
181
|
+
"fitness_age": ("get_fitnessage_data", "fitness_age"), "endurance_score": ("get_endurance_score", "endurance_score"),
|
|
182
|
+
"hydration": ("get_hydration_data", "nutrition_log"), "menstrual": ("get_menstrual_data_for_date", "menstrual"),
|
|
183
|
+
"nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
|
|
184
|
+
"nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
|
|
185
|
+
}
|
|
186
|
+
current = from_date
|
|
187
|
+
while current <= to_date:
|
|
188
|
+
day = current.isoformat()
|
|
189
|
+
for endpoint, (method, entity_type) in calls.items():
|
|
190
|
+
payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
|
|
191
|
+
if payload is not None:
|
|
192
|
+
self._entity(endpoint, entity_type, payload, raw_hash, day)
|
|
193
|
+
current += timedelta(days=1)
|
|
194
|
+
|
|
195
|
+
def _range(self, from_date: date, to_date: date) -> None:
|
|
196
|
+
calls = {
|
|
197
|
+
"daily_steps": ("get_daily_steps", "daily_health"), "body_battery": ("get_body_battery", "daily_health"),
|
|
198
|
+
"blood_pressure": ("get_blood_pressure", "blood_pressure"), "weigh_ins": ("get_weigh_ins", "body_composition"),
|
|
199
|
+
"progress_summary": ("get_progress_summary_between_dates", "progress_summary"), "menstrual_calendar": ("get_menstrual_calendar_data", "menstrual"),
|
|
200
|
+
}
|
|
201
|
+
for endpoint, (method, entity_type) in calls.items():
|
|
202
|
+
# Garmin rejects broad body-battery and menstrual-calendar ranges.
|
|
203
|
+
# Keep every request inside the provider's 92-day limit.
|
|
204
|
+
window_start = from_date
|
|
205
|
+
# Garmin rejects the Body Battery endpoint's larger historical
|
|
206
|
+
# ranges. Keep it deliberately small; the captures are
|
|
207
|
+
# content-addressed so overlapping retry windows are harmless.
|
|
208
|
+
window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
|
|
209
|
+
while window_start <= to_date:
|
|
210
|
+
window_end = min(to_date, window_start + timedelta(days=window_days - 1))
|
|
211
|
+
start, end = window_start.isoformat(), window_end.isoformat()
|
|
212
|
+
payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
|
|
213
|
+
if payload is not None:
|
|
214
|
+
self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
|
|
215
|
+
window_start = window_end + timedelta(days=1)
|
|
216
|
+
|
|
217
|
+
def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
|
|
218
|
+
"""Stage daily cycling FTP setting history from Garmin's range endpoint."""
|
|
219
|
+
endpoint = "functional_threshold_power_range"
|
|
220
|
+
window_start = from_date
|
|
221
|
+
while window_start <= to_date:
|
|
222
|
+
# This private Connect endpoint is not documented with a maximum
|
|
223
|
+
# range. Keep requests aligned with other conservative range calls.
|
|
224
|
+
window_end = min(to_date, window_start + timedelta(days=89))
|
|
225
|
+
start, end = window_start.isoformat(), window_end.isoformat()
|
|
226
|
+
payload, raw_hash = self._capture(
|
|
227
|
+
endpoint,
|
|
228
|
+
lambda start=start, end=end: self.api.get_functional_threshold_power_range(
|
|
229
|
+
start, end, sport="CYCLING"
|
|
230
|
+
),
|
|
231
|
+
None,
|
|
232
|
+
{"from": start, "to": end, "sport": "CYCLING", "aggregation": "daily"},
|
|
233
|
+
)
|
|
234
|
+
if payload is not None:
|
|
235
|
+
for item in functional_threshold_power_items(payload):
|
|
236
|
+
series = str(item.get("series") or item.get("sport") or "").upper()
|
|
237
|
+
value = item.get("value", item.get("functionalThresholdPower"))
|
|
238
|
+
occurred_on = ftp_history_date(item)
|
|
239
|
+
if series != "CYCLING" or isinstance(value, bool) or not isinstance(value, (int, float)) or occurred_on is None:
|
|
240
|
+
continue
|
|
241
|
+
normalized = {
|
|
242
|
+
**item,
|
|
243
|
+
"sport": "CYCLING",
|
|
244
|
+
"calendarDate": occurred_on,
|
|
245
|
+
"functionalThresholdPower": value,
|
|
246
|
+
}
|
|
247
|
+
self.writer.source_entity(
|
|
248
|
+
"functional_threshold_power",
|
|
249
|
+
f"cycling:{occurred_on}",
|
|
250
|
+
normalized,
|
|
251
|
+
raw_hash,
|
|
252
|
+
occurred_on=occurred_on,
|
|
253
|
+
)
|
|
254
|
+
window_start = window_end + timedelta(days=1)
|
|
255
|
+
|
|
256
|
+
def _max_metrics_history(self, from_date: date, to_date: date) -> None:
|
|
257
|
+
"""Stage range max-metric responses by their actual calendar date."""
|
|
258
|
+
window_start = from_date
|
|
259
|
+
while window_start <= to_date:
|
|
260
|
+
window_end = min(to_date, window_start + timedelta(days=89))
|
|
261
|
+
start, end = window_start.isoformat(), window_end.isoformat()
|
|
262
|
+
payload, raw_hash = self._capture(
|
|
263
|
+
"max_metrics_range",
|
|
264
|
+
lambda start=start, end=end: self.api.get_max_metrics_range(start, end),
|
|
265
|
+
None,
|
|
266
|
+
{"from": start, "to": end},
|
|
267
|
+
)
|
|
268
|
+
observed: set[str] = set()
|
|
269
|
+
if payload is not None:
|
|
270
|
+
for item in as_items(payload):
|
|
271
|
+
item_day = payload_date(item)
|
|
272
|
+
if item_day:
|
|
273
|
+
observed.add(item_day)
|
|
274
|
+
self.writer.source_entity("max_metric", f"max_metrics:{item_day}", item, raw_hash, occurred_on=item_day)
|
|
275
|
+
current = window_start
|
|
276
|
+
while current <= window_end:
|
|
277
|
+
day = current.isoformat()
|
|
278
|
+
if day not in observed:
|
|
279
|
+
fallback, fallback_hash = self._capture(
|
|
280
|
+
"max_metrics",
|
|
281
|
+
lambda day=day: self.api.get_max_metrics(day),
|
|
282
|
+
day,
|
|
283
|
+
{"date": day, "fallback": "range-missing"},
|
|
284
|
+
)
|
|
285
|
+
if fallback is not None:
|
|
286
|
+
for item in as_items(fallback):
|
|
287
|
+
self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
|
|
288
|
+
current += timedelta(days=1)
|
|
289
|
+
window_start = window_end + timedelta(days=1)
|
|
290
|
+
|
|
291
|
+
def _hrv_history(self, from_date: date, to_date: date) -> None:
|
|
292
|
+
"""Stage the PR-402 HRV range API, retaining daily fallback coverage."""
|
|
293
|
+
window_start = from_date
|
|
294
|
+
while window_start <= to_date:
|
|
295
|
+
window_end = min(to_date, window_start + timedelta(days=89))
|
|
296
|
+
start, end = window_start.isoformat(), window_end.isoformat()
|
|
297
|
+
payload, raw_hash = self._capture(
|
|
298
|
+
"hrv_range",
|
|
299
|
+
lambda start=start, end=end: self.api.get_hrv_data_range(start, end),
|
|
300
|
+
None,
|
|
301
|
+
{"from": start, "to": end},
|
|
302
|
+
)
|
|
303
|
+
observed: set[str] = set()
|
|
304
|
+
if payload is not None:
|
|
305
|
+
for item in as_items(payload):
|
|
306
|
+
item_day = payload_date(item)
|
|
307
|
+
if item_day:
|
|
308
|
+
observed.add(item_day)
|
|
309
|
+
self.writer.source_entity("daily_health", f"hrv:{item_day}", item, raw_hash, occurred_on=item_day)
|
|
310
|
+
current = window_start
|
|
311
|
+
while current <= window_end:
|
|
312
|
+
day = current.isoformat()
|
|
313
|
+
if day not in observed:
|
|
314
|
+
fallback, fallback_hash = self._capture("hrv", lambda day=day: self.api.get_hrv_data(day), day, {"date": day, "fallback": "range-missing"})
|
|
315
|
+
if fallback is not None:
|
|
316
|
+
self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
|
|
317
|
+
current += timedelta(days=1)
|
|
318
|
+
window_start = window_end + timedelta(days=1)
|
|
319
|
+
|
|
320
|
+
def _score_history(self, from_date: date, to_date: date) -> None:
|
|
321
|
+
"""Stage daily-capable endurance-adjacent metrics from range endpoints."""
|
|
322
|
+
window_start = from_date
|
|
323
|
+
while window_start <= to_date:
|
|
324
|
+
window_end = min(to_date, window_start + timedelta(days=89))
|
|
325
|
+
start, end = window_start.isoformat(), window_end.isoformat()
|
|
326
|
+
for endpoint, method, entity_type in (
|
|
327
|
+
("hill_score", lambda: self.api.get_hill_score(start, end), "hill_score"),
|
|
328
|
+
("running_tolerance", lambda: self.api.get_running_tolerance(start, end, aggregation="daily"), "running_tolerance"),
|
|
329
|
+
):
|
|
330
|
+
payload, raw_hash = self._capture(endpoint, method, None, {"from": start, "to": end, "aggregation": "daily"})
|
|
331
|
+
if payload is not None:
|
|
332
|
+
for index, item in enumerate(as_items(payload)):
|
|
333
|
+
item_day = payload_date(item)
|
|
334
|
+
if item_day:
|
|
335
|
+
self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
|
|
336
|
+
window_start = window_end + timedelta(days=1)
|
|
337
|
+
|
|
338
|
+
# Garmin limits daily race-prediction windows to one year.
|
|
339
|
+
window_start = from_date
|
|
340
|
+
while window_start <= to_date:
|
|
341
|
+
window_end = min(to_date, window_start + timedelta(days=365))
|
|
342
|
+
start, end = window_start.isoformat(), window_end.isoformat()
|
|
343
|
+
payload, raw_hash = self._capture("race_predictions", lambda start=start, end=end: self.api.get_race_predictions(start, end, "daily"), None, {"from": start, "to": end, "aggregation": "daily"})
|
|
344
|
+
if payload is not None:
|
|
345
|
+
for index, item in enumerate(as_items(payload)):
|
|
346
|
+
item_day = payload_date(item) or start
|
|
347
|
+
self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
|
|
348
|
+
window_start = window_end + timedelta(days=1)
|
|
349
|
+
|
|
350
|
+
def _activities(self, from_date: date, to_date: date) -> None:
|
|
351
|
+
start, end = from_date.isoformat(), to_date.isoformat()
|
|
352
|
+
payload, raw_hash = self._capture("activities", lambda: self.api.get_activities_by_date(start, end), None, {"from": start, "to": end})
|
|
353
|
+
if payload is None:
|
|
354
|
+
return
|
|
355
|
+
self._entity("activities", "activity", payload, raw_hash)
|
|
356
|
+
for summary in as_items(payload):
|
|
357
|
+
activity_id = remote_id(summary, "unknown")
|
|
358
|
+
summary_hash = hashlib.sha256(json.dumps(summary, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
|
|
359
|
+
details_fetched = self.known_activity_hashes.get(activity_id) != summary_hash
|
|
360
|
+
if details_fetched:
|
|
361
|
+
self._activity_details(activity_id, summary)
|
|
362
|
+
self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
|
|
363
|
+
|
|
364
|
+
def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
|
|
365
|
+
if not activity.get("isMultiSportParent"):
|
|
366
|
+
return
|
|
367
|
+
metadata = as_dict(activity.get("metadataDTO"))
|
|
368
|
+
child_ids = metadata.get("childIds")
|
|
369
|
+
if not isinstance(child_ids, list):
|
|
370
|
+
return
|
|
371
|
+
for child_id in dict.fromkeys(str(value) for value in child_ids if value is not None and str(value) != parent_activity_id):
|
|
372
|
+
payload, raw_hash = self._capture("activity", lambda child_id=child_id: self.api.get_activity(child_id), child_id, {"parentActivityId": parent_activity_id})
|
|
373
|
+
if isinstance(payload, dict):
|
|
374
|
+
self.writer.source_entity("activity", child_id, payload, raw_hash, parent_remote_id=parent_activity_id)
|
|
375
|
+
|
|
376
|
+
def _activity_details(self, activity_id: str, summary: dict[str, Any]) -> None:
|
|
377
|
+
calls = {
|
|
378
|
+
"activity": ("get_activity", "activity_detail"), "activity_details": ("get_activity_details", "activity_detail"),
|
|
379
|
+
"activity_splits": ("get_activity_splits", "activity_interval"), "activity_typed_splits": ("get_activity_typed_splits", "activity_interval"),
|
|
380
|
+
"activity_split_summaries": ("get_activity_split_summaries", "activity_interval"), "activity_weather": ("get_activity_weather", "activity_weather"),
|
|
381
|
+
"activity_hr_zones": ("get_activity_hr_in_timezones", "activity_zone"), "activity_power_zones": ("get_activity_power_in_timezones", "activity_zone"),
|
|
382
|
+
"activity_exercise_sets": ("get_activity_exercise_sets", "activity_exercise_set"), "activity_gear": ("get_activity_gear", "activity_gear"),
|
|
383
|
+
}
|
|
384
|
+
details: dict[str, Any] | None = None
|
|
385
|
+
for endpoint, (method, entity_type) in calls.items():
|
|
386
|
+
payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)(activity_id), activity_id)
|
|
387
|
+
if payload is not None:
|
|
388
|
+
self._entity(endpoint, entity_type, payload, raw_hash, parent_remote_id=activity_id)
|
|
389
|
+
if endpoint == "activity" and isinstance(payload, dict):
|
|
390
|
+
self._multisport_children(activity_id, payload)
|
|
391
|
+
if endpoint == "activity_details" and isinstance(payload, dict):
|
|
392
|
+
details = payload
|
|
393
|
+
self._activity_files(activity_id, summary, details)
|
|
394
|
+
|
|
395
|
+
def _activity_files(self, activity_id: str, summary: dict[str, Any], details: dict[str, Any] | None) -> None:
|
|
396
|
+
try:
|
|
397
|
+
from garminconnect import Garmin
|
|
398
|
+
formats = {
|
|
399
|
+
"activity_original": Garmin.ActivityDownloadFormat.ORIGINAL,
|
|
400
|
+
"activity_tcx": Garmin.ActivityDownloadFormat.TCX,
|
|
401
|
+
"activity_gpx": Garmin.ActivityDownloadFormat.GPX,
|
|
402
|
+
"activity_kml": Garmin.ActivityDownloadFormat.KML,
|
|
403
|
+
"activity_csv": Garmin.ActivityDownloadFormat.CSV,
|
|
404
|
+
}
|
|
405
|
+
except ImportError as error:
|
|
406
|
+
self.writer.error("activity_files", str(error), activity_id, False)
|
|
407
|
+
return
|
|
408
|
+
original_path: Path | None = None
|
|
409
|
+
original_hash: str | None = None
|
|
410
|
+
for endpoint, format_value in formats.items():
|
|
411
|
+
try:
|
|
412
|
+
contents = self.api.download_activity(activity_id, format_value)
|
|
413
|
+
digest = self.writer.archive_bytes(endpoint, activity_id, contents, "zip" if endpoint == "activity_original" else endpoint.rsplit("_", 1)[1])
|
|
414
|
+
if endpoint == "activity_original":
|
|
415
|
+
original_path = self.data_dir / "raw" / "garmin" / endpoint / activity_id / f"{digest}.zip"
|
|
416
|
+
original_hash = digest
|
|
417
|
+
except Exception as error:
|
|
418
|
+
self.writer.error(endpoint, str(error), activity_id)
|
|
419
|
+
samples = activity_details_to_samples(f"garmin:{activity_id}", details or {})
|
|
420
|
+
if not samples and original_path and original_path.exists():
|
|
421
|
+
samples = fit_archive_to_samples(f"garmin:{activity_id}", original_path)
|
|
422
|
+
if samples:
|
|
423
|
+
start_date = str(summary.get("startTimeLocal") or summary.get("startTimeGMT") or date.today().isoformat())
|
|
424
|
+
destination, digest, row_count = write_parquet(self.data_dir, activity_id, start_date, samples)
|
|
425
|
+
self.writer.emit({
|
|
426
|
+
"kind": "stream_manifest", "provider": "garmin", "activityRemoteId": activity_id,
|
|
427
|
+
"relativePath": str(destination.relative_to(self.data_dir)), "contentHash": digest, "rowCount": row_count,
|
|
428
|
+
"startAt": samples[0].get("timestamp_utc"), "endAt": samples[-1].get("timestamp_utc"),
|
|
429
|
+
"columns": SAMPLE_COLUMNS, "rawObjectHash": None,
|
|
430
|
+
})
|
|
431
|
+
for duration, best_power in activity_power_bests(samples).items():
|
|
432
|
+
self.writer.source_entity(
|
|
433
|
+
"activity_power_best",
|
|
434
|
+
f"{activity_id}:{duration}",
|
|
435
|
+
{"durationSeconds": duration, "bestPowerWatts": best_power, "sourceType": "garmin_fit_derived"},
|
|
436
|
+
original_hash,
|
|
437
|
+
parent_remote_id=activity_id,
|
|
438
|
+
occurred_on=start_date[:10],
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _collections(self) -> None:
|
|
442
|
+
today = date.today()
|
|
443
|
+
calls = {
|
|
444
|
+
"workouts": ("get_workouts", (), "workout"), "scheduled_workouts": ("get_scheduled_workouts", (today.year, today.month), "scheduled_workout"),
|
|
445
|
+
"goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
|
|
446
|
+
}
|
|
447
|
+
for endpoint, (method, arguments, entity_type) in calls.items():
|
|
448
|
+
payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
|
|
449
|
+
if payload is not None:
|
|
450
|
+
self._entity(endpoint, entity_type, payload, raw_hash)
|