2026-06-10 22:31:18 +02:00
|
|
|
"""HMAC-signed daily food log for diet_guard.
|
|
|
|
|
|
|
|
|
|
Each meal is stored as an individually HMAC-signed entry, reusing the shared
|
|
|
|
|
key at ``/etc/workout-locker/hmac.key`` -- the same key the screen locker uses
|
|
|
|
|
-- so the log is tamper-evident: editing the JSON to fake compliance
|
|
|
|
|
invalidates the signature. On a system without the shared key, entries are
|
|
|
|
|
written unsigned and still accepted on read, so the tool degrades gracefully
|
|
|
|
|
instead of silently losing the data it just wrote.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from typing import TYPE_CHECKING
|
2026-06-22 18:22:24 +02:00
|
|
|
import uuid
|
2026-06-10 22:31:18 +02:00
|
|
|
|
2026-06-21 18:16:45 +02:00
|
|
|
from gatelock.log_integrity import (
|
2026-06-10 22:31:18 +02:00
|
|
|
compute_entry_hmac,
|
|
|
|
|
verify_entry_hmac,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-22 12:18:39 +02:00
|
|
|
from diet_guard._budget import daily_budget
|
|
|
|
|
from diet_guard._coerce import as_float
|
|
|
|
|
from diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE
|
2026-06-21 18:16:45 +02:00
|
|
|
|
2026-06-10 22:31:18 +02:00
|
|
|
if TYPE_CHECKING:
|
2026-06-22 12:18:39 +02:00
|
|
|
from diet_guard._estimator import Nutrition
|
2026-06-10 22:31:18 +02:00
|
|
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# On-disk shape: {"YYYY-MM-DD": [entry, entry, ...]}, newest entry last.
|
|
|
|
|
DayLog = dict[str, list[dict[str, object]]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def now_local() -> datetime:
|
|
|
|
|
"""Return the current time as a timezone-aware local datetime."""
|
|
|
|
|
return datetime.now(tz=timezone.utc).astimezone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _today() -> str:
|
|
|
|
|
"""Return today's *local* date as ``YYYY-MM-DD``.
|
|
|
|
|
|
|
|
|
|
Local, not UTC: "what I ate today" is a local-calendar concept, and a meal
|
|
|
|
|
eaten late in the evening must not roll into tomorrow's budget.
|
|
|
|
|
"""
|
|
|
|
|
return now_local().date().isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_float(entry: dict[str, object], key: str) -> float:
|
|
|
|
|
"""Return ``entry[key]`` coerced to float (0.0 if missing/non-numeric).
|
|
|
|
|
|
|
|
|
|
``bool`` is rejected even though it subclasses ``int``: a boolean stored in
|
|
|
|
|
a calorie or macro field is meaningless and must not count as 1 gram.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
entry: A stored log entry.
|
|
|
|
|
key: The numeric field name to read.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The field as a float, or 0.0 when absent or not a real number.
|
|
|
|
|
"""
|
feat: split oversized modules for 500-line limit, fix kasa coverage gap
Split diet_guard/_gatelock.py, wake_alarm/_alarm.py, and the
usage_report.py/_usage_report_parsing.py pair into focused
sub-modules so every Python file is <= 500 lines, satisfying
test_file_length.py. Install python-kasa into .venv (declared in
requirements but missing after the 3.13->3.14 venv upgrade),
fixing 8 failing smart_plug tests and restoring 100% coverage.
Also includes prior in-progress work from the working tree: the
wake_alarm Progress/View/Hardware field-grouping refactor,
brother_printer query module + tests, diet_guard foodbank/state/cli
updates, new shared coerce/logging_setup helpers, morning_routine
orchestrator tweaks, dwm window-manager config, gaming scripts, and
misc maintenance/digital-wellbeing script updates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 07:19:37 +02:00
|
|
|
return as_float(entry.get(key))
|
2026-06-10 22:31:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def entry_kcal(entry: dict[str, object]) -> float:
|
|
|
|
|
"""Return an entry's calorie count as a float (0.0 if missing/invalid)."""
|
|
|
|
|
return _entry_float(entry, "kcal")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_raw_log() -> DayLog:
|
|
|
|
|
"""Read the log file without verification (empty dict on any error)."""
|
|
|
|
|
if not FOOD_LOG_FILE.exists():
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
with FOOD_LOG_FILE.open() as handle:
|
|
|
|
|
data = json.load(handle)
|
|
|
|
|
except (OSError, json.JSONDecodeError):
|
|
|
|
|
_logger.warning("Cannot read food log %s", FOOD_LOG_FILE)
|
|
|
|
|
return {}
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
return {}
|
|
|
|
|
result: DayLog = {}
|
|
|
|
|
for key, value in data.items():
|
|
|
|
|
if isinstance(key, str) and isinstance(value, list):
|
|
|
|
|
result[key] = [item for item in value if isinstance(item, dict)]
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_log(log: DayLog) -> None:
|
|
|
|
|
"""Persist the full log to disk, creating the data directory if needed."""
|
|
|
|
|
FOOD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with FOOD_LOG_FILE.open("w") as handle:
|
|
|
|
|
json.dump(log, handle, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hmac_key_available() -> bool:
|
|
|
|
|
"""Return True if the shared HMAC key can be loaded for signing."""
|
|
|
|
|
return compute_entry_hmac({"_probe": True}) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry_is_valid(entry: dict[str, object]) -> bool:
|
|
|
|
|
"""Return True if an entry is untampered.
|
|
|
|
|
|
|
|
|
|
A signed entry must verify against the shared key. An unsigned entry is
|
|
|
|
|
accepted only when no key is available at all; an unsigned entry on a
|
|
|
|
|
system that *does* have a key means someone stripped the signature to
|
|
|
|
|
cheat, so it is rejected.
|
|
|
|
|
"""
|
|
|
|
|
if isinstance(entry.get("hmac"), str):
|
|
|
|
|
return verify_entry_hmac(entry)
|
|
|
|
|
return not _hmac_key_available()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def log_meal(
|
|
|
|
|
description: str,
|
|
|
|
|
nutrition: Nutrition,
|
|
|
|
|
slot: int | None = None,
|
2026-06-22 18:22:24 +02:00
|
|
|
*,
|
|
|
|
|
components: list[dict[str, object]] | None = None,
|
2026-06-10 22:31:18 +02:00
|
|
|
) -> dict[str, object]:
|
|
|
|
|
"""Append a signed entry for ``description`` to today's log.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
description: The user's free-text meal description.
|
|
|
|
|
nutrition: Estimated nutrition for the portion eaten.
|
|
|
|
|
slot: The meal-slot hour this entry satisfies (e.g. ``12`` for the
|
|
|
|
|
12:00 checkpoint). When None the entry still counts toward the
|
|
|
|
|
day's calories but does not mark any slot as logged.
|
2026-06-22 18:22:24 +02:00
|
|
|
components: For a composite (multi-item) meal, each component's own
|
|
|
|
|
name and macros. Carried on the log entry itself -- not just the
|
|
|
|
|
food bank -- so a bank rebuilt purely by replaying the log (the
|
|
|
|
|
companion phone app's sync model) can recover every component's
|
|
|
|
|
standalone nutrition, not just the composite's summed total.
|
2026-06-10 22:31:18 +02:00
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The stored entry dict (carrying an ``hmac`` field when a key exists).
|
|
|
|
|
"""
|
|
|
|
|
entry: dict[str, object] = {
|
2026-06-22 18:22:24 +02:00
|
|
|
"id": str(uuid.uuid4()),
|
2026-06-10 22:31:18 +02:00
|
|
|
"time": now_local().isoformat(timespec="seconds"),
|
|
|
|
|
"desc": description,
|
|
|
|
|
"grams": nutrition.grams,
|
|
|
|
|
"kcal": nutrition.kcal,
|
|
|
|
|
"protein_g": nutrition.protein_g,
|
|
|
|
|
"carbs_g": nutrition.carbs_g,
|
|
|
|
|
"fat_g": nutrition.fat_g,
|
|
|
|
|
"source": nutrition.source,
|
|
|
|
|
}
|
|
|
|
|
if slot is not None:
|
|
|
|
|
entry["slot"] = slot
|
2026-06-22 18:22:24 +02:00
|
|
|
if components is not None:
|
|
|
|
|
entry["components"] = list(components)
|
2026-06-10 22:31:18 +02:00
|
|
|
signature = compute_entry_hmac(entry)
|
|
|
|
|
if signature is not None:
|
|
|
|
|
entry["hmac"] = signature
|
|
|
|
|
else:
|
|
|
|
|
_logger.warning("HMAC key unavailable - logging unsigned entry")
|
|
|
|
|
|
|
|
|
|
log = _read_raw_log()
|
|
|
|
|
log.setdefault(_today(), []).append(entry)
|
|
|
|
|
_write_log(log)
|
|
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_log() -> DayLog:
|
2026-06-22 18:22:24 +02:00
|
|
|
"""Return the log with only valid, non-deleted entries retained.
|
|
|
|
|
|
|
|
|
|
A "deleted" entry is a tombstone left by :func:`undo_last_today`, not a
|
|
|
|
|
removal: it is kept on disk (and re-signed) rather than popped, so a sync
|
|
|
|
|
merge with another device can see the tombstone and not resurrect a
|
|
|
|
|
stale copy of the same entry. Readers simply filter it out here.
|
|
|
|
|
"""
|
2026-06-10 22:31:18 +02:00
|
|
|
raw = _read_raw_log()
|
|
|
|
|
verified: DayLog = {}
|
|
|
|
|
for day, entries in raw.items():
|
2026-06-22 18:22:24 +02:00
|
|
|
kept = [
|
|
|
|
|
entry
|
|
|
|
|
for entry in entries
|
|
|
|
|
if _entry_is_valid(entry) and not entry.get("deleted")
|
|
|
|
|
]
|
2026-06-10 22:31:18 +02:00
|
|
|
if kept:
|
|
|
|
|
verified[day] = kept
|
|
|
|
|
return verified
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def today_entries() -> list[dict[str, object]]:
|
|
|
|
|
"""Return today's valid log entries (possibly empty)."""
|
|
|
|
|
return load_log().get(_today(), [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def today_total_kcal() -> float:
|
|
|
|
|
"""Return total kcal logged today across valid entries."""
|
|
|
|
|
total = sum(entry_kcal(entry) for entry in today_entries())
|
|
|
|
|
return round(total, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def today_total_macros() -> tuple[float, float, float]:
|
|
|
|
|
"""Return today's total ``(protein_g, carbs_g, fat_g)`` across valid entries.
|
|
|
|
|
|
|
|
|
|
Returned as a fixed ``(protein, carbs, fat)`` triple so callers (the gate
|
|
|
|
|
dashboard, the CLI status) can show how the day's macros are stacking up
|
|
|
|
|
next to the calorie total.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The summed protein, carbohydrate, and fat grams, each rounded to 0.1 g.
|
|
|
|
|
"""
|
|
|
|
|
entries = today_entries()
|
|
|
|
|
protein = sum(_entry_float(entry, "protein_g") for entry in entries)
|
|
|
|
|
carbs = sum(_entry_float(entry, "carbs_g") for entry in entries)
|
|
|
|
|
fat = sum(_entry_float(entry, "fat_g") for entry in entries)
|
|
|
|
|
return round(protein, 1), round(carbs, 1), round(fat, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def logged_slots_today() -> set[int]:
|
|
|
|
|
"""Return the set of meal-slot hours already covered by today's log.
|
|
|
|
|
|
|
|
|
|
Only valid (HMAC-verified) entries count, so stripping entries to dodge a
|
|
|
|
|
checkpoint makes that slot reappear as unsatisfied -- the fail-closed
|
|
|
|
|
direction. An entry without a ``slot`` field (e.g. a snack logged with no
|
|
|
|
|
checkpoint) contributes calories but satisfies no slot.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The distinct integer slot hours logged today (possibly empty).
|
|
|
|
|
"""
|
|
|
|
|
slots: set[int] = set()
|
|
|
|
|
for entry in today_entries():
|
|
|
|
|
value = entry.get("slot")
|
|
|
|
|
if isinstance(value, int) and not isinstance(value, bool):
|
|
|
|
|
slots.add(value)
|
|
|
|
|
return slots
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def remaining_budget() -> float:
|
|
|
|
|
"""Return kcal remaining against the sealed budget (may be negative).
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
BudgetError: If the budget is uninitialized or its seal is broken;
|
|
|
|
|
the caller decides whether to guide the user or fail closed.
|
|
|
|
|
"""
|
|
|
|
|
return round(daily_budget() - today_total_kcal(), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def consumption_band() -> str:
|
|
|
|
|
"""Return a qualitative band for today's intake, never revealing the budget.
|
|
|
|
|
|
|
|
|
|
Mirrors how the focus daemon surfaces "at home?" rather than the raw
|
|
|
|
|
coordinates: the caller learns whether to worry, not the number behind the
|
|
|
|
|
threshold. The threshold still leaks by boundary-probing (watch the label
|
|
|
|
|
flip), so this hides the anchor, it does not make the budget unrecoverable.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
``"OVER BUDGET"``, ``"approaching limit"``, or ``"on track"``.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
BudgetError: Propagated from :func:`daily_budget` for the caller to
|
|
|
|
|
translate into guidance.
|
|
|
|
|
"""
|
|
|
|
|
budget = daily_budget()
|
|
|
|
|
consumed = today_total_kcal()
|
|
|
|
|
if consumed >= budget:
|
|
|
|
|
return "OVER BUDGET"
|
|
|
|
|
if consumed >= budget * BUDGET_WARN_FRACTION:
|
|
|
|
|
return "approaching limit"
|
|
|
|
|
return "on track"
|
|
|
|
|
|
|
|
|
|
|
Add cross-device log sync (Python half of Milestone 3)
Pulls every other device's pushed log from GitHub-backed dumb storage,
merges it with the local log, and pushes this device's own merged copy
back -- the PC half of the diet-guard-app sync plan.
- _sync_merge.py: pure union-by-id merge, tombstone always wins, legacy
(time, desc) dedup for pre-id entries. Commutative and idempotent.
- _sync_github.py: minimal GitHub Contents API client (list/get/put),
distinguishing a 404 on an unused path from the repo itself being
unreachable.
- _sync.py: orchestration -- pull, merge, re-sign every persisted entry
regardless of origin, write, rebuild the food bank, push. Re-signing
unconditionally is load-bearing: an unsigned phone-origin entry would
otherwise be silently dropped on the very next read once a machine
holds the shared HMAC key.
- _foodbank.rebuild_food_bank(): the "replay a full log into a fresh
bank" entrypoint the Python side was missing (the Dart port already
had its equivalent). Backs sync's bank-rebuild step.
- New diet-guard-sync.service/.timer (15-minute cadence, headless, a
separate unit from the gate so a held lock can't stall sync) and a
new install.sh step to install them.
- Created the private kuhyx/diet-guard-sync GitHub repo for storage.
Incidental to this feature: adding the `sync` subcommand pushed _cli.py
past the repo's 500-line cap, so `gate`'s CLI glue moved out alongside
sync's into _cli_gate.py/_cli_sync.py -- same split pattern already used
for the gate window logic itself, not a sync-specific design choice.
338 tests, 100% branch coverage. Verified importing and running cleanly
under /usr/bin/python (the production interpreter), not just the dev
venv -- the gap that caused the earlier 3-day outage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
2026-06-22 19:36:27 +02:00
|
|
|
def read_raw_log() -> DayLog:
|
|
|
|
|
"""Return the log exactly as stored, including tombstoned/invalid entries.
|
|
|
|
|
|
|
|
|
|
Public counterpart of :func:`_read_raw_log`, for the sync orchestration
|
|
|
|
|
(:mod:`diet_guard._sync`), which must see tombstones to merge them (the
|
|
|
|
|
filtered :func:`load_log` drops them) and must not discard an entry that
|
|
|
|
|
fails verification just because a phone-origin copy was never signed.
|
|
|
|
|
"""
|
|
|
|
|
return _read_raw_log()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_raw_log(log: DayLog) -> None:
|
|
|
|
|
"""Persist ``log`` verbatim, overwriting the file on disk.
|
|
|
|
|
|
|
|
|
|
Public counterpart of :func:`_write_log`, for :mod:`diet_guard._sync` to
|
|
|
|
|
write back a merged log after re-signing it.
|
|
|
|
|
"""
|
|
|
|
|
_write_log(log)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resign_entry(entry: dict[str, object]) -> dict[str, object]:
|
|
|
|
|
"""Return a copy of ``entry`` with a freshly computed ``hmac``.
|
|
|
|
|
|
|
|
|
|
Strips any existing signature first, mirroring :func:`undo_last_today`:
|
|
|
|
|
a signature computed on another device (or none, if the phone -- which
|
|
|
|
|
never holds the shared key -- produced this entry) cannot be trusted
|
|
|
|
|
as-is, and recomputing is the only way :func:`_entry_is_valid` will
|
|
|
|
|
accept it back on the next read. A no-op (signature-wise) when no HMAC
|
|
|
|
|
key is available locally, matching :func:`log_meal`'s degrade-gracefully
|
|
|
|
|
behavior.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
entry: A log entry, signed or not.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A new dict equal to ``entry`` except for its ``hmac`` field.
|
|
|
|
|
"""
|
|
|
|
|
resigned = dict(entry)
|
|
|
|
|
resigned.pop("hmac", None)
|
|
|
|
|
signature = compute_entry_hmac(resigned)
|
|
|
|
|
if signature is not None:
|
|
|
|
|
resigned["hmac"] = signature
|
|
|
|
|
return resigned
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 22:31:18 +02:00
|
|
|
def undo_last_today() -> dict[str, object] | None:
|
2026-06-22 18:22:24 +02:00
|
|
|
"""Tombstone today's most recently logged, not-yet-undone entry.
|
|
|
|
|
|
|
|
|
|
Marks the entry ``deleted`` in place and re-signs it, rather than
|
|
|
|
|
physically removing it: a sync merge with another device only ever
|
|
|
|
|
*adds* entries it hasn't seen before, so a physical delete here would be
|
|
|
|
|
silently resurrected the next time that device's stale copy is pulled
|
|
|
|
|
back in. The tombstone travels with the entry instead, and every reader
|
|
|
|
|
(:func:`load_log`, the food-bank rebuild) already skips it.
|
2026-06-10 22:31:18 +02:00
|
|
|
|
2026-06-22 18:22:24 +02:00
|
|
|
Operates on the raw log so a mistaken entry can always be undone, even
|
|
|
|
|
one that would not pass verification.
|
2026-06-10 22:31:18 +02:00
|
|
|
|
|
|
|
|
Returns:
|
2026-06-22 18:22:24 +02:00
|
|
|
The tombstoned entry, or None if nothing undoable was logged today.
|
2026-06-10 22:31:18 +02:00
|
|
|
"""
|
|
|
|
|
log = _read_raw_log()
|
|
|
|
|
today = _today()
|
|
|
|
|
entries = log.get(today)
|
|
|
|
|
if not entries:
|
|
|
|
|
return None
|
2026-06-22 18:22:24 +02:00
|
|
|
for entry in reversed(entries):
|
|
|
|
|
if entry.get("deleted"):
|
|
|
|
|
continue
|
|
|
|
|
entry["deleted"] = True
|
|
|
|
|
entry.pop("hmac", None)
|
|
|
|
|
signature = compute_entry_hmac(entry)
|
|
|
|
|
if signature is not None:
|
|
|
|
|
entry["hmac"] = signature
|
|
|
|
|
_write_log(log)
|
|
|
|
|
return entry
|
|
|
|
|
return None
|