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.
@@ -0,0 +1,5 @@
1
+ """Cronometer MCP server using the mobile REST API."""
2
+
3
+ from .client import CronometerClient, CronometerError
4
+
5
+ __all__ = ["CronometerClient", "CronometerError"]
@@ -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