cronometer-api-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cronometer_api_mcp/__init__.py +5 -0
- cronometer_api_mcp/client.py +699 -0
- cronometer_api_mcp/server.py +1027 -0
- cronometer_api_mcp-0.1.0.dist-info/METADATA +225 -0
- cronometer_api_mcp-0.1.0.dist-info/RECORD +8 -0
- cronometer_api_mcp-0.1.0.dist-info/WHEEL +4 -0
- cronometer_api_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- cronometer_api_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"""Cronometer mobile API client.
|
|
2
|
+
|
|
3
|
+
Reverse-engineered from the Cronometer Android/Flutter app (v4.52.6).
|
|
4
|
+
Communicates with mobile.cronometer.com/api/v2/* using clean JSON payloads.
|
|
5
|
+
|
|
6
|
+
Endpoint catalog was extracted via static analysis of libapp.so (Dart AOT
|
|
7
|
+
snapshot) from the APK. See the calorie-estimator project for the original
|
|
8
|
+
Frida-based traffic capture that established the auth flow and initial
|
|
9
|
+
endpoints.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from datetime import date, datetime
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
BASE_URL = "https://mobile.cronometer.com"
|
|
21
|
+
|
|
22
|
+
# Auth block sent with every request (mimics the Android app)
|
|
23
|
+
_APP_AUTH_TEMPLATE = {
|
|
24
|
+
"api": 3,
|
|
25
|
+
"os": "Android",
|
|
26
|
+
"build": "2807",
|
|
27
|
+
"flavour": "free",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Cronometer nutrient IDs (from the login response nutrient list)
|
|
31
|
+
NUTRIENT_IDS = {
|
|
32
|
+
"energy": 208,
|
|
33
|
+
"protein": 203,
|
|
34
|
+
"fat": 204,
|
|
35
|
+
"carbs": 205,
|
|
36
|
+
"fiber": 291,
|
|
37
|
+
"sugar": 269,
|
|
38
|
+
"sodium": 307,
|
|
39
|
+
"alcohol": 221,
|
|
40
|
+
"net_carbs": -1205,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CronometerError(Exception):
|
|
45
|
+
"""Raised when a Cronometer API call fails."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CronometerClient:
|
|
49
|
+
"""Stateful client for the Cronometer mobile API.
|
|
50
|
+
|
|
51
|
+
Caches the auth token in memory and reuses it across requests.
|
|
52
|
+
Re-authenticates automatically when the session expires.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._user_id: int | None = None
|
|
57
|
+
self._token: str | None = None
|
|
58
|
+
self._http = httpx.Client(
|
|
59
|
+
base_url=BASE_URL,
|
|
60
|
+
headers={
|
|
61
|
+
"user-agent": "Dart/3.9 (dart:io)",
|
|
62
|
+
"content-type": "text/plain; charset=utf-8",
|
|
63
|
+
"accept-encoding": "gzip",
|
|
64
|
+
},
|
|
65
|
+
timeout=30.0,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Authentication
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _get_credentials(self) -> tuple[str, str]:
|
|
73
|
+
username = os.getenv("CRONOMETER_USERNAME")
|
|
74
|
+
password = os.getenv("CRONOMETER_PASSWORD")
|
|
75
|
+
if not username or not password:
|
|
76
|
+
raise CronometerError(
|
|
77
|
+
"CRONOMETER_USERNAME and CRONOMETER_PASSWORD env vars must be set"
|
|
78
|
+
)
|
|
79
|
+
return username, password
|
|
80
|
+
|
|
81
|
+
def login(self) -> None:
|
|
82
|
+
"""Authenticate with Cronometer and cache the session token."""
|
|
83
|
+
username, password = self._get_credentials()
|
|
84
|
+
|
|
85
|
+
payload = {
|
|
86
|
+
"email": username,
|
|
87
|
+
"password": password,
|
|
88
|
+
"timezone": "America/New_York",
|
|
89
|
+
"userCode": None,
|
|
90
|
+
"build": "4.48.2 b2807-a",
|
|
91
|
+
"device": "Android 14 (SDK 34), Google Pixel 6 Pro",
|
|
92
|
+
"firebaseToken": "",
|
|
93
|
+
"features": {
|
|
94
|
+
"food_search_config": '{"newSearch": true, "newSpellcheck": true}',
|
|
95
|
+
"use_gpt_autofill": "true",
|
|
96
|
+
},
|
|
97
|
+
"auth": {
|
|
98
|
+
"userId": None,
|
|
99
|
+
"token": None,
|
|
100
|
+
**_APP_AUTH_TEMPLATE,
|
|
101
|
+
},
|
|
102
|
+
"lastSeen": 0,
|
|
103
|
+
"config": {"call_version": 2},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
logger.info("Logging in to Cronometer as %s", username)
|
|
107
|
+
resp = self._http.post("/api/v2/login", json=payload)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
data = resp.json()
|
|
110
|
+
|
|
111
|
+
if data.get("result") != "SUCCESS" and "sessionKey" not in data:
|
|
112
|
+
raise CronometerError(f"Login failed: {data}")
|
|
113
|
+
|
|
114
|
+
self._user_id = data["id"]
|
|
115
|
+
self._token = data["sessionKey"]
|
|
116
|
+
logger.info(
|
|
117
|
+
"Cronometer login successful (userId=%d, token=%s...)",
|
|
118
|
+
self._user_id,
|
|
119
|
+
self._token[:8] if self._token else "???",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _ensure_auth(self) -> None:
|
|
123
|
+
"""Login lazily on first use."""
|
|
124
|
+
if self._token is None:
|
|
125
|
+
self.login()
|
|
126
|
+
|
|
127
|
+
def _auth_block(self) -> dict:
|
|
128
|
+
return {
|
|
129
|
+
"userId": self._user_id,
|
|
130
|
+
"token": self._token,
|
|
131
|
+
**_APP_AUTH_TEMPLATE,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Request helpers
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _request(self, endpoint: str, payload: dict, *, _retried: bool = False) -> dict:
|
|
139
|
+
"""Send a v2 POST request with JSON auth block. Re-authenticates once on failure."""
|
|
140
|
+
self._ensure_auth()
|
|
141
|
+
|
|
142
|
+
payload["auth"] = self._auth_block()
|
|
143
|
+
payload.setdefault("lastSeen", 0)
|
|
144
|
+
|
|
145
|
+
logger.debug("Cronometer v2 request: POST %s", endpoint)
|
|
146
|
+
resp = self._http.post(endpoint, json=payload)
|
|
147
|
+
|
|
148
|
+
# Check for auth-related failures and retry once
|
|
149
|
+
if resp.status_code in (401, 403) and not _retried:
|
|
150
|
+
logger.warning(
|
|
151
|
+
"Cronometer auth rejected (%d), re-authenticating",
|
|
152
|
+
resp.status_code,
|
|
153
|
+
)
|
|
154
|
+
self._token = None
|
|
155
|
+
self.login()
|
|
156
|
+
return self._request(endpoint, payload, _retried=True)
|
|
157
|
+
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
data = resp.json()
|
|
160
|
+
|
|
161
|
+
# Some endpoints return errors in the body
|
|
162
|
+
if isinstance(data, dict) and data.get("result") == "FAILURE":
|
|
163
|
+
if not _retried:
|
|
164
|
+
logger.warning("Cronometer request failed, re-authenticating: %s", data)
|
|
165
|
+
self._token = None
|
|
166
|
+
self.login()
|
|
167
|
+
return self._request(endpoint, payload, _retried=True)
|
|
168
|
+
raise CronometerError(f"Cronometer API error: {data}")
|
|
169
|
+
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
def _v3_headers(self) -> dict:
|
|
173
|
+
"""Headers for v3 REST API requests (auth via headers, not JSON body)."""
|
|
174
|
+
return {
|
|
175
|
+
"x-crono-session": self._token,
|
|
176
|
+
"x-crono-app-os": "android",
|
|
177
|
+
"x-crono-app-build-number": "2807",
|
|
178
|
+
"x-crono-app-version": "4.48.2",
|
|
179
|
+
"content-type": "application/json; charset=utf-8",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def _request_v3(
|
|
183
|
+
self,
|
|
184
|
+
method: str,
|
|
185
|
+
path: str,
|
|
186
|
+
*,
|
|
187
|
+
json_body: dict | None = None,
|
|
188
|
+
_retried: bool = False,
|
|
189
|
+
) -> httpx.Response:
|
|
190
|
+
"""Send a v3 REST API request. Auth is via x-crono-session header.
|
|
191
|
+
|
|
192
|
+
The v3 API uses RESTful conventions: HTTP verbs, path-based routing,
|
|
193
|
+
and standard status codes (e.g. 204 for successful deletes).
|
|
194
|
+
|
|
195
|
+
Returns the raw httpx.Response (caller handles status interpretation).
|
|
196
|
+
"""
|
|
197
|
+
self._ensure_auth()
|
|
198
|
+
|
|
199
|
+
url = f"/api/v3/user/{self._user_id}{path}"
|
|
200
|
+
logger.debug("Cronometer v3 request: %s %s", method, url)
|
|
201
|
+
|
|
202
|
+
resp = self._http.request(
|
|
203
|
+
method, url, json=json_body, headers=self._v3_headers()
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Re-authenticate once on auth failures
|
|
207
|
+
if resp.status_code in (401, 403) and not _retried:
|
|
208
|
+
logger.warning(
|
|
209
|
+
"Cronometer v3 auth rejected (%d), re-authenticating",
|
|
210
|
+
resp.status_code,
|
|
211
|
+
)
|
|
212
|
+
self._token = None
|
|
213
|
+
self.login()
|
|
214
|
+
return self._request_v3(method, path, json_body=json_body, _retried=True)
|
|
215
|
+
|
|
216
|
+
return resp
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
# Date helpers
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _format_day(d: date | None = None) -> str:
|
|
224
|
+
"""Format a date as Cronometer expects: non-zero-padded 'YYYY-M-D'."""
|
|
225
|
+
d = d or date.today()
|
|
226
|
+
return f"{d.year}-{d.month}-{d.day}"
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Food search
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def search_food(self, query: str) -> list[dict]:
|
|
233
|
+
"""Search the Cronometer food database.
|
|
234
|
+
|
|
235
|
+
Returns a list of food entries, each with keys:
|
|
236
|
+
id, name, measureId, translationId, measureDisplayName, source,
|
|
237
|
+
globalPopularity, score, etc.
|
|
238
|
+
"""
|
|
239
|
+
payload = {
|
|
240
|
+
"query": query,
|
|
241
|
+
"tab": "ALL",
|
|
242
|
+
"sources": ["All"],
|
|
243
|
+
"config": {
|
|
244
|
+
"newSearch": True,
|
|
245
|
+
"newSpellcheck": True,
|
|
246
|
+
"call_version": 1,
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
data = self._request("/api/v2/find_food", payload)
|
|
250
|
+
foods = data.get("foods", [])
|
|
251
|
+
logger.info("Food search for %r returned %d results", query, len(foods))
|
|
252
|
+
return foods
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Food details
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def get_food(self, food_id: int) -> dict:
|
|
259
|
+
"""Fetch full food details, including server-assigned measure IDs.
|
|
260
|
+
|
|
261
|
+
Returns the full food object with keys: id, name, measures,
|
|
262
|
+
defaultMeasureId, nutrients, etc.
|
|
263
|
+
"""
|
|
264
|
+
payload = {"id": food_id, "config": {"call_version": 1}}
|
|
265
|
+
data = self._request("/api/v2/get_food", payload)
|
|
266
|
+
logger.info(
|
|
267
|
+
"Fetched food %d: %r (defaultMeasureId=%s)",
|
|
268
|
+
food_id,
|
|
269
|
+
data.get("name"),
|
|
270
|
+
data.get("defaultMeasureId"),
|
|
271
|
+
)
|
|
272
|
+
return data
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------------
|
|
275
|
+
# Custom food creation
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def create_custom_food(
|
|
279
|
+
self,
|
|
280
|
+
name: str,
|
|
281
|
+
*,
|
|
282
|
+
calories: float,
|
|
283
|
+
protein_g: float,
|
|
284
|
+
fat_g: float,
|
|
285
|
+
carbs_g: float,
|
|
286
|
+
fiber_g: float = 0,
|
|
287
|
+
sugar_g: float = 0,
|
|
288
|
+
sodium_mg: float = 0,
|
|
289
|
+
serving_name: str = "1 serving",
|
|
290
|
+
serving_grams: float = 100.0,
|
|
291
|
+
) -> dict:
|
|
292
|
+
"""Create a custom food in Cronometer.
|
|
293
|
+
|
|
294
|
+
Nutrient amounts are per the full serving (serving_grams).
|
|
295
|
+
They are normalized to per-100g internally, since Cronometer stores
|
|
296
|
+
all nutrient data on a per-100g basis.
|
|
297
|
+
|
|
298
|
+
Returns {"food_id": int, "measure_id": int | None}.
|
|
299
|
+
"""
|
|
300
|
+
# Cronometer stores nutrients per 100g -- normalize from per-serving.
|
|
301
|
+
scale = 100.0 / serving_grams if serving_grams > 0 else 1.0
|
|
302
|
+
|
|
303
|
+
net_carbs = max(0, carbs_g - fiber_g)
|
|
304
|
+
|
|
305
|
+
nutrients = [
|
|
306
|
+
{"id": NUTRIENT_IDS["energy"], "amount": round(calories * scale, 2)},
|
|
307
|
+
{"id": NUTRIENT_IDS["protein"], "amount": round(protein_g * scale, 2)},
|
|
308
|
+
{"id": NUTRIENT_IDS["fat"], "amount": round(fat_g * scale, 2)},
|
|
309
|
+
{"id": NUTRIENT_IDS["carbs"], "amount": round(carbs_g * scale, 2)},
|
|
310
|
+
{"id": NUTRIENT_IDS["fiber"], "amount": round(fiber_g * scale, 2)},
|
|
311
|
+
{"id": NUTRIENT_IDS["sugar"], "amount": round(sugar_g * scale, 2)},
|
|
312
|
+
{"id": NUTRIENT_IDS["sodium"], "amount": round(sodium_mg * scale, 2)},
|
|
313
|
+
# Derived / calculated fields the app includes
|
|
314
|
+
{"id": -203, "amount": round(protein_g * scale, 2)},
|
|
315
|
+
{"id": -204, "amount": round(fat_g * scale, 2)},
|
|
316
|
+
{"id": -205, "amount": round(carbs_g * scale, 2)},
|
|
317
|
+
{"id": -221, "amount": 0}, # alcohol
|
|
318
|
+
{"id": NUTRIENT_IDS["net_carbs"], "amount": round(net_carbs * scale, 2)},
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
payload = {
|
|
322
|
+
"data": {
|
|
323
|
+
"id": 0,
|
|
324
|
+
"name": name,
|
|
325
|
+
"category": 0,
|
|
326
|
+
"owner": None,
|
|
327
|
+
"retired": None,
|
|
328
|
+
"source": None,
|
|
329
|
+
"defaultMeasureId": 0,
|
|
330
|
+
"comments": None,
|
|
331
|
+
"alternateId": None,
|
|
332
|
+
"measures": [
|
|
333
|
+
{
|
|
334
|
+
"id": 0,
|
|
335
|
+
"name": serving_name,
|
|
336
|
+
"value": serving_grams,
|
|
337
|
+
"amount": 1.0,
|
|
338
|
+
"type": "Atomic",
|
|
339
|
+
}
|
|
340
|
+
],
|
|
341
|
+
"labelType": "AMERICAN_2016",
|
|
342
|
+
"nutrients": nutrients,
|
|
343
|
+
"properties": {},
|
|
344
|
+
"foodTags": [],
|
|
345
|
+
},
|
|
346
|
+
"config": {"call_version": 1},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
data = self._request("/api/v2/add_food", payload)
|
|
350
|
+
food_id = data.get("id")
|
|
351
|
+
if not food_id:
|
|
352
|
+
raise CronometerError(f"Failed to create custom food: {data}")
|
|
353
|
+
|
|
354
|
+
logger.info("Created custom food %r (id=%d)", name, food_id)
|
|
355
|
+
return {"food_id": food_id, "measure_id": None}
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
# Diary: add serving
|
|
359
|
+
# ------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def add_serving(
|
|
362
|
+
self,
|
|
363
|
+
food_id: int,
|
|
364
|
+
measure_id: int | None,
|
|
365
|
+
grams: float,
|
|
366
|
+
translation_id: int = 0,
|
|
367
|
+
day: date | None = None,
|
|
368
|
+
diary_group: int = 0,
|
|
369
|
+
) -> dict:
|
|
370
|
+
"""Log a food serving to the diary.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
food_id: Cronometer food ID.
|
|
374
|
+
measure_id: Measure/unit ID (from get_food). 0 for gram-based.
|
|
375
|
+
grams: Weight in grams.
|
|
376
|
+
translation_id: Translation ID (from search results, usually 0).
|
|
377
|
+
day: Date to log to. Defaults to today.
|
|
378
|
+
diary_group: Meal group. 0 = auto (based on time of day),
|
|
379
|
+
1 = Breakfast, 2 = Lunch, 3 = Dinner, 4 = Snacks.
|
|
380
|
+
|
|
381
|
+
Returns the serving confirmation dict from the API.
|
|
382
|
+
"""
|
|
383
|
+
now = datetime.now()
|
|
384
|
+
day_str = self._format_day(day)
|
|
385
|
+
time_str = f"{now.hour}:{now.minute}:{now.second}"
|
|
386
|
+
|
|
387
|
+
if diary_group == 0:
|
|
388
|
+
diary_group = _meal_group_for_hour(now.hour)
|
|
389
|
+
|
|
390
|
+
serving = {
|
|
391
|
+
"order": (diary_group << 16) | 1,
|
|
392
|
+
"day": day_str,
|
|
393
|
+
"time": time_str,
|
|
394
|
+
"offset": None,
|
|
395
|
+
"source": None,
|
|
396
|
+
"userId": self._user_id,
|
|
397
|
+
"servingId": None,
|
|
398
|
+
"type": "Serving",
|
|
399
|
+
"foodId": food_id,
|
|
400
|
+
"measureId": measure_id or 0,
|
|
401
|
+
"grams": grams,
|
|
402
|
+
"translationId": translation_id,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
payload = {
|
|
406
|
+
"serving": serving,
|
|
407
|
+
"config": {"call_version": 2},
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
data = self._request("/api/v2/add_serving", payload)
|
|
411
|
+
logger.info(
|
|
412
|
+
"Logged serving: food_id=%d, grams=%.1f, day=%s (serving_id=%s)",
|
|
413
|
+
food_id,
|
|
414
|
+
grams,
|
|
415
|
+
day_str,
|
|
416
|
+
data.get("id"),
|
|
417
|
+
)
|
|
418
|
+
return data
|
|
419
|
+
|
|
420
|
+
# ------------------------------------------------------------------
|
|
421
|
+
# Diary: get diary entries
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def get_diary(self, day: date | None = None) -> dict:
|
|
425
|
+
"""Get all diary entries for a given day.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
day: Date to fetch. Defaults to today.
|
|
429
|
+
|
|
430
|
+
Returns the full diary response from the API.
|
|
431
|
+
"""
|
|
432
|
+
payload = {
|
|
433
|
+
"day": self._format_day(day),
|
|
434
|
+
"config": {"call_version": 1},
|
|
435
|
+
}
|
|
436
|
+
data = self._request("/api/v2/get_diary", payload)
|
|
437
|
+
logger.info("Fetched diary for %s", self._format_day(day))
|
|
438
|
+
return data
|
|
439
|
+
|
|
440
|
+
# ------------------------------------------------------------------
|
|
441
|
+
# Diary: delete entries
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def delete_entries(self, entry_ids: list[str], day: date | None = None) -> dict:
|
|
445
|
+
"""Remove diary entries by their serving IDs.
|
|
446
|
+
|
|
447
|
+
Fetches the diary for the given day, matches entries by servingId,
|
|
448
|
+
and sends the full serving objects to the v3 DELETE endpoint.
|
|
449
|
+
|
|
450
|
+
Uses: DELETE /api/v3/user/{userId}/diary-entries
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
entry_ids: List of serving IDs to delete (as strings).
|
|
454
|
+
day: The day the entries belong to. Defaults to today.
|
|
455
|
+
|
|
456
|
+
Returns dict with removed IDs and count.
|
|
457
|
+
"""
|
|
458
|
+
# Fetch the diary to get full serving objects (required by v3 API)
|
|
459
|
+
diary_data = self.get_diary(day)
|
|
460
|
+
diary_entries = diary_data.get("diary", [])
|
|
461
|
+
|
|
462
|
+
id_set = set(str(eid) for eid in entry_ids)
|
|
463
|
+
to_delete = []
|
|
464
|
+
for entry in diary_entries:
|
|
465
|
+
if str(entry.get("servingId")) in id_set:
|
|
466
|
+
to_delete.append(entry)
|
|
467
|
+
|
|
468
|
+
if not to_delete:
|
|
469
|
+
logger.warning(
|
|
470
|
+
"None of the requested entry IDs found in diary for %s",
|
|
471
|
+
self._format_day(day),
|
|
472
|
+
)
|
|
473
|
+
return {"removed": [], "count": 0}
|
|
474
|
+
|
|
475
|
+
resp = self._request_v3(
|
|
476
|
+
"DELETE",
|
|
477
|
+
"/diary-entries",
|
|
478
|
+
json_body={"diaryEntries": to_delete},
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if resp.status_code == 204:
|
|
482
|
+
removed_ids = [str(e["servingId"]) for e in to_delete]
|
|
483
|
+
logger.info(
|
|
484
|
+
"Deleted %d entries for %s: %s",
|
|
485
|
+
len(removed_ids),
|
|
486
|
+
self._format_day(day),
|
|
487
|
+
removed_ids,
|
|
488
|
+
)
|
|
489
|
+
return {"removed": removed_ids, "count": len(removed_ids)}
|
|
490
|
+
else:
|
|
491
|
+
raise CronometerError(
|
|
492
|
+
f"Delete failed with status {resp.status_code}: {resp.text[:300]}"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
# Diary: mark day complete
|
|
497
|
+
# ------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
def mark_day_complete(self, day: date | None = None, complete: bool = True) -> dict:
|
|
500
|
+
"""Mark a diary day as complete or incomplete.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
day: Date to mark. Defaults to today.
|
|
504
|
+
complete: True to mark complete, False for incomplete.
|
|
505
|
+
|
|
506
|
+
Returns the API response.
|
|
507
|
+
"""
|
|
508
|
+
payload = {
|
|
509
|
+
"day": self._format_day(day),
|
|
510
|
+
"complete": complete,
|
|
511
|
+
"config": {"call_version": 1},
|
|
512
|
+
}
|
|
513
|
+
data = self._request("/api/v2/set_complete", payload)
|
|
514
|
+
status = "complete" if complete else "incomplete"
|
|
515
|
+
logger.info("Marked %s as %s", self._format_day(day), status)
|
|
516
|
+
return data
|
|
517
|
+
|
|
518
|
+
# ------------------------------------------------------------------
|
|
519
|
+
# Diary: copy from yesterday
|
|
520
|
+
# ------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
def copy_day(
|
|
523
|
+
self, from_day: date | None = None, to_day: date | None = None
|
|
524
|
+
) -> dict:
|
|
525
|
+
"""Copy all diary entries from one day to another.
|
|
526
|
+
|
|
527
|
+
Uses: POST /api/v2/copy
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
from_day: Source date. Defaults to yesterday.
|
|
531
|
+
to_day: Destination date. Defaults to today.
|
|
532
|
+
|
|
533
|
+
Returns the API response with the copied entries.
|
|
534
|
+
"""
|
|
535
|
+
from datetime import timedelta
|
|
536
|
+
|
|
537
|
+
to_day = to_day or date.today()
|
|
538
|
+
from_day = from_day or (to_day - timedelta(days=1))
|
|
539
|
+
|
|
540
|
+
payload = {
|
|
541
|
+
"from": self._format_day(from_day),
|
|
542
|
+
"to": self._format_day(to_day),
|
|
543
|
+
"diaryGroupNumber": None,
|
|
544
|
+
"config": {"call_version": 1},
|
|
545
|
+
}
|
|
546
|
+
data = self._request("/api/v2/copy", payload)
|
|
547
|
+
logger.info(
|
|
548
|
+
"Copied entries from %s to %s",
|
|
549
|
+
self._format_day(from_day),
|
|
550
|
+
self._format_day(to_day),
|
|
551
|
+
)
|
|
552
|
+
return data
|
|
553
|
+
|
|
554
|
+
# ------------------------------------------------------------------
|
|
555
|
+
# Nutrition: get nutrients
|
|
556
|
+
# ------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
def get_nutrients(self, day: date | None = None) -> dict:
|
|
559
|
+
"""Get nutrient totals for a given day.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
day: Date to fetch. Defaults to today.
|
|
563
|
+
|
|
564
|
+
Returns the nutrient summary from the API.
|
|
565
|
+
"""
|
|
566
|
+
payload = {
|
|
567
|
+
"day": self._format_day(day),
|
|
568
|
+
"config": {"call_version": 1},
|
|
569
|
+
}
|
|
570
|
+
data = self._request("/api/v2/get_nutrients", payload)
|
|
571
|
+
logger.info("Fetched nutrients for %s", self._format_day(day))
|
|
572
|
+
return data
|
|
573
|
+
|
|
574
|
+
def get_nutrition_scores(
|
|
575
|
+
self, day: date | None = None, *, include_supplements: bool = True
|
|
576
|
+
) -> dict:
|
|
577
|
+
"""Get nutrition scores with per-nutrient consumed amounts.
|
|
578
|
+
|
|
579
|
+
This is the richest nutrition endpoint -- it returns category scores
|
|
580
|
+
(All Targets, Vitamins, Minerals, Electrolytes, Antioxidants, Immune
|
|
581
|
+
Support, Metabolism, Bone Health, etc.) with the actual consumed amount
|
|
582
|
+
and confidence level for each nutrient.
|
|
583
|
+
|
|
584
|
+
Automatically fetches the diary to obtain serving IDs.
|
|
585
|
+
|
|
586
|
+
Uses: POST /api/v2/get_nutrition_scores
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
day: Date to score. Defaults to today.
|
|
590
|
+
include_supplements: Whether to include supplements in scoring.
|
|
591
|
+
|
|
592
|
+
Returns the nutrition scores from the API.
|
|
593
|
+
"""
|
|
594
|
+
diary_data = self.get_diary(day)
|
|
595
|
+
diary_entries = diary_data.get("diary", [])
|
|
596
|
+
|
|
597
|
+
serving_ids = [
|
|
598
|
+
e["servingId"]
|
|
599
|
+
for e in diary_entries
|
|
600
|
+
if e.get("type") == "Serving" and "servingId" in e
|
|
601
|
+
]
|
|
602
|
+
|
|
603
|
+
payload = {
|
|
604
|
+
"startDay": "1900-1-1",
|
|
605
|
+
"endDay": "1900-1-1",
|
|
606
|
+
"servingIds": serving_ids,
|
|
607
|
+
"supplements": "true" if include_supplements else "false",
|
|
608
|
+
"config": {"call_version": 1},
|
|
609
|
+
}
|
|
610
|
+
data = self._request("/api/v2/get_nutrition_scores", payload)
|
|
611
|
+
logger.info(
|
|
612
|
+
"Fetched nutrition scores for %s (%d servings)",
|
|
613
|
+
self._format_day(day),
|
|
614
|
+
len(serving_ids),
|
|
615
|
+
)
|
|
616
|
+
return data
|
|
617
|
+
|
|
618
|
+
# ------------------------------------------------------------------
|
|
619
|
+
# Macro targets
|
|
620
|
+
# ------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
def get_macro_schedules(self) -> dict:
|
|
623
|
+
"""Get the weekly macro target schedule.
|
|
624
|
+
|
|
625
|
+
Returns the schedule mapping days of week to macro templates.
|
|
626
|
+
"""
|
|
627
|
+
payload = {"config": {"call_version": 1}}
|
|
628
|
+
data = self._request("/api/v2/get_macro_schedules", payload)
|
|
629
|
+
logger.info("Fetched macro schedules")
|
|
630
|
+
return data
|
|
631
|
+
|
|
632
|
+
def get_macro_target_templates(self) -> dict:
|
|
633
|
+
"""Get all saved macro target templates.
|
|
634
|
+
|
|
635
|
+
Returns the list of macro target templates with their values.
|
|
636
|
+
"""
|
|
637
|
+
payload = {"config": {"call_version": 1}}
|
|
638
|
+
data = self._request("/api/v2/get_macro_target_templates", payload)
|
|
639
|
+
logger.info("Fetched macro target templates")
|
|
640
|
+
return data
|
|
641
|
+
|
|
642
|
+
# ------------------------------------------------------------------
|
|
643
|
+
# Fasting
|
|
644
|
+
# ------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
def get_fasting_with_date_range(
|
|
647
|
+
self, start: date | None = None, end: date | None = None
|
|
648
|
+
) -> dict:
|
|
649
|
+
"""Get fasting history for a date range.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
start: Start date. Defaults to 30 days ago.
|
|
653
|
+
end: End date. Defaults to today.
|
|
654
|
+
|
|
655
|
+
Returns fasting entries from the API.
|
|
656
|
+
"""
|
|
657
|
+
from datetime import timedelta
|
|
658
|
+
|
|
659
|
+
end = end or date.today()
|
|
660
|
+
start = start or (end - timedelta(days=30))
|
|
661
|
+
|
|
662
|
+
payload = {
|
|
663
|
+
"start": self._format_day(start),
|
|
664
|
+
"end": self._format_day(end),
|
|
665
|
+
"config": {"call_version": 1},
|
|
666
|
+
}
|
|
667
|
+
data = self._request("/api/v2/get_fasting_with_date_range", payload)
|
|
668
|
+
logger.info("Fetched fasting data %s to %s", start, end)
|
|
669
|
+
return data
|
|
670
|
+
|
|
671
|
+
def get_fasting_stats(self) -> dict:
|
|
672
|
+
"""Get aggregate fasting statistics.
|
|
673
|
+
|
|
674
|
+
Returns total fasting hours, longest fast, averages, etc.
|
|
675
|
+
"""
|
|
676
|
+
payload = {"config": {"call_version": 1}}
|
|
677
|
+
data = self._request("/api/v2/get_fasting_stats", payload)
|
|
678
|
+
logger.info("Fetched fasting stats")
|
|
679
|
+
return data
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# ======================================================================
|
|
683
|
+
# Helpers
|
|
684
|
+
# ======================================================================
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _meal_group_for_hour(hour: int) -> int:
|
|
688
|
+
"""Map hour of day to a Cronometer diary meal group.
|
|
689
|
+
|
|
690
|
+
1 = Breakfast, 2 = Lunch, 3 = Dinner, 4 = Snacks.
|
|
691
|
+
"""
|
|
692
|
+
if 4 <= hour < 10:
|
|
693
|
+
return 1 # Breakfast
|
|
694
|
+
elif 10 <= hour < 14:
|
|
695
|
+
return 2 # Lunch
|
|
696
|
+
elif 14 <= hour < 21:
|
|
697
|
+
return 3 # Dinner
|
|
698
|
+
else:
|
|
699
|
+
return 4 # Snacks
|