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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@ All Coros API responses return `result: "0000"` on success. Any other value indi
`COROS_MCP_TOOLSET=readonly` registers only the 12 read tools (hides write tools, raw escape hatches, and auth tools); `COROS_MCP_HIDE_AUTH_TOOLS=1` hides just the `authenticate_*` tools. Both are evaluated at import time in `server.py` via the `_tool()` registration wrapper; excluded functions remain plain callables (tests import them directly). `get_help` filters its listing against the registered set. Date/range parameters are schema-validated (`_Day` pattern, `_Weeks` bounds) at the MCP layer.

### Region Handling
Regions (`eu`, `us`) map to different base URLs for both APIs. EU tokens only work on EU endpoints — mixing regions causes auth failures.
Regions (`eu`, `us`) map to different base URLs for both APIs. EU tokens only work on EU endpoints — mixing regions causes auth failures. Region is not geography: login can succeed against the wrong region and still 1019 on every call after it (confirmed live — a German account needed `us`), so rule the region out first when auth breaks with a valid-looking token.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ List reusable workout templates saved in the Coros library.

Returns: `workouts` (list), `count`

Each entry includes: `id`, `name`, `sport_type`, `sport_name`, `estimated_time_seconds`, `exercise_count`, `exercises` (list of steps with `name`, `duration_seconds`, `intensity_low`, `intensity_high`, `sets`)
Each entry includes: `id`, `name`, `sport_type`, `sport_name`, `estimated_time_seconds`, `exercise_count`, `exercises` (list of steps with `name`, `intensity_low`, `intensity_high`, `sets`, and exactly one duration key: `duration_seconds`, `distance_meters`, or `duration_open`)

### `save_workout_template`

Expand Down Expand Up @@ -345,7 +345,11 @@ Save a reusable cycling/intervals workout **template** to the Coros library. The
}
```

**Distance-based steps:** use `duration_meters` instead of `duration_minutes` for a step that should end at a real distance regardless of pace, e.g. `{"name": "1km @ 4:00/km", "duration_meters": 1000, "intensity_low": 235, "intensity_high": 245}` (pace in sec/km with `intensity_type: 3`). Exactly one of the two keys per step.
**Distance-based steps:** use `duration_meters` instead of `duration_minutes` for a step that should end at a real distance regardless of pace, e.g. `{"name": "1km @ 4:00/km", "duration_meters": 1000, "intensity_low": 235, "intensity_high": 245}` (pace in sec/km with `intensity_type: 3`).

**Open (lap-press) steps:** use `duration_open: true` for a step with no clock and no distance cap — the watch shows no countdown and advances only when the athlete presses lap, e.g. `{"name": "Warm-up", "duration_open": true, "intensity_low": 120, "intensity_high": 150}`. Intensity bounds still show as a guide zone but never end the step. Because an open step's length is unknowable ahead of time, it counts toward neither `total_minutes` nor `distance_meters_total`, and the response carries a `warning` saying so.

Exactly one of the three keys (`duration_minutes`, `duration_meters`, `duration_open`) per step.

`sport_type`: `2` = Indoor Cycling (default), `200` = Road Bike

Expand Down
91 changes: 73 additions & 18 deletions coros_mcp/coros_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,23 @@ def __init__(self, code: str, message: str):
self.code = code


# Appended to 1019 ("access token invalid") errors: region is not geography,
# and a login against the wrong region returns a valid-looking token that
# 1019s on every subsequent call — worth ruling out before anything else.
_REGION_HINT = (
" — if this persists right after a successful login, try the other "
"region (eu/us): an account's home shard need not match where you live."
)


def _check_response(body: dict, context: str) -> None:
"""Raise CorosAPIError if the Coros API response indicates an error."""
if body.get("result") != "0000":
code = str(body.get("result"))
raise CorosAPIError(
str(body.get("result")),
code,
f"Coros {context} error: {body.get('message', 'unknown error')} "
f"(result={body.get('result')})",
f"(result={code})" + (_REGION_HINT if code == "1019" else ""),
)


Expand Down Expand Up @@ -670,9 +680,10 @@ async def fetch_activity_detail(auth: StoredAuth, activity_id: str, sport_type:
# ---------------------------------------------------------------------------

# sportType=2 = Indoor Cycling (indoor trainer); intensityType=6 = power in watts
# targetType=2 = time-based (seconds); targetType=5 = distance-based (meters
# x100, intensity values x1000 with intensityMultiplier=1000); exerciseType=2
# = cycling block
# targetType=1 = open/manual (targetValue=0 -- no cap, the step ends on a lap
# press); targetType=2 = time-based (seconds); targetType=5 = distance-based
# (meters x100, intensity values x1000 with intensityMultiplier=1000);
# exerciseType=2 = cycling block
# IntensityType values: 1=weight, 2=HR, 3=pace, 4=speed, 5=none, 6=power, 7=cadence

# Note: the workout API uses sportType=1 for Running; the activity API uses
Expand Down Expand Up @@ -733,6 +744,12 @@ def _parse_workout(item: dict) -> dict:
if ex.get("intensityMultiplier") == 1000:
parsed["intensity_low"] = _unscale(parsed["intensity_low"], 1000)
parsed["intensity_high"] = _unscale(parsed["intensity_high"], 1000)
elif ex.get("targetType") == 1:
# Open/manual step (see _target_fields): targetValue=0 means "no
# cap", not "zero seconds". Report it as duration_open so a
# read-modify-write round trip keeps the open semantics instead of
# silently rewriting the step as a 0-second timed one.
parsed["duration_open"] = True
else:
parsed["duration_seconds"] = ex.get("targetValue")
exercises.append(parsed)
Expand Down Expand Up @@ -860,27 +877,62 @@ def _target_fields(s: dict) -> dict:
"""Return the targetType/targetValue/intensityMultiplier fields for a
step, plus its intensity values scaled to match.

Two mutually exclusive duration keys are supported:
Three mutually exclusive duration keys are supported:
- duration_minutes: time-based (targetType=2, seconds, intensity
values used as-is).
- duration_meters: distance-based (targetType=5, meters x100 -- the
same convention every other distance field in this API uses --
intensityMultiplier=1000 with intensity values scaled x1000).
- duration_open: manual/lap-press (targetType=1, targetValue=0,
seconds=0). No clock or distance cap -- the step only ends when
the athlete presses lap. Intensity bounds are still allowed (shown
as a guide zone on the watch) but never gate advancement.

The x1000 intensity scaling for distance steps and targetType=5 are
not documented anywhere in Coros's API; confirmed by building a
distance-type step in the Coros app itself and reading back the raw
values via /training/schedule/query. targetType=1 (a natural first
guess) is NOT distance -- it silently produces a zero-duration,
zero-distance step with no error.
values via /training/schedule/query. targetType=1/targetValue=0 was
first assumed to be a broken guess (see git history) -- it's actually
the wire encoding for an open/manual step, confirmed the same way: by
building a manual-duration step in the Coros app and reading back the
raw exercises via /training/program/query.
"""
low = s.get("intensity_low", s.get("power_low_w", 0))
high = s.get("intensity_high", s.get("power_high_w", 0))
if "duration_meters" in s and "duration_minutes" in s:
# duration_open is a flag, so only a TRUTHY value counts as "set":
# a client that spells out `duration_open: False` on a timed step
# means "not open", not "two duration keys" -- rejecting that would
# be a hard error on a well-formed step.
duration_keys = [
k
for k in ("duration_minutes", "duration_meters", "duration_open")
if k in s and (k != "duration_open" or s[k])
]
if len(duration_keys) > 1:
raise ValueError(
f"step {s.get('name', '<unnamed>')!r} must set either "
"duration_minutes or duration_meters, not both"
f"step {s.get('name', '<unnamed>')!r} must set exactly one of "
f"duration_minutes, duration_meters, duration_open -- got {duration_keys}"
)
if not duration_keys:
hint = (
" (duration_open is present but falsy -- it must be True to "
"mark an open step)"
if "duration_open" in s
else ""
)
raise ValueError(
f"step {s.get('name', '<unnamed>')!r} needs duration_minutes, "
f"duration_meters, or duration_open{hint}"
)
if s.get("duration_open"):
return {
"targetType": 1,
"targetValue": 0,
"intensityValue": low,
"intensityValueExtend": high,
"intensityMultiplier": 0,
"seconds": 0,
}
if "duration_meters" in s:
try:
meters = float(s["duration_meters"])
Expand All @@ -902,11 +954,6 @@ def _target_fields(s: dict) -> dict:
"intensityMultiplier": 1000,
"seconds": 0, # real elapsed time is unknown ahead of time
}
if "duration_minutes" not in s:
raise ValueError(
f"step {s.get('name', '<unnamed>')!r} needs duration_minutes "
"or duration_meters"
)
duration_s = int(s["duration_minutes"] * 60)
return {
"targetType": 2,
Expand Down Expand Up @@ -967,6 +1014,12 @@ def _target_fields(s: dict) -> dict:
# iteration's meters x100) with correct derived distance/
# duration/load. Verified live 2026-08-12 by scheduling a
# 3x400m distance-only group and reading back the raw values.
# NOT yet verified live: a group whose iteration has neither
# time nor distance (every sub-step duration_open, or open +
# distance). Those also send targetValue=0 but give the
# server nothing to normalize the header to. Groups mixing an
# open sub-step with a TIMED one are fine -- the header still
# carries the timed sub-steps' seconds.
"targetType": 2,
"targetValue": iteration_seconds,
"sets": step["repeat"],
Expand Down Expand Up @@ -1127,7 +1180,9 @@ async def save_workout_template(
Plain step:
- name: str — step label (e.g. "10:00 Warm-up")
- duration_minutes: float — step duration in minutes, OR
duration_meters: float — step distance in meters (exactly one of the two)
duration_meters: float — step distance in meters, OR
duration_open: True — no cap; the step ends when the athlete presses
lap on the watch (exactly one of the three)
- intensity_low: int — lower intensity target (watts, BPM, etc. per intensity_type)
- intensity_high: int — upper intensity target (0 = open-ended)

Expand Down
Loading
Loading