diff --git a/CLAUDE.md b/CLAUDE.md index 7741163..31004bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Priority chain for retrieval: `COROS_ACCESS_TOKEN` env var → system keyring All source modules live in the `coros_mcp/` package: - **`coros_mcp/server.py`**: FastMCP tool definitions. Each `@mcp.tool()` function validates auth, delegates to `coros_api`, and returns a dict. This is the only file that imports from `fastmcp`. `_run_with_auth()` retries once after re-login — for non-idempotent writes (create/schedule) only on auth errors (`retry_all=False`), to avoid duplicate server-side writes. -- **`coros_mcp/coros_api.py`**: All HTTP logic. Contains two sets of endpoints (Training Hub + mobile), the AES encryption for mobile auth, auto-refresh logic, and response parsers. API errors raise `CorosAPIError` (carries the raw Coros result code). The `fetch_daily_records()` function merges two endpoints: `/analyse/dayDetail/query` (long range, no VO2max) + `/analyse/query` (last 28 days, has VO2max/fitness fields). +- **`coros_mcp/coros_api.py`**: All HTTP logic. Contains two sets of endpoints (Training Hub + mobile), the AES encryption for mobile auth, auto-refresh logic, and response parsers. API errors raise `CorosAPIError` (carries the raw Coros result code). The `targetType`/`targetValue` wire encoding differs between the endurance and strength namespaces — the table above `_parse_workout` is the single source of truth, and each namespace has its own parser (`_parse_workout` / `_parse_strength_workout`, dispatched in `fetch_workout_templates`). The `fetch_daily_records()` function merges two endpoints: `/analyse/dayDetail/query` (long range, no VO2max) + `/analyse/query` (last 28 days, has VO2max/fitness fields). - **`coros_mcp/models.py`**: Pydantic v2 models: `StoredAuth`, `DailyRecord`, `SleepRecord`/`SleepPhases`, `HRVRecord`, `ActivitySummary`. - **`coros_mcp/cli.py`**: CLI entry point registered as `coros-mcp` script. Delegates to `coros_api.login()` / `login_mobile()` and `cache.sync.sync_all()`. - **`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. diff --git a/README.md b/README.md index 771a458..17e63a2 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,21 @@ 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`, `intensity_low`, `intensity_high`, `sets`, and exactly one duration key: `duration_seconds`, `distance_meters`, or `duration_open`) +Every entry includes: `id`, `name`, `sport_type`, `sport_name`, `estimated_time_seconds`, `exercise_count`, `exercises`. + +The shape of `exercises` depends on the namespace — endurance and strength templates speak different step vocabularies: + +| | Endurance (run/bike) | Strength (`sport_type` 4) | +|---|---|---| +| Always | `name`, `sets` | `name`, `sets`, `origin_id`, `overview`, `rest_seconds` | +| Target | exactly one of `duration_seconds`, `distance_meters`, `duration_open` | one of `reps` or `duration_seconds` (e.g. a plank) | +| Load | `intensity_low`, `intensity_high` | one of `weight_kg`, `weight_lbs`, `bodyweight`, or `effort_target` (the app's 1–10 RPE scale, used instead of a weight) | + +Strength entries also carry program-level `sets` (circuit rounds) and `total_duration_seconds`. + +The load and structural keys (`origin_id`, `overview`, `sets`, `rest_seconds`, `weight_kg`, `weight_lbs`) are deliberately the same names [`save_strength_workout_template`](#save_strength_workout_template) takes as *input*. The **target** keys are not yet: that tool takes `target_type`/`target_value` where this returns `reps`/`duration_seconds`, and it has no input for `bodyweight` or `effort_target`. Reading a template, editing it and writing it back therefore still needs a manual mapping (`reps` → `target_type=3`); teaching the write tools these names is tracked separately. + +Anything unrecognized is reported raw rather than guessed at: `target_type_raw`/`target_value_raw` for an unknown `targetType`, `rest_type_raw`/`rest_value_raw` for an unknown rest encoding, and `intensity_value_raw`/`intensity_display_unit_raw` for an unknown weight unit. `effort_target` is read-only — the write side has no input for it at all. ### `save_workout_template` diff --git a/coros_mcp/coros_api.py b/coros_mcp/coros_api.py index eb990b8..44f9719 100644 --- a/coros_mcp/coros_api.py +++ b/coros_mcp/coros_api.py @@ -679,12 +679,66 @@ async def fetch_activity_detail(auth: StoredAuth, activity_id: str, sport_type: # Workout programs (/training/program/query + /training/program/add) # --------------------------------------------------------------------------- -# sportType=2 = Indoor Cycling (indoor trainer); intensityType=6 = power in watts -# 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 +# sportType=2 = Indoor Cycling (indoor trainer); intensityType=6 = power in +# watts; exerciseType=2 = cycling block # IntensityType values: 1=weight, 2=HR, 3=pace, 4=speed, 5=none, 6=power, 7=cadence +# +# --------------------------------------------------------------------------- +# WIRE ENCODING TABLE -- targetType/targetValue by namespace +# --------------------------------------------------------------------------- +# A step is a (targetType, targetValue) pair: a tag plus a bare number. The +# tag's vocabulary DIFFERS between the endurance and strength namespaces, and +# targetValue carries no unit of its own -- read it only through this table. +# Keep the read side (_parse_workout / _parse_strength_workout) and the write +# side (_target_fields / _build_strength_program_payload) in sync with it. +# +# targetType | endurance (run/bike) | strength (sportType=4) +# -----------+-------------------------------+------------------------------ +# 1 | open/manual: targetValue=0, | -- +# | no cap, ends on a lap press | +# 2 | seconds | seconds +# 3 | -- | reps +# 5 | meters x100 (intensity values | -- +# | x1000, intensityMultiplier | +# | =1000) | +# +# Anything not in this table is UNKNOWN, not a duration: both parsers surface +# it as target_type_raw/target_value_raw rather than guessing a unit. Coros +# adding a fourth type (calories, HR target, ...) should show up as visibly +# unparsed, not as silently wrong seconds -- that guess is what produced +# GH #59 (open steps) and GH #60 (strength reps). +# +# Strength weight lives in the intensity fields, not in targetValue (see +# _parse_strength_exercise and the encoding notes in +# _build_strength_program_payload): +# intensityDisplayUnit "6" -> kg: intensityValue = kg x 1000 +# intensityDisplayUnit "7" -> lbs: intensityPercent = lbs x 1_000_000 +# (intensityValue holds the kg equivalent) +# intensityCustom 1 -> bodyweight, whatever the other fields say +# (an explicit 0.0 kg is value=0 with +# intensityCustom=0 -- the marker is the only +# thing separating the two) +# intensityDisplayUnit=0 is not a third weight unit. With no value it is the +# app's untouched default (rendered "0.0 kg"); with a value it is an EFFORT +# target on the app's 1-10 RPE scale, stored UNSCALED -- the one reading of +# this field that is not a weight at all. +# +# Which load types an exercise ACCEPTS is a client-side rule, not a server +# one. Probed live 2026-09-09 by POSTing deliberately invalid combinations to +# /training/program/add: an effort target on a weight-only exercise, an +# effort target of 99 (the app's scale is 1-10), a kg weight on an exercise +# the app only offers an effort target for, and a 1-second rest (the app's +# floor is 3s). All six probes returned result "0000" and read back +# byte-identical. So the server will not reject a nonsensical load -- the +# parser has to stay readable in the face of values no app screen can +# produce, and callers should not treat a successful write as validation. +# +# The catalogue carries a usable signal, though not an explicit rule list: +# the 6 entries (of 382) with NO intensityValue key -- warm up, cool down, +# rest, indoor rower, skierg, burpee -- are the weightless ones, and the +# burpee is the one confirmed in the app to offer an effort target instead +# of a weight. The 8 entries carrying exerciseKind (1-8) are the HYROX +# stations, a separate axis. # Note: the workout API uses sportType=1 for Running; the activity API uses # 100 (and 102 Trail, 103 Track). _build_workout_program_payload maps the @@ -719,6 +773,11 @@ async def fetch_activity_detail(auth: StoredAuth, activity_id: str, sport_type: # the COROS side. (Strength uses a separate builder and is not listed here.) _KNOWN_SPORT_TYPES = _RUNNING_ACTIVITY_SPORT_TYPES | _CYCLING_SPORT_TYPES +# Strength is its own namespace end to end: a separate builder on the write +# side (_build_strength_program_payload) and a separate parser on the read +# side (_parse_strength_workout). +_STRENGTH_SPORT_TYPE = 4 + def _unscale(value: float | int | None, divisor: int) -> float | int | None: """Divide a wire-scaled value back down, returning an int when exact.""" @@ -728,31 +787,49 @@ def _unscale(value: float | int | None, divisor: int) -> float | int | None: return int(result) if result.is_integer() else result -def _parse_workout(item: dict) -> dict: - exercises = [] - for ex in item.get("exercises", []): - parsed = { - "name": ex.get("name"), - "intensity_low": ex.get("intensityValue"), - "intensity_high": ex.get("intensityValueExtend"), - "sets": ex.get("sets", 1), - } - if ex.get("targetType") == 5: - # Distance step (see _target_fields): targetValue is meters x100; - # intensityMultiplier=1000 means intensity values are scaled x1000. - parsed["distance_meters"] = _unscale(ex.get("targetValue"), 100) - 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) +def _as_number(value: object) -> float | int | None: + """Coerce a wire value to a number, or None if it isn't one. + + The wire types are not what the write side sends: this server sends + `intensityDisplayUnit` as a string ("6") and gets an int back, and the + numeric intensity fields can arrive as strings too. `_unscale` raises + TypeError on a string, so coerce before dividing rather than trusting + the type. A non-numeric value (including the "" this server sends for + bodyweight) becomes None. + """ + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + return value + if isinstance(value, str): + try: + return float(value) if "." in value else int(value) + except ValueError: + return None + return None + + +# Pounds -> kg factor, the same constant the write side uses. +_LBS_IN_KG = 0.45359237 + + +def _round_exact(value: float, places: int = 2) -> float | int: + """Round a derived weight, returning an int when it lands on one. + + A pound weight recovered from its stored kg equivalent misses by a + rounding step (42 lbs -> 19051 -> 42.0002), which would otherwise surface + as a nonsense precision the athlete never typed. + """ + result = round(value, places) + return int(result) if float(result).is_integer() else result + + +def _parse_workout_header(item: dict, exercises: list[dict]) -> dict: + """The fields every workout template carries, whatever its namespace. + + Split out so the endurance and strength parsers can share them without + sharing a step vocabulary (see the wire encoding table above). + """ # sportType from the workout API is always a wire ID (runs come back as 1, # never 100/102/103), so the wire-keyed lookup below is correct here. sport = item.get("sportType") @@ -767,6 +844,224 @@ def _parse_workout(item: dict) -> dict: } +def _parse_exercise(ex: dict, program_sport: int | None) -> dict: + """Dispatch one exercise to its namespace's parser. + + Per EXERCISE, not per program: both builders emit `hybridTotalSets`, so + the wire format admits mixed programs, and the strength builder stamps + `sportType: 4` on every exercise it writes. Dispatching on the program + sport alone would mislabel the strength steps of a hybrid template -- + the same class of bug as GH #60, one sport further along. The program + sport is the fallback for exercises that carry no sportType of their own. + """ + sport = ex.get("sportType", program_sport) + if sport is None: + sport = program_sport + if sport == _STRENGTH_SPORT_TYPE: + return _parse_strength_exercise(ex) + return _parse_endurance_exercise(ex) + + +def _parse_workout(item: dict) -> dict: + """Parse an ENDURANCE workout template (run/bike) into step dicts. + + Strength templates (sportType=4) speak a different targetType vocabulary + and are handled by _parse_strength_workout. Dispatch happens per + EXERCISE, not per program -- see _parse_exercise. + """ + exercises = [_parse_exercise(ex, item.get("sportType")) for ex in item.get("exercises", [])] + return _parse_workout_header(item, exercises) + + +def _parse_endurance_exercise(ex: dict) -> dict: + """Parse one ENDURANCE step (run/bike) -- see the wire encoding table above.""" + parsed = { + "name": ex.get("name"), + "intensity_low": ex.get("intensityValue"), + "intensity_high": ex.get("intensityValueExtend"), + "sets": ex.get("sets", 1), + } + if ex.get("targetType") == 5: + # Distance step (see _target_fields): targetValue is meters x100; + # intensityMultiplier=1000 means intensity values are scaled x1000. + parsed["distance_meters"] = _unscale(ex.get("targetValue"), 100) + 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 + elif ex.get("targetType") == 2 and not ex.get("isGroup"): + parsed["duration_seconds"] = ex.get("targetValue") + elif ex.get("isGroup"): + # Repeat-group header, not a step: `sets` is the repeat count and + # targetValue is the iteration length (0 on app-created groups, + # which use targetType=0). The sub-steps follow as flat entries + # carrying this row's id in their groupId -- this parser does not + # nest them. + parsed["is_group"] = True + parsed["repeat"] = ex.get("sets", 1) + parsed.pop("sets", None) + else: + # Unknown targetType: surface it raw instead of calling it + # seconds. The old catch-all `else` here is what made GH #59 and + # GH #60 silent -- see the wire encoding table above. + parsed["target_type_raw"] = ex.get("targetType") + parsed["target_value_raw"] = ex.get("targetValue") + group_id = str(ex.get("groupId", "0")) + if group_id != "0": + parsed["group_id"] = group_id + return parsed + + +def _parse_strength_exercise(ex: dict) -> dict: + """Parse one strength exercise (sportType=4) -- see the wire encoding table. + + Names are chosen to match what save_strength_workout_template takes as + INPUT wherever that is possible today, which is the load axis and the + structural fields: origin_id, name, overview, sets, rest_seconds, + weight_kg and weight_lbs all round-trip verbatim. + + The TARGET axis does not, and this is deliberately not papered over. The + write side takes target_type/target_value (2=seconds, 3=reps) where this + emits `reps` / `duration_seconds`, so `reps` here is a BETTER name rather + than a matching one -- reporting reps as a duration is the bug this + parser exists to fix, and naming it target_value would just re-hide it. + Likewise `bodyweight: True` is not an input the write side declares: it + spells bodyweight by OMITTING both weight keys, so feeding this key back + happens to work only because the builder ignores keys it does not know. + And `effort_target` has no write-side input at all. + + So the output of this parser is not yet directly pasteable into + save_strength_workout_template. Closing that gap means teaching the write + tools these names (with target_type/target_value kept as aliases) and is + tracked as its own change -- see the follow-up issue referenced in the + GH #60 PR. Endurance is already off in the same way: read emits + duration_seconds, write takes duration_minutes. + + Notably absent: intensity_low/intensity_high. The strength namespace puts + weight in the intensity fields, so those endurance names would carry a raw + wire number here (27900 for 27.9 kg) -- omitting them is more honest than + surfacing that. + """ + parsed: dict = { + "name": ex.get("name"), + "origin_id": str(ex["originId"]) if ex.get("originId") is not None else None, + "overview": ex.get("overview"), + "sets": ex.get("sets", 1), + } + + target_type = ex.get("targetType") + if target_type == 3: + parsed["reps"] = ex.get("targetValue") + elif target_type == 2: + parsed["duration_seconds"] = ex.get("targetValue") + else: + parsed["target_type_raw"] = target_type + parsed["target_value_raw"] = ex.get("targetValue") + + # Rest encoding mirrors the write side: restType=3 is "Skip rests" (the + # app's wording) and carries no value; restType=1 holds the seconds. + rest_type = ex.get("restType") + if rest_type == 1: + parsed["rest_seconds"] = ex.get("restValue", 0) + elif rest_type == 3: + parsed["rest_seconds"] = 0 + else: + parsed["rest_type_raw"] = rest_type + parsed["rest_value_raw"] = ex.get("restValue") + + # Weight. intensityDisplayUnit is the discriminator: 6=kg, 7=lbs, 0=no + # unit (bodyweight). Note the read side returns it as an INT while the + # write side sends it as a STRING ("6"/"7"), hence the str() coercion. + # + # intensityCustom=1 is the bodyweight marker, in the app's own data as + # well as this server's -- only the companion value differs (the app + # sends 0, this server sends ""). It is the ONLY reliable discriminator: + # a 0.0 kg exercise is also value=0, and differs solely by custom=0. + # Verified 2026-09-09 by flipping one exercise from its 0.0 kg default to + # Bodyweight in the app and re-reading: intensityCustom 0 -> 1 was the + # entire diff. + display_unit = str(ex.get("intensityDisplayUnit", "")) + # Coerce before dividing: these arrive as ints from the app and as + # strings from some server paths (see _as_number). + raw_value = ex.get("intensityValue") + value = _as_number(raw_value) + # Only the two CONFIRMED spellings of "no weight" count as bodyweight: + # the app sends 0 with intensityCustom=1, this server sends "". A value + # that is merely unparseable is unknown, not bodyweight -- it falls + # through to the raw branch rather than being guessed at. + if ex.get("intensityCustom") == 1 or raw_value in ("", None): + # A null or empty weight is no weight, which is exactly what + # bodyweight means to the write side (it spells bodyweight by + # omitting the key). The "" this server sends comes back as None, + # so both spellings have to land here. + parsed["bodyweight"] = True + elif value is None: + # Non-numeric and not one of the bodyweight spellings: unknown. + parsed["intensity_value_raw"] = raw_value + parsed["intensity_display_unit_raw"] = ex.get("intensityDisplayUnit") + elif display_unit == "6" or (display_unit == "0" and not value): + # kg. displayUnit 0 with no value is the app's untouched default, + # which it still renders as "0.0 kg" -- report the zero rather than + # omitting the key, or rewriting the template would turn it into a + # bodyweight exercise (an omitted weight is how the write side spells + # bodyweight). + parsed["weight_kg"] = _unscale(value, 1000) + elif display_unit == "0": + # No weight unit but a value: the app's EFFORT target, a 1-10 RPE + # scale it labels in words (1 minimum, 2 very easy, ... 5 moderate, + # 8 very hard, 9 near max, 10 max). Confirmed 2026-09-09 against a + # timed burpee showing "Target 5, Moderate" with intensityValue=5 -- + # unscaled, unlike every weight in this field. + # + # NOTE: save_strength_workout_template has no input for this, so an + # effort target survives a read but cannot yet be written back. + parsed["effort_target"] = value + elif display_unit == "7": + # lbs. intensityValue always holds the kg equivalent; intensityPercent + # holds the typed pounds (lbs x 1e6) but ONLY on templates this server + # wrote -- a real app-created lbs exercise comes back with + # intensityPercent=0, so derive from the kg equivalent when it is + # missing. Reporting kg here instead would silently flip the + # athlete's unit on a read-modify-write. + percent = _as_number(ex.get("intensityPercent")) or 0 + if percent: + parsed["weight_lbs"] = _unscale(percent, 1_000_000) + else: + parsed["weight_lbs"] = _round_exact((value or 0) / 1000 / _LBS_IN_KG) + else: + # Unrecognized unit: same principle as an unknown targetType -- show + # it rather than guess a scale for it. + parsed["intensity_value_raw"] = ex.get("intensityValue") + parsed["intensity_display_unit_raw"] = ex.get("intensityDisplayUnit") + + return parsed + + +def _parse_strength_workout(item: dict) -> dict: + """Parse a STRENGTH workout template (sportType=4). + + Separate from _parse_workout because the two namespaces share only the + header: targetType means something different in each (reps vs distance), + and strength carries weight where endurance carries intensity bounds. + """ + exercises = [_parse_exercise(ex, item.get("sportType")) for ex in item.get("exercises", [])] + parsed = _parse_workout_header(item, exercises) + # Circuit rounds: the whole exercise list repeats `sets` times. Endurance + # templates have no equivalent, so it lives here rather than in the header. + # App-created templates send sets=null (they express repetition per + # exercise instead), so normalize that to a single round. `totalSets` is + # deliberately not surfaced: this server writes it as the circuit count + # while the app returns the summed per-exercise sets, so the two disagree. + parsed["sets"] = item.get("sets") or 1 + parsed["total_duration_seconds"] = item.get("duration") + return parsed + + async def fetch_workout_templates(auth: StoredAuth) -> list[dict]: """List all reusable workout templates in the user's library.""" async with httpx.AsyncClient(timeout=30) as client: @@ -780,7 +1075,15 @@ async def fetch_workout_templates(auth: StoredAuth) -> list[dict]: _check_response(body, "workout list") - return [_parse_workout(w) for w in body.get("data", [])] + # The program sportType picks the HEADER shape (strength adds circuit + # rounds and a total duration). The step vocabulary is chosen per + # exercise inside both parsers -- see _parse_exercise -- so a hybrid + # template's strength steps stay correctly parsed either way. + return [ + _parse_strength_workout(w) if w.get("sportType") == _STRENGTH_SPORT_TYPE + else _parse_workout(w) + for w in body.get("data", []) + ] def _parse_training_plan(item: dict) -> dict: @@ -877,6 +1180,10 @@ def _target_fields(s: dict) -> dict: """Return the targetType/targetValue/intensityMultiplier fields for a step, plus its intensity values scaled to match. + The endurance column of the WIRE ENCODING TABLE at the top of this + section is the source of truth for the targetType values below; keep + the two in sync. + Three mutually exclusive duration keys are supported: - duration_minutes: time-based (targetType=2, seconds, intensity values used as-is). @@ -1339,13 +1646,19 @@ def apply_workout_calculation(program: dict, calculation: dict) -> dict: return updated +# Kept deliberately: originId, intensityCustom, intensityDisplayUnit and +# isIntensityPercent used to be dropped here as noise. They are the semantic +# core of the strength read path -- originId is what makes a template +# rewritable at all, and the other three are the only things separating a +# weight from a bodyweight marker from an effort target (see +# _parse_strength_exercise). A raw view that hides the fields the parsed view +# decodes is useless for debugging exactly the cases you would reach for it. _EXERCISE_DROP = frozenset({ "videoInfos", "videoUrl", "videoUrlArrStr", "coverUrlArrStr", "thumbnailUrl", "sourceUrl", "animationId", "access", "deleted", "defaultOrder", "status", "createTimestamp", "userId", "muscle", "muscleRelevance", "part", "equipment", - "sortNo", "originId", "isDefaultAdd", "intensityCustom", - "intensityDisplayUnit", "isIntensityPercent", + "sortNo", "isDefaultAdd", }) _PROGRAM_DROP = frozenset({ diff --git a/coros_mcp/server.py b/coros_mcp/server.py index ca18e0e..252b73e 100644 --- a/coros_mcp/server.py +++ b/coros_mcp/server.py @@ -806,11 +806,38 @@ async def list_workout_templates() -> dict: Returns ------- dict with keys: workouts (list), count - Each entry contains: 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 for time-based steps, distance_meters for distance-based - steps, or duration_open=True for open/lap-press steps) + + Every entry carries: id, name, sport_type, sport_name, + estimated_time_seconds, exercise_count, exercises. + + The shape of `exercises` depends on the namespace, because the two speak + different step vocabularies: + + - Endurance (running/cycling): name, intensity_low, intensity_high, sets, + and exactly one duration key -- duration_seconds for time-based steps, + distance_meters for distance-based, or duration_open=True for + open/lap-press steps. + - Strength (sport_type 4): name, origin_id, overview, sets, rest_seconds, + one of reps (rep-based) or duration_seconds (timed, e.g. a plank), and + one load key -- weight_kg, weight_lbs, bodyweight=True, or + effort_target (the app's 1-10 RPE scale, used instead of a weight). + The load and structural keys (origin_id, overview, sets, rest_seconds, + weight_kg, weight_lbs) are the same names save_strength_workout_template + takes as INPUT. The target keys are NOT yet: that tool takes + target_type/target_value where this returns reps/duration_seconds, and + it has no input for bodyweight or effort_target. So this output is not + directly pasteable into it -- edit a template by mapping reps -> + target_type=3/target_value, or duration_seconds -> target_type=2. + No intensity_low/intensity_high: strength puts load in those wire + fields, so the endurance names would carry a raw scaled number. + Strength entries also carry program-level sets (circuit rounds) and + total_duration_seconds. + + Anything this server does not recognize is reported raw rather than + guessed at: target_type_raw/target_value_raw for an unknown targetType, + rest_type_raw/rest_value_raw for an unknown rest encoding, and + intensity_value_raw/intensity_display_unit_raw for an unknown weight + unit. """ auth = await _get_auth() if auth is None: diff --git a/tests/test_strength_template_parser.py b/tests/test_strength_template_parser.py new file mode 100644 index 0000000..c23f0a1 --- /dev/null +++ b/tests/test_strength_template_parser.py @@ -0,0 +1,485 @@ +"""Tests for the strength read path (GH #60). + +`_parse_workout` only ever knew the endurance targetType vocabulary and sent +everything else to `duration_seconds`, so a 3x12 squat read back out of the +library as "12 seconds" and 27.9 kg read back as `intensity_low: 27900`. +Strength now has its own parser; `fetch_workout_templates` dispatches on +sportType=4. + +The fixtures below are the WIRE shapes of a real strength template built in +the Coros app and read back from /training/program/list on 2026-09-09: a 4x12 +decline dumbbell bench press at 31 kg, a 4x6 greatest stretch at bodyweight, +the same press entered in pounds, a 3x60s burpee with an effort target, and +4x10 bicycle crunches with rests skipped. + +That capture corrected three guesses taken from the write side: + + - a real lbs exercise comes back with `intensityPercent: 0` -- only + templates this server wrote carry the typed pounds there, so the pound + value has to be recovered from the kg equivalent in `intensityValue`; + - `intensityCustom: 1` is the bodyweight marker in the app's data too, not + just this server's. An exercise left at the app's default reads + `intensityValue: 0, intensityCustom: 0, intensityDisplayUnit: 0` and is a + real **0.0 kg**, not bodyweight -- verified by flipping one exercise from + that default to Bodyweight in the app and diffing the re-read, where + `intensityCustom` 0 -> 1 was the entire change; + - `intensityDisplayUnit: 0` carrying a non-zero value is not a weight at + all. It is the app's effort target on a 1-10 RPE scale (the burpee shows + "Target 5, Moderate" for `intensityValue: 5`), stored unscaled. + +The round-trip test is the load-bearing one for the write side: every value +the write side encodes, the read side must decode back to what was typed. +Note what it does NOT assert -- that the two speak the same NAMES. They do on +the load axis (weight_kg/weight_lbs/rest_seconds/sets/origin_id) and do not on +the target axis, where the write side takes target_type/target_value and the +read side returns reps/duration_seconds. That gap is deliberate and left to a +follow-up; see the GH #60 PR description. + +Pure dict-shape assertions -- no HTTP, no auth, no mocks. +""" + +import pytest + +from coros_mcp.coros_api import ( + _build_strength_program_payload, + _parse_strength_workout, + _parse_workout, +) + + +def _exercise(**overrides): + """Minimal WIRE-shaped strength exercise -- what the API hands back. + + Modelled on the 2026-09-09 capture, including that the API returns + `intensityDisplayUnit` as an int (6) where the write side sends a + string ("6"). + """ + base = { + "name": "T1061", + "originId": "1061", + "overview": "sid_strength_squats", + "targetType": 3, + "targetValue": 12, + "sets": 3, + "restType": 1, + "restValue": 60, + "intensityValue": 27900, + "intensityCustom": 0, + "intensityDisplayUnit": 6, + "intensityPercent": 0, + } + base.update(overrides) + return base + + +def _item(*exercises, **overrides): + base = { + "id": 900, + "name": "strength_decode_ref", + "sportType": 4, + "exerciseNum": len(exercises), + "duration": 600, + "sets": 1, + "exercises": list(exercises), + } + base.update(overrides) + return base + + +def _parse_one(**overrides): + return _parse_strength_workout(_item(_exercise(**overrides)))["exercises"][0] + + +# --------------------------------------------------------------------------- +# targetType: reps vs seconds vs unknown +# --------------------------------------------------------------------------- + +def test_reps_are_reps_not_seconds(): + """The bug in GH #60: targetType=3 is reps in the strength namespace, and + the endurance parser reported it as duration_seconds -- so a 3x12 squat + read back as "3 sets of 12 seconds".""" + ex = _parse_one(targetType=3, targetValue=12) + assert ex["reps"] == 12 + assert "duration_seconds" not in ex + assert ex["sets"] == 3 + + +def test_time_based_strength_exercise_keeps_duration_seconds(): + """targetType=2 is seconds in BOTH namespaces (a plank, say) -- correct + already, so it keeps its name.""" + ex = _parse_one(targetType=2, targetValue=60) + assert ex["duration_seconds"] == 60 + assert "reps" not in ex + + +def test_unknown_target_type_is_surfaced_raw_not_guessed(): + """An unknown targetType must be visibly unparsed rather than silently + labelled seconds -- that catch-all guess is what produced #59 and #60.""" + ex = _parse_one(targetType=99, targetValue=7) + assert ex["target_type_raw"] == 99 + assert ex["target_value_raw"] == 7 + assert "duration_seconds" not in ex + assert "reps" not in ex + + +def test_endurance_parser_also_stops_guessing(): + """Same fix on the endurance side: only targetType=2 is seconds there.""" + parsed = _parse_workout({"sportType": 1, "exercises": [ + {"name": "???", "targetType": 42, "targetValue": 500}, + ]})["exercises"][0] + assert parsed["target_type_raw"] == 42 + assert parsed["target_value_raw"] == 500 + assert "duration_seconds" not in parsed + + +# --------------------------------------------------------------------------- +# Weight -- kg / lbs / bodyweight / explicit zero +# --------------------------------------------------------------------------- + +def test_kg_weight_is_unscaled(): + """intensityValue is kg x 1000 (27900 -> 27.9), the value that used to + surface raw as intensity_low.""" + ex = _parse_one(intensityValue=27900, intensityDisplayUnit="6", intensityCustom=0) + assert ex["weight_kg"] == 27.9 + assert "weight_lbs" not in ex + assert "bodyweight" not in ex + + +def test_lbs_from_a_real_app_created_exercise(): + """The exact wire shape of the pounds exercise in the 2026-09-09 capture: + displayUnit 7 (int), the kg equivalent in intensityValue, and + **intensityPercent: 0**. + + The first draft of this parser read pounds out of intensityPercent, which + is only populated on templates this server wrote -- against real app data + it returned 0 lbs. The pound value has to be recovered from the kg + equivalent (19051 -> 19.051 kg -> 42 lbs), and rounded, or the athlete + gets 42.0002. + """ + ex = _parse_one( + intensityValue=19051, + intensityPercent=0, + intensityDisplayUnit=7, + intensityCustom=0, + ) + assert ex["weight_lbs"] == 42 + assert "weight_kg" not in ex + + +def test_lbs_from_a_template_this_server_wrote(): + """Our own writer does populate intensityPercent (lbs x 1e6) and sends + displayUnit as a string -- both shapes must decode.""" + ex = _parse_one( + intensityValue=20411, + intensityPercent=45_000_000, + intensityDisplayUnit="7", + intensityCustom=0, + ) + assert ex["weight_lbs"] == 45 + assert "weight_kg" not in ex + + +def test_bodyweight_app_shape(): + """App-created bodyweight: intensityCustom=1 with a ZERO value (this + server writes "" instead) and the unit left at 6. + + The marker is the only thing separating this from a 0.0 kg exercise -- + see the test below, which is the same row before it was switched to + Bodyweight in the app. + """ + ex = _parse_one(intensityValue=0, intensityCustom=1, intensityDisplayUnit=6) + assert ex["bodyweight"] is True + assert "weight_kg" not in ex + + +def test_app_default_zero_weight_is_not_bodyweight(): + """An untouched exercise reads `intensityValue: 0, intensityCustom: 0, + intensityDisplayUnit: 0` and the app renders it "0.0 kg". + + Treating unit 0 as "no unit, therefore bodyweight" was wrong, and wrong + in the direction that matters: reporting bodyweight here and rewriting + the template would drop a real (if zero) weight, because an omitted + weight is exactly how the write side spells bodyweight. + """ + ex = _parse_one(intensityValue=0, intensityCustom=0, intensityDisplayUnit=0) + assert ex["weight_kg"] == 0 + assert "bodyweight" not in ex + + +def test_effort_target_is_not_a_weight(): + """displayUnit 0 with a value is the app's effort target on its 1-10 RPE + scale, stored UNSCALED -- the timed burpee in the capture shows + "Target 5, Moderate" for intensityValue=5. + + Read as a weight it becomes 0.005 kg, which is what this parser did + before the app was consulted. + """ + ex = _parse_one(targetType=2, targetValue=60, + intensityValue=5, intensityCustom=0, intensityDisplayUnit=0) + assert ex["effort_target"] == 5 + assert "weight_kg" not in ex + assert ex["duration_seconds"] == 60 + + +def test_null_weight_reads_as_bodyweight(): + """The "" this server writes for bodyweight comes back from the API as + None -- observed 2026-09-09 when a written template was read back. A null + weight is no weight, which is what bodyweight means to the write side. + """ + ex = _parse_one(intensityValue=None, intensityCustom=1, intensityDisplayUnit=6) + assert ex["bodyweight"] is True + assert "weight_kg" not in ex + + +def test_effort_target_outside_the_apps_scale_is_preserved(): + """The server accepts an effort target of 99 (the app's scale is 1-10) + and hands it back unchanged -- confirmed by POSTing one live. The parser + reports what is stored rather than clamping it to a range the server + itself does not enforce; a value no app screen can produce is exactly + what a reader needs to see. + """ + assert _parse_one(intensityValue=99, intensityCustom=0, + intensityDisplayUnit=0)["effort_target"] == 99 + + +def test_unknown_display_unit_is_surfaced_raw(): + """Same principle as an unknown targetType: show it, don't scale it.""" + ex = _parse_one(intensityValue=1234, intensityCustom=0, intensityDisplayUnit=9) + assert ex["intensity_value_raw"] == 1234 + assert ex["intensity_display_unit_raw"] == 9 + assert "weight_kg" not in ex + + +def test_bodyweight_marker_written_by_this_server(): + """intensityCustom=1 with an empty intensityValue is what save_strength_ + workout_template emits for bodyweight.""" + ex = _parse_one(intensityValue="", intensityCustom=1, intensityDisplayUnit="6") + assert ex["bodyweight"] is True + assert "weight_kg" not in ex + + +def test_explicit_zero_kg_is_not_bodyweight(): + """weight_kg=0 renders as "0.00 kg" in the app and is deliberately + distinct from Bodyweight on the write side. A falsiness check here would + collapse the two, so the parser keys off intensityCustom.""" + ex = _parse_one(intensityValue=0, intensityCustom=0, intensityDisplayUnit="6") + assert ex["weight_kg"] == 0 + assert "bodyweight" not in ex + + +def test_strength_exercises_carry_no_endurance_intensity_fields(): + """intensity_low/intensity_high mean nothing in the strength namespace -- + they would carry a raw wire weight. Omitting them is more honest.""" + ex = _parse_one() + assert "intensity_low" not in ex + assert "intensity_high" not in ex + + +# --------------------------------------------------------------------------- +# Rest +# --------------------------------------------------------------------------- + +def test_rest_seconds_from_rest_type_1(): + assert _parse_one(restType=1, restValue=90)["rest_seconds"] == 90 + + +def test_skip_rests_reads_back_as_zero(): + """restType=3 is the app's "Skip rests" and carries no value -- the write + side emits it for rest_seconds=0, so it must read back as 0. + + Confirmed in the 2026-09-09 capture by the bicycle-crunches row, saved + with rests skipped: restType 3, restValue 0. Note the app's smallest + *real* rest is 3 seconds -- below that it only offers Skip rests -- so a + written rest_seconds of 1 or 2 has no representation in the app. + """ + ex = _parse_one(restType=3, restValue=0) + assert ex["rest_seconds"] == 0 + + +def test_unknown_rest_type_is_surfaced_raw(): + ex = _parse_one(restType=7, restValue=5) + assert ex["rest_type_raw"] == 7 + assert "rest_seconds" not in ex + + +# --------------------------------------------------------------------------- +# Header and program-level fields +# --------------------------------------------------------------------------- + +def test_header_fields_shared_with_endurance(): + parsed = _parse_strength_workout(_item(_exercise())) + assert parsed["id"] == "900" + assert parsed["name"] == "strength_decode_ref" + assert parsed["sport_type"] == 4 + assert parsed["sport_name"] == "Strength" + assert parsed["exercise_count"] == 1 + + +def test_circuit_rounds_and_total_duration_are_reported(): + """Program-level `sets` repeats the whole exercise list (circuit rounds); + endurance templates have no equivalent.""" + parsed = _parse_strength_workout(_item(_exercise(), sets=3, duration=1800)) + assert parsed["sets"] == 3 + assert parsed["total_duration_seconds"] == 1800 + + +def test_null_program_sets_normalizes_to_one_round(): + """App-created templates send `sets: null` -- they express repetition per + exercise instead (the capture had sets=null with 4 sets on each of its + three exercises). Reporting None as the circuit count would be worse than + saying "one round".""" + parsed = _parse_strength_workout(_item(_exercise(), sets=None)) + assert parsed["sets"] == 1 + + +# --------------------------------------------------------------------------- +# Endurance repeat-group headers (also from the 2026-09-09 capture) +# --------------------------------------------------------------------------- + +def test_app_created_repeat_group_header_is_not_a_step(): + """A repeat-group header in an app-created endurance template is + targetType=0/targetValue=0 with isGroup=True and the repeat count in + `sets` -- the shape found in the "Pyramide" template. + + The old catch-all reported it as a phantom `duration_seconds: 0` step; + the honest fallback surfaced it as an unknown type on the first live read + after that change, which is how it was found at all. + """ + parsed = _parse_workout({"sportType": 1, "exercises": [ + {"name": "", "targetType": 0, "targetValue": 0, "sets": 3, "isGroup": True, "groupId": "0"}, + {"name": "T3001", "targetType": 5, "targetValue": 40000, "sets": 1, + "isGroup": False, "groupId": "434164070839664642"}, + ]})["exercises"] + + header, sub_step = parsed + assert header["is_group"] is True + assert header["repeat"] == 3 + assert "duration_seconds" not in header + assert "target_type_raw" not in header + # Sub-steps stay flat but carry their parent's id, so the grouping is at + # least reconstructable by the caller. + assert sub_step["distance_meters"] == 400 + assert sub_step["group_id"] == "434164070839664642" + assert "group_id" not in header + + +# --------------------------------------------------------------------------- +# Round trip -- the load-bearing test +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("weight,expected", [ + ({}, {"bodyweight": True}), + ({"weight_kg": 27.9}, {"weight_kg": 27.9}), + ({"weight_kg": 0}, {"weight_kg": 0}), + ({"weight_lbs": 45}, {"weight_lbs": 45}), +]) +def test_write_read_round_trip_speaks_one_vocabulary(weight, expected): + """Every value save_strength_workout encodes, list_workout_templates must + decode back to what was typed -- 27.9 kg in, 27.9 kg out, not 27900. + + The `written` dict below deliberately uses target_type/target_value while + the assertions read `reps`: that mismatch is the one axis where read and + write do NOT share a name yet (see the module docstring). The VALUE + survives, which is what makes a mapped read-modify-write possible; the + names not matching is what makes it manual. + + origin_id/overview matter here specifically: the write side REQUIRES + origin_id, so a parsed template that omitted it could not be rewritten at + all. + """ + written = { + "origin_id": "1061", + "name": "T1061", + "overview": "sid_strength_squats", + "target_type": 3, + "target_value": 12, + "sets": 3, + "rest_seconds": 90, + **weight, + } + payload = _build_strength_program_payload( + name="round trip", exercises=[written], by_id={}, + ) + read = _parse_strength_workout({"id": 1, **payload})["exercises"][0] + + assert read["origin_id"] == written["origin_id"] + assert read["name"] == written["name"] + assert read["overview"] == written["overview"] + assert read["reps"] == written["target_value"] + assert read["sets"] == written["sets"] + assert read["rest_seconds"] == written["rest_seconds"] + for key, value in expected.items(): + assert read[key] == value + + +# --------------------------------------------------------------------------- +# Dispatch happens per exercise, not per program +# --------------------------------------------------------------------------- + + +def test_strength_exercise_inside_an_endurance_program_is_parsed_as_strength(): + """Both builders emit `hybridTotalSets`, so the wire format admits mixed + programs, and the strength builder stamps `sportType: 4` on every exercise. + + Dispatching on the program sport alone would send a hybrid template's + strength steps through the endurance parser -- reporting 12 reps as 12 + seconds and 27.9 kg as `intensity_low: 27900`, which is GH #60 again one + sport further along. + """ + parsed = _parse_workout({"sportType": 1, "exercises": [ + {"name": "Easy run", "targetType": 2, "targetValue": 600, "intensityValue": 140}, + {"name": "T1061", "sportType": 4, "originId": "1061", "overview": "sid_strength_squats", + "targetType": 3, "targetValue": 12, "sets": 3, "restType": 1, "restValue": 60, + "intensityValue": 27900, "intensityDisplayUnit": 6, "intensityCustom": 0}, + ]})["exercises"] + + assert parsed[0]["duration_seconds"] == 600 # endurance step, unchanged + assert parsed[1]["reps"] == 12 # strength step, not seconds + assert parsed[1]["weight_kg"] == 27.9 # not intensity_low: 27900 + assert "intensity_low" not in parsed[1] + + +def test_exercise_without_its_own_sport_type_falls_back_to_the_program(): + """App-created templates need not stamp sportType per exercise, so the + program sport stays the fallback.""" + parsed = _parse_strength_workout({"sportType": 4, "exercises": [ + {"name": "T1061", "originId": "1061", "targetType": 3, "targetValue": 10, "sets": 2}, + ]})["exercises"][0] + assert parsed["reps"] == 10 + + +# --------------------------------------------------------------------------- +# Wire types: what comes back is not what the write side sends +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("wire_value", [27900, "27900"]) +def test_weight_survives_a_string_intensity_value(wire_value): + """`intensityDisplayUnit` goes out as a string ("6") and comes back an int, + so the numeric fields are not type-stable either. `_unscale` raises + TypeError on a string -- coerce before dividing rather than trusting it. + """ + ex = _parse_one(intensityValue=wire_value, intensityDisplayUnit="6", intensityCustom=0) + assert ex["weight_kg"] == 27.9 + + +def test_pounds_survive_a_string_intensity_percent(): + ex = _parse_one( + intensityValue=19051, intensityDisplayUnit="7", + intensityPercent="42000000", intensityCustom=0, + ) + assert ex["weight_lbs"] == 42 + + +def test_unparseable_intensity_value_is_raw_not_bodyweight(): + """A value that merely fails to parse is UNKNOWN, not bodyweight. + + Only the two confirmed spellings of "no weight" -- the app's + intensityCustom=1 and this server's "" -- mean bodyweight. Collapsing + anything non-numeric into bodyweight would be the same guess this issue + exists to remove, and would silently rewrite a load on a read-modify-write. + """ + ex = _parse_one(intensityValue="junk", intensityDisplayUnit="6", intensityCustom=0) + assert ex["intensity_value_raw"] == "junk" + assert "bodyweight" not in ex + assert "weight_kg" not in ex