diff --git a/README.md b/README.md index 1f73d5e..cbced34 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ cached session, so it also overrides a stale cached timezone. | Tool | Description | |------|-------------| +| `search_activities` | Find activities to log as exercise, by name | +| `add_exercise` | Log an exercise with its duration and calories burned | | `get_macro_targets` | Weekly macro schedule and saved target templates | | `get_fasting_history` | Fasting history within a date range | | `get_fasting_stats` | Aggregate fasting statistics | diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..61ada08 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -1960,6 +1960,115 @@ def get_biometrics( ) return data + # ------------------------------------------------------------------ + # Exercise + # ------------------------------------------------------------------ + + def get_activities(self) -> list[dict]: + """List every activity an exercise can be logged as. + + The app's activity picker merges two catalogs: the current one from + /api/v2/get_activity_forest (about 200 activities, ids from 1135 up, + grouped into categories) and the older, more detailed one from + /api/v2/all_legacy_activities (about 1100, ids 1-1134, e.g. "Walking + 3.0 mph, moderate"). The id ranges don't overlap, and add_exercise + accepts ids from either. + + Returns [{"id": int, "name": str, "legacy": bool}, ...]. + """ + config = {"config": {"call_version": 1}} + forest = self._request("/api/v2/get_activity_forest", dict(config)) + legacy = self._request("/api/v2/all_legacy_activities", dict(config)) + activities = [ + {"id": a["id"], "name": a["name"], "legacy": False} + for a in (forest.get("activitiesMap") or {}).values() + ] + activities += [ + {"id": a["id"], "name": a["name"], "legacy": True} + for a in legacy.get("activities") or [] + ] + return activities + + def add_exercise( + self, + activity_id: int, + minutes: float, + calories: float, + day: date | None = None, + diary_group: int = 0, + ) -> dict: + """Log an exercise with a given calorie burn. + + The app computes the burn itself from the activity, the minutes and + the user's weight and sends it with calorieOverride false. Here the + caller supplies it and calorieOverride is set, so no activity + intensity data is needed. The server still requires the body weight + (in lb), which is taken from the profile. + + Args: + activity_id: Activity ID from get_activities. + minutes: Duration in minutes. + calories: Calories burned (a positive number). + day: Date to log to. Defaults to today. + diary_group: Meal group, as for add_serving (0 = auto). + + Uses: POST /api/v2/add_exercise + + Returns {"exerciseId": int, "activityId", "minutes", "calories", "day"}. + """ + if not math.isfinite(minutes) or minutes <= 0: + raise ValueError(f"minutes must be positive, got {minutes}") + if not math.isfinite(calories) or calories < 0: + raise ValueError(f"calories must be zero or positive, got {calories}") + if diary_group == 0: + diary_group = _meal_group_for_hour(self.now().hour) + + exercise = { + "exerciseId": None, + "type": "Exercise", + "activityId": activity_id, + "activitySpecId": None, + "minutes": minutes, + "calories": -calories, # stored negative: energy out + "calorieOverride": True, + "weight": round(self._profile_weight_kg() / _KG_PER_LB, 1), + "day": self._format_day(day), + "order": (diary_group << 16) | 1, + "name": None, + "meta": None, + "externalId": None, + "source": None, + "userId": self.user_id, + } + payload = {"exercise": exercise, "config": {"call_version": 1}} + data = self._request("/api/v2/add_exercise", payload) + if not data.get("id"): + raise CronometerError(f"Failed to add exercise: {data}") + logger.info( + "Logged exercise %d (%.0f min, %.0f kcal) on %s", + activity_id, + minutes, + calories, + exercise["day"], + ) + return { + "exerciseId": data["id"], + "activityId": activity_id, + "minutes": minutes, + "calories": calories, + "day": (day or self.today()).isoformat(), + } + + def _profile_weight_kg(self) -> float: + """The current body weight from the profile's prefs, in kg.""" + profile = self._request("/api/v2/get_profile", {"config": {"call_version": 1}}) + for pref in profile.get("prefs") or []: + if "weightInKG" in pref: + return float(pref["weightInKG"]) + raise CronometerError( + "The profile has no weight; set one in Cronometer before logging exercise" + ) + # ====================================================================== # Helpers @@ -1991,6 +2100,9 @@ def _translation_id(food: dict) -> int: return 0 +_KG_PER_LB = 0.45359237 + + def _meal_group_for_hour(hour: int) -> int: """Map hour of day to a Cronometer diary meal group. diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..37b6fe5 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -1043,6 +1043,73 @@ def get_biometrics( return _err(e) +@mcp.tool(annotations=_READ_ONLY) +def search_activities(query: str) -> str: + """Find activities to log with add_exercise, by name. + + Searches Cronometer's two activity catalogs: the current one ("General + Walking", "HIIT") and the older, more specific one ("Walking 3.0 mph, + moderate"). Every word of the query must appear in the name. + + Args: + query: Activity name or keywords (e.g. "walking", "cycling"). + """ + try: + client = _get_client() + words = query.lower().split() + matches = [ + {"activity_id": a["id"], "name": a["name"], "legacy": a["legacy"]} + for a in client.get_activities() + if all(w in a["name"].lower() for w in words) + ] + return _ok({"query": query, "count": len(matches), "activities": matches}) + except Exception as e: + return _err(e) + + +@mcp.tool(annotations=_WRITE) +def add_exercise( + activity_id: int, + minutes: float, + calories: float, + date: str | None = None, + diary_group: str = "auto", +) -> str: + """Log an exercise to the Cronometer diary. + + Use search_activities to find activity_id. The calorie burn is not + calculated for you: pass it (estimate it from the activity's MET value, + the minutes and the user's weight if the user doesn't know it). The + exercise appears in get_food_log's diary as an "Exercise" entry; + remove_food_entry with its exerciseId deletes it. + + Args: + activity_id: Activity ID from search_activities. + minutes: Duration in minutes. + calories: Calories burned, as a positive number. + date: Date as YYYY-MM-DD (defaults to today). + diary_group: Meal slot -- one of "auto", "breakfast", "lunch", + "dinner", "snacks" (default "auto"). + """ + try: + groups = {"auto": 0, "breakfast": 1, "lunch": 2, "dinner": 3, "snacks": 4} + group_int = groups.get(diary_group.strip().lower()) + if group_int is None: + return _err( + ValueError( + f"Invalid diary_group '{diary_group}'. " + "Must be one of: auto, breakfast, lunch, dinner, snacks." + ) + ) + client = _get_client() + result = client.add_exercise( + activity_id, minutes, calories, _parse_date(date), group_int + ) + return _ok({"exercise": result}) + except Exception as e: + return _err(e) + + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ diff --git a/tests/test_exercise.py b/tests/test_exercise.py new file mode 100644 index 0000000..e8662cc --- /dev/null +++ b/tests/test_exercise.py @@ -0,0 +1,76 @@ +"""get_activities / add_exercise.""" + +from __future__ import annotations + +from datetime import date + +import pytest +from test_client import make_cold_client + +from cronometer_api_mcp.client import CronometerError + +FOREST = { + "activitiesMap": { + "1342": {"id": 1342, "name": "Treadmill Walking", "calories": 0}, + }, + "groupsMap": {}, +} +LEGACY = {"activities": [{"id": 12, "name": "Walking 3.0 mph", "cals": 1.5}]} +PROFILE = {"prefs": [{"tz": 0}, {"weightInKG": 80.0}]} +ADDED = {"messages": [], "id": 881403934} + + +def test_get_activities_merges_both_catalogs(tmp_path): + client, _ = make_cold_client(tmp_path, [FOREST, LEGACY]) + + assert client.get_activities() == [ + {"id": 1342, "name": "Treadmill Walking", "legacy": False}, + {"id": 12, "name": "Walking 3.0 mph", "legacy": True}, + ] + + +def test_add_exercise_sends_override_calories_and_profile_weight(tmp_path): + client, state = make_cold_client(tmp_path, [PROFILE, ADDED]) + + result = client.add_exercise(1342, 15, 40, date(1900, 1, 2), diary_group=2) + + sent = state["payloads"][1] + assert sent["config"] == {"call_version": 1} + ex = sent["exercise"] + assert ex["activityId"] == 1342 + assert ex["minutes"] == 15 + assert ex["calories"] == -40 + assert ex["calorieOverride"] is True + assert ex["weight"] == 176.4 # 80 kg in lb + assert ex["day"] == "1900-1-2" + assert ex["order"] == (2 << 16) | 1 + assert ex["exerciseId"] is None + assert result == { + "exerciseId": 881403934, + "activityId": 1342, + "minutes": 15, + "calories": 40, + "day": "1900-01-02", + } + + +def test_add_exercise_needs_a_profile_weight(tmp_path): + client, state = make_cold_client(tmp_path, [{"prefs": [{"tz": 0}]}, ADDED]) + + with pytest.raises(CronometerError, match="no weight"): + client.add_exercise(1342, 15, 40, diary_group=1) + assert len(state["payloads"]) == 1 + + +@pytest.mark.parametrize( + ("minutes", "calories", "message"), + [(0, 10, "minutes"), (10, -5, "calories"), (float("nan"), 10, "minutes")], +) +def test_add_exercise_validates_before_any_request( + tmp_path, minutes, calories, message +): + client, state = make_cold_client(tmp_path, [PROFILE, ADDED]) + + with pytest.raises(ValueError, match=message): + client.add_exercise(1342, minutes, calories, diary_group=1) + assert state["payloads"] == [] diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..688dd7e 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -13,6 +13,7 @@ EXPECTED_TOOLS = { "add_custom_food", + "add_exercise", "add_food_entry", "add_recipe", "copy_day", @@ -29,6 +30,7 @@ "list_biometrics", "mark_day_complete", "remove_food_entry", + "search_activities", "search_foods", "update_custom_food", }