diet-guard/diet_guard/_state.py
Krzysztof kuhy Rudnicki c310861855 slots: minute-of-day consumers (phase 2 of 3: Python side)
Every Python consumer now speaks slot minutes:

- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
  at every "is this slot logged?" site: gate, CLI status, MCP, catering
  occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
  keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
  whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
  legacy int hour, validated against today's slots; get_slots returns
  {minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
  list, typed times, Up/Down step without posting the popdown), locked
  as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:53:39 +02:00

216 lines
7.6 KiB
Python

"""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 UTC, datetime
import json
import logging
import os
from typing import TYPE_CHECKING
import uuid
from gatelock.log_integrity import (
compute_entry_hmac,
verify_entry_hmac,
)
from diet_guard._coerce import as_float
from diet_guard._constants import FOOD_LOG_FILE
from diet_guard._slot_wire import slot_fields
if TYPE_CHECKING:
from diet_guard._estimator import Nutrition
_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=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.
"""
return as_float(entry.get(key))
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.
Written atomically -- a temp file in the same directory, then
:func:`os.replace` -- so a concurrent reader (the gate now syncs while the
15-min timer may also be writing) never sees a half-written file and
mistakes a torn read for an empty log.
"""
FOOD_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_path = FOOD_LOG_FILE.with_name(f"{FOOD_LOG_FILE.name}.{os.getpid()}.tmp")
try:
with tmp_path.open("w") as handle:
json.dump(log, handle, indent=2)
tmp_path.replace(FOOD_LOG_FILE)
finally:
tmp_path.unlink(missing_ok=True)
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,
*,
components: list[dict[str, object]] | None = None,
when: datetime | None = None,
) -> dict[str, object]:
"""Append a signed entry for ``description`` to a day's log.
Args:
description: The user's free-text meal description.
nutrition: Estimated nutrition for the portion eaten.
slot: The meal-slot *minute of day* this entry satisfies (e.g.
``720`` for the 12:00 checkpoint, ``435`` for 07:15). Encoded
through :func:`diet_guard._slot_wire.slot_fields`, so a whole-hour
slot is written exactly as before the move to minutes. When None
the entry still counts toward the day's calories but does not mark
any slot as logged.
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.
when: Pre-log this meal against a future date/time instead of now
(e.g. via ``diet_guard ate --date ... --hour ...``). Must not be
in the past. The entry's ``time`` and day bucket are both derived
from this value, so it lands under the future date and the gate
sees that slot as already satisfied once that day arrives.
Returns:
The stored entry dict (carrying an ``hmac`` field when a key exists).
Raises:
ValueError: ``when`` is before the current time.
"""
if when is not None and when < now_local():
msg = "cannot log a meal in the past"
raise ValueError(msg)
effective = when or now_local()
entry: dict[str, object] = {
"id": str(uuid.uuid4()),
"time": effective.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:
# Before signing: the HMAC covers the whole dict, so a ``slot_min``
# added afterwards would make the entry fail verification and vanish.
entry.update(slot_fields(slot))
if components is not None:
entry["components"] = list(components)
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(effective.date().isoformat(), []).append(entry)
_write_log(log)
return entry
def load_log() -> DayLog:
"""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.
"""
raw = _read_raw_log()
verified: DayLog = {}
for day, entries in raw.items():
kept = [
entry
for entry in entries
if _entry_is_valid(entry) and not entry.get("deleted")
]
if kept:
verified[day] = kept
return verified