Skip to content
Merged
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: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ All source modules live in the `coros_mcp/` package:
- **`coros_mcp/cache/`**: SQLite-backed local data store. `store.py` — raw read/write; `sync.py` — smart fetch logic (resolve gaps, backfill, chunk), `_resolve_fetch_range()` decides what to hit the API for, `_fetch_chunked()` splits long uncached ranges into 12-week API calls; `utils.py` — timezone helpers.
- **`coros_mcp/auth/`**: Token storage abstraction. Priority chain: env var → encrypted file → keyring. `encrypted_store.py` uses AES-256-GCM with a machine-bound key (machine *binding* against off-machine leaks, not protection from local attackers — that comes from 0600 file perms); `keyring_store.py` wraps the system keyring.

### Resting Heart Rate: two distinct fields
`/analyse/dayDetail/query` returns both `rhr` (daily aggregate, shown in the web dashboard / Training Hub) and `testRhr` (measured resting HR, the value the Coros **app** displays). They routinely differ by several bpm. `DailyRecord` stores both (`rhr`, `test_rhr`); use `test_rhr` when matching against the app. Days cached before `test_rhr` existed have it as `null` — re-sync the range to backfill.

### API Response Pattern
All Coros API responses return `result: "0000"` on success. Any other value indicates an error — check `message` field. Large time-series fields (`graphList`, `frequencyList`, `gpsLightDuration`) are stripped from activity detail responses to keep them manageable.

Expand Down
1 change: 1 addition & 0 deletions coros_mcp/coros_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ def _parse_daily_record(item: dict) -> DailyRecord:
baseline=item.get("sleepHrvBase"),
interval_list=item.get("sleepHrvIntervalList"),
rhr=item.get("rhr"),
test_rhr=item.get("testRhr"),
training_load=item.get("trainingLoad"),
training_load_ratio=item.get("trainingLoadRatio"),
tired_rate=item.get("tiredRateNew"),
Expand Down
3 changes: 2 additions & 1 deletion coros_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ class DailyRecord(BaseModel):
avg_sleep_hrv: float | None = None
baseline: float | None = None
interval_list: list[int] | None = None
rhr: int | None = None # resting heart rate (bpm)
rhr: int | None = None # daily RHR aggregate (web dashboard / Training Hub)
test_rhr: int | None = None # measured RHR shown in the Coros app (API: testRhr)
training_load: int | None = None
training_load_ratio: float | None = None # acute/chronic ratio
tired_rate: float | None = None
Expand Down
7 changes: 6 additions & 1 deletion coros_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,12 @@ async def get_daily_metrics(weeks: _Weeks = 4) -> dict:
- date: YYYYMMDD local date (per COROS_TIMEZONE, defaults to system timezone)
- avg_sleep_hrv: average nightly RMSSD in ms
- baseline: rolling baseline RMSSD
- rhr: resting heart rate (bpm)
- rhr: daily resting heart rate (bpm) — aggregate shown in the Coros
web dashboard / Training Hub; differs from the app's displayed value
- test_rhr: measured resting heart rate (bpm) — matches the resting HR
shown in the Coros app. Prefer this when comparing against the app;
may be null for days cached before this field existed (re-sync the
range to backfill)
- training_load: daily training load
- training_load_ratio: acute/chronic training load ratio
- tired_rate: fatigue rate
Expand Down
27 changes: 27 additions & 0 deletions tests/test_daily_test_rhr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""testRhr (app resting HR) must survive parsing and cache round-trip.

The Coros /analyse/dayDetail/query response carries two resting-HR fields:
- rhr: daily aggregate shown in the web dashboard / Training Hub
- testRhr: measured resting HR shown in the Coros app
"""

from coros_mcp.coros_api import _parse_daily_record
from coros_mcp.models import DailyRecord


def test_parse_daily_record_keeps_both_rhr_values():
rec = _parse_daily_record({"happenDay": 20260813, "rhr": 56, "testRhr": 49})
assert rec.rhr == 56
assert rec.test_rhr == 49


def test_parse_daily_record_test_rhr_absent_is_none():
rec = _parse_daily_record({"happenDay": 20260812, "rhr": 46})
assert rec.rhr == 46
assert rec.test_rhr is None


def test_test_rhr_survives_json_round_trip():
rec = DailyRecord(date="20260813", rhr=56, test_rhr=49)
restored = DailyRecord.model_validate_json(rec.model_dump_json())
assert restored.test_rhr == 49
Loading