diff --git a/README.md b/README.md index 1f73d5e..db467e3 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,8 @@ cached session, so it also overrides a stale cached timezone. | Tool | Description | |------|-------------| -| `get_macro_targets` | Weekly macro schedule and saved target templates | +| `get_macro_targets` | Macro split (percent of energy), weekly macro schedule and saved target templates | +| `set_macro_split` | Set the protein / carbs / fat split in percent of energy | | `get_fasting_history` | Fasting history within a date range | | `get_fasting_stats` | Aggregate fasting statistics | | `list_biometrics` | List trackable biometric metrics and their units | diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..1244eb6 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -1861,6 +1861,54 @@ def get_macro_target_templates(self) -> dict: logger.info("Fetched macro target templates") return data + def get_macro_split(self) -> dict: + """The macro split (percent of energy) from the profile's prefs. + + The app keeps it as three user prefs, macroProtein, macroCarbs and + macroLipids, read back through /api/v2/get_profile. Prefs come as a + list of one-key objects. + + Returns {"protein": float, "carbs": float, "fat": float}; a share + that was never set is None. + """ + profile = self._request("/api/v2/get_profile", {"config": {"call_version": 1}}) + prefs: dict = {} + for pref in profile.get("prefs") or []: + prefs.update(pref) + return { + name: float(prefs[key]) if prefs.get(key) is not None else None + for name, key in _MACRO_SPLIT_PREFS.items() + } + + def set_macro_split(self, protein: float, carbs: float, fat: float) -> dict: + """Set the macro split, in percent of energy. + + Writes the three prefs with /api/v2/set_pref, one call per macro, as + the app does (it sends only the ones that changed; all three are + sent here so the result is exact). The shares must add up to 100. + + Returns the split as written. + """ + split = {"protein": protein, "carbs": carbs, "fat": fat} + for name, value in split.items(): + if not math.isfinite(value) or not 0 <= value <= 100: + raise ValueError(f"{name} must be between 0 and 100, got {value}") + if abs(sum(split.values()) - 100) > 0.01: + raise ValueError( + f"protein, carbs and fat must add up to 100, got {sum(split.values())}" + ) + for name, key in _MACRO_SPLIT_PREFS.items(): + payload = { + "key": key, + "val": str(float(split[name])), + "config": {"call_version": 1}, + } + data = self._request("/api/v2/set_pref", payload) + if data.get("result") != "SUCCESS": + raise CronometerError(f"Failed to set {key}: {data}") + logger.info("Set macro split to %s", split) + return split + # ------------------------------------------------------------------ # Fasting # ------------------------------------------------------------------ @@ -1991,6 +2039,14 @@ def _translation_id(food: dict) -> int: return 0 +# Profile prefs holding the macro split, in percent of energy. +_MACRO_SPLIT_PREFS = { + "protein": "macroProtein", + "carbs": "macroCarbs", + "fat": "macroLipids", +} + + 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..472ce96 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -895,8 +895,9 @@ def import_recipe(ingredients: str, name: str | None = None) -> str: def get_macro_targets() -> str: """Get current macro targets including weekly schedule and templates. - Returns the weekly macro schedule (which template applies to each day) - and all saved macro target templates with their values. + Returns the macro split (percent of energy for protein, carbs and fat), + the weekly macro schedule (which template applies to each day) and all + saved macro target templates with their values. """ try: client = _get_client() @@ -904,6 +905,7 @@ def get_macro_targets() -> str: templates = client.get_macro_target_templates() return _ok( { + "macro_split": client.get_macro_split(), "schedules": schedules, "templates": templates, } @@ -912,6 +914,26 @@ def get_macro_targets() -> str: return _err(e) +@mcp.tool(annotations=_WRITE_IDEMPOTENT) +def set_macro_split(protein: float, carbs: float, fat: float) -> str: + """Set how the energy target is split between protein, carbs and fat. + + This changes the user's real targets: confirm the numbers with the user + first, and read the current split with get_macro_targets so it can be + put back. Values are percent of energy and must add up to 100. + + Args: + protein: Protein share in percent (e.g. 30). + carbs: Carbohydrate share in percent (e.g. 40). + fat: Fat share in percent (e.g. 30). + """ + try: + client = _get_client() + return _ok({"macro_split": client.set_macro_split(protein, carbs, fat)}) + except Exception as e: + return _err(e) + + # ------------------------------------------------------------------ # Fasting # ------------------------------------------------------------------ diff --git a/tests/test_macro_split.py b/tests/test_macro_split.py new file mode 100644 index 0000000..d3f7f46 --- /dev/null +++ b/tests/test_macro_split.py @@ -0,0 +1,64 @@ +"""get_macro_split / set_macro_split: the macroProtein, macroCarbs and +macroLipids profile prefs, written with /api/v2/set_pref.""" + +from __future__ import annotations + +import pytest +from test_client import make_cold_client + +from cronometer_api_mcp.client import CronometerError + +SET_OK = {"result": "SUCCESS", "messages": []} + + +def test_get_macro_split_reads_profile_prefs(tmp_path): + profile = { + "prefs": [ + {"macroLipids": 30.0}, + {"tz": 0}, + {"macroProtein": "30.0"}, + {"macroCarbs": "40.0"}, + ] + } + client, _ = make_cold_client(tmp_path, [profile]) + + assert client.get_macro_split() == {"protein": 30.0, "carbs": 40.0, "fat": 30.0} + + +def test_get_macro_split_unset_share_is_none(tmp_path): + client, _ = make_cold_client(tmp_path, [{"prefs": [{"macroProtein": "25"}]}]) + + assert client.get_macro_split() == {"protein": 25.0, "carbs": None, "fat": None} + + +def test_set_macro_split_writes_all_three_prefs(tmp_path): + client, state = make_cold_client(tmp_path, [SET_OK]) + + result = client.set_macro_split(25, 45, 30) + + assert [(p["key"], p["val"]) for p in state["payloads"]] == [ + ("macroProtein", "25.0"), + ("macroCarbs", "45.0"), + ("macroLipids", "30.0"), + ] + assert all(p["config"] == {"call_version": 1} for p in state["payloads"]) + assert result == {"protein": 25, "carbs": 45, "fat": 30} + + +@pytest.mark.parametrize( + ("split", "message"), + [((30, 30, 30), "add up to 100"), ((-5, 70, 35), "between 0 and 100")], +) +def test_set_macro_split_validates_before_any_request(tmp_path, split, message): + client, state = make_cold_client(tmp_path, [SET_OK]) + + with pytest.raises(ValueError, match=message): + client.set_macro_split(*split) + assert state["payloads"] == [] + + +def test_set_macro_split_raises_on_unexpected_response(tmp_path): + client, _ = make_cold_client(tmp_path, [{"messages": []}]) + + with pytest.raises(CronometerError, match="macroProtein"): + client.set_macro_split(30, 40, 30) diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..7e2d65b 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -30,6 +30,7 @@ "mark_day_complete", "remove_food_entry", "search_foods", + "set_macro_split", "update_custom_food", }