diff --git a/.gitignore b/.gitignore index 4e96aef..a32dfb7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,11 @@ nohup.out venv/ __pycache__/ *.pyc +*.egg-info/ # os .DS_Store .testvenv/ .cook_plan.json .probe_names.json +.cook_notes.json diff --git a/README.md b/README.md index 4c2b274..3008b10 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,30 @@ It escalates ("🔴 urgent") past ~90 minutes in a single continuous stall, and the difference between "still stalled, not wrapped yet" and "wrapped and *still* stalled" (the latter suggests bumping the grill temp instead of wrapping again). +### Cook notes — log the cut, weight, and how it turned out + +Sensors only know temperature. Attach the details that don't come off a probe — +the cut, its weight, and (since logging sometimes starts after the meat actually +went on) a corrected start time: + +```bash +./venv/bin/python history.py note 2 --cut "pork butt" --weight 8.5 \ + --on-grill "8:15 AM" --verdict "amazing" --notes "hot and fast, 276 avg pit temp" +``` + +`history.py show ` and the Cook Report both pick these up automatically — +weight and cut in the header, your verdict front and center, and the total +duration measured from your corrected `--on-grill` time instead of whichever +tick logging happened to start on. Persisted to a gitignored `.cook_notes.json`. + +This also fixed a real bug: stage-crossing times ("wrap reached...", "done +reached...") used to report the first single reading past the threshold, which +a brief probe-reinsertion spike (pulling the probe out to wrap, then putting it +back in — it reads grill-ambient air for a few ticks before settling into the +meat) could trigger hours before the meat actually got there. Both `history.py` +and the Cook Report now use the same spike-cleaned series the chart already +relies on, so the reported crossing is the real one. + --- ## 🔐 Credentials diff --git a/SECURITY.md b/SECURITY.md index aaa34d3..d9f865d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,7 @@ Pellet Pilot handles credentials for your Traeger grill account. This document d | Local machine | AppleScript injection via alarm text | User-influenced strings stripped of control characters (incl. newlines) and escaped before `osascript` | | Network path | MITM on MQTT WSS | TLS verification **on by default**; `TRAEGER_INSECURE_TLS=1` only as last resort | | Grill identifier (`thingName`, from the Traeger API) | Path/MQTT-topic injection if the upstream API ever returned an unexpected value | Validated against an alphanumeric pattern before use in any URL path or MQTT topic | -| Local config files (`.probe_names.json`, `presets/*.yaml`) | Oversized/malformed file handed to `json.load`/`yaml.safe_load` | Size-capped before parsing; YAML loaded with `safe_load` only (never `yaml.load`); `--preset` names are checked against path separators/`.`/`..` before being joined into a file path | +| Local config files (`.probe_names.json`, `.cook_notes.json`, `presets/*.yaml`) | Oversized/malformed file handed to `json.load`/`yaml.safe_load` | Size-capped before parsing; YAML loaded with `safe_load` only (never `yaml.load`); `--preset` names are checked against path separators/`.`/`..` before being joined into a file path | **Blast radius:** This tool is **read-only** against the grill — it polls status and sends command `90` (force status publish). It does **not** expose start/stop/set-temp. A stolen token cannot remotely ignite or change targets through this client. diff --git a/cook_notes.py b/cook_notes.py new file mode 100644 index 0000000..63be558 --- /dev/null +++ b/cook_notes.py @@ -0,0 +1,57 @@ +"""Manual per-cook notes -- the cut, its weight, a corrected on-grill time +(sensor logging can start well after the meat actually went on, e.g. mid +setup), and a free-text verdict/notes. None of this is sensor data; it's +what you tell it after the fact, same spirit as naming a probe. + +Persisted to .cook_notes.json, keyed by the cook session's own logged start +timestamp (see history.session_key()) -- that logged start can itself be +late (that's the whole reason --on-grill exists), but it's still a stable, +unique key per cook. +""" +import json +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +NOTES_FILE = os.path.join(HERE, ".cook_notes.json") + +# A notes file this size is already nonsensical; refuse to parse rather than +# hand a giant blob to json.load. Mirrors plan.py/probe_names.py's own caps. +_MAX_NOTES_FILE_BYTES = 256 * 1024 + +FIELDS = ("cut", "weight_lb", "on_grill", "verdict", "notes") + + +def load_notes(path=None): + # `path=None` (not `path=NOTES_FILE`) so a monkeypatched NOTES_FILE + # (e.g. in tests) is honored even by callers that don't pass `path` + # explicitly -- a default bound at def time would freeze the *original* + # value instead of looking it up per call. + path = path or NOTES_FILE + if not os.path.exists(path): + return {} + if os.path.getsize(path) > _MAX_NOTES_FILE_BYTES: + raise ValueError( + f"{path} is larger than expected ({_MAX_NOTES_FILE_BYTES} bytes) -- refusing to parse") + with open(path) as f: + return json.load(f) + + +def save_note(session_key, path=None, **fields): + """Merge the given fields into the note for `session_key` and persist. + Unknown fields are ignored; omitted/None fields leave any existing value + alone, so you can add a --verdict later without re-typing the weight. + """ + path = path or NOTES_FILE + notes = load_notes(path) + note = dict(notes.get(session_key, {})) + for k, v in fields.items(): + if k in FIELDS and v is not None: + note[k] = v + notes[session_key] = note + with open(path, "w") as f: + json.dump(notes, f, indent=2) + return note + + +def get_note(session_key, path=None): + return load_notes(path).get(session_key) diff --git a/history.py b/history.py index 652599d..0e1aca7 100644 --- a/history.py +++ b/history.py @@ -10,6 +10,10 @@ history.py show # detail + probe trend for one cook history.py summary # aggregate stats across all cooks history.py list --gap 30 # custom session gap (minutes) + history.py note --cut "pork butt" --weight 8.5 --on-grill "8:15 AM" \ + --verdict amazing --notes "hot and fast, 276 avg pit temp" + # attach manual notes -- see cook_notes.py. --on-grill corrects the + # start time when sensor logging began after the meat actually went on. """ import csv import datetime as dt @@ -18,6 +22,7 @@ import numpy as np +import cook_notes import plan from poll import MAX_PROBES from trend import sparkline @@ -90,6 +95,44 @@ def summarize(sess): } +def session_key(sess): + """Stable key tying a manual note (cook_notes.py) to this session -- the + session's own logged start timestamp. That logged start can itself be + later than when the meat actually went on (that's what --on-grill + corrects for), but it's still a unique, stable key per cook.""" + return sess[0]["_ts"].isoformat() + + +def stage_hits(sess, probe, stages): + """[(label, temp, datetime|None)] -- when each stage was first reached. + + Uses a spike-cleaned temperature series (plot.py's clean_and_events, the + same logic the chart already relies on) so a brief probe-reinsertion + spike -- e.g. the probe reading grill-ambient air for a few ticks right + after being pulled out for a wrap, before settling back into the meat -- + isn't mistaken for the real crossing. A naive "first reading >= stemp" + check would report the stage reached the moment that transient spike + passes through the threshold, which can be hours before the meat + genuinely got there. + """ + stagelist = stages.get(probe) + if not stagelist: + return [] + import plot # local import: plot.py imports from this module, so a + # top-level import here would be circular (same pattern + # poll.py already uses for its own plot import) + t0 = sess[0]["_ts"] + xs = [(r["_ts"] - t0).total_seconds() / 60 for r in sess] + temps = [_num(r.get(f"probe{probe}_temp")) for r in sess] + cx, cy, _ = plot.clean_and_events(xs, temps) + hits = [] + for stemp, label in stagelist: + hit_x = next((x for x, y in zip(cx, cy) if y >= stemp), None) + hit = (t0 + dt.timedelta(minutes=hit_x)) if hit_x is not None else None + hits.append((label, stemp, hit)) + return hits + + def cmd_list(groups): print(f"{'#':>2} {'date':<14} {'dur':>5} {'grill':>6} probes") for i, sess in enumerate(groups, 1): @@ -106,9 +149,30 @@ def cmd_show(groups, idx): sys.exit(f"No cook #{idx}; have 1..{len(groups)}") sess = groups[idx - 1] s = summarize(sess) - print(f"=== cook #{idx} — {s['start']:%Y-%m-%d %H:%M} → {s['end']:%H:%M} " - f"({s['duration_min']:.0f} min, {s['readings']} readings) ===") + note = cook_notes.get_note(session_key(sess)) or {} + + header = f"=== cook #{idx}" + if note.get("cut"): + header += f" — {note['cut']}" + if note.get("weight_lb"): + header += f" ({note['weight_lb']:g} lb)" + print(header + " ===") + + on_grill = note.get("on_grill") + if on_grill: + on_grill_dt = dt.datetime.fromisoformat(on_grill) + total_min = (s["end"] - on_grill_dt).total_seconds() / 60 + print(f"on the grill: {on_grill_dt:%Y-%m-%d %-I:%M %p} → {s['end']:%-I:%M %p} " + f"({total_min:.0f} min total -- logging started {s['start']:%-I:%M %p})") + else: + print(f"{s['start']:%Y-%m-%d %H:%M} → {s['end']:%H:%M} " + f"({s['duration_min']:.0f} min, {s['readings']} readings)") print(f"grill peak: {int(s['max_grill'] or 0)}° thing: {s['thing']}") + if note.get("verdict"): + print(f"verdict: {note['verdict']}") + if note.get("notes"): + print(f"notes: {note['notes']}") + cook_plan = plan.load_plan() t0 = sess[0]["_ts"] for k, v in s["probes"].items(): @@ -120,11 +184,49 @@ def cmd_show(groups, idx): + ("reached ✅" if v["reached"] else "not reached")) if v["target"] else "no target" print(f"P{k}: {int(v['start'])}→{int(v['final'])}° (peak {int(v['peak'])}°) {rate} {tgt}") print(f" {sparkline(arr)}") - for stemp, label in cook_plan.get(k, []): # when each stage was reached - hit = next((r["_ts"] for r in sess - if (_num(r.get(f"probe{k}_temp")) or -1) >= stemp), None) - if hit: - print(f" • {label} {int(stemp)}° reached {hit:%-I:%M %p}") + for label, stemp, hit in stage_hits(sess, k, cook_plan): + when = hit.strftime("%-I:%M %p") if hit else "not reached" + print(f" • {label} {int(stemp)}° reached {when}") + + +def _parse_time_of_day(spec, on_date): + """Parse "8:15 AM" or "08:15" and combine with on_date (a date, not a + datetime) -- --on-grill corrects the start TIME of a cook already in the + log, not an arbitrary date.""" + for fmt in ("%I:%M %p", "%I:%M%p", "%H:%M"): + try: + t = dt.datetime.strptime(spec.strip(), fmt).time() + return dt.datetime.combine(on_date, t) + except ValueError: + continue + raise ValueError(f'Could not parse time {spec!r} -- try "8:15 AM" or "08:15"') + + +def cmd_note(groups, idx, argv): + if idx < 1 or idx > len(groups): + sys.exit(f"No cook #{idx}; have 1..{len(groups)}") + sess = groups[idx - 1] + + def opt(name): + return argv[argv.index(name) + 1] if name in argv else None + + fields = {} + if opt("--cut"): + fields["cut"] = opt("--cut") + if opt("--weight"): + fields["weight_lb"] = float(opt("--weight")) + if opt("--on-grill"): + fields["on_grill"] = _parse_time_of_day(opt("--on-grill"), sess[0]["_ts"].date()).isoformat() + if opt("--verdict"): + fields["verdict"] = opt("--verdict") + if opt("--notes"): + fields["notes"] = opt("--notes") + if not fields: + sys.exit("Nothing to save -- pass at least one of " + "--cut/--weight/--on-grill/--verdict/--notes") + + note = cook_notes.save_note(session_key(sess), **fields) + print(f"Saved note for cook #{idx}: {note}") def cmd_summary(groups): @@ -149,10 +251,12 @@ def main(): cmd_list(groups) elif cmd == "show": cmd_show(groups, int(argv[1])) + elif cmd == "note": + cmd_note(groups, int(argv[1]), argv[2:]) elif cmd == "summary": cmd_summary(groups) else: - sys.exit(f"Unknown command '{cmd}'. Use: list | show | summary") + sys.exit(f"Unknown command '{cmd}'. Use: list | show | note [...] | summary") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 3e71dbf..ee00981 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ py-modules = [ "alarms", "traeger_client", "wrap_coach", + "cook_notes", ] [tool.setuptools.package-data] diff --git a/report.py b/report.py index 7c3576a..109f049 100644 --- a/report.py +++ b/report.py @@ -8,13 +8,15 @@ report.py # latest cook -> cook_report.html report.py --cook 2 --out r.html """ +import datetime as dt import html as _html import sys +import cook_notes import plan import probe_names from forecast import _FLAT, STALL_HI, STALL_LO -from history import _num, load_rows, sessions, summarize +from history import _num, load_rows, session_key, sessions, stage_hits, summarize from plot import render_svg _STAGE_ROW_STYLE = "color:#8a7d70;font-style:italic;font-size:12px" @@ -43,16 +45,6 @@ def _stall_minutes(sess, probe): return total -def _stage_hits(sess, probe, stages): - """[(label, temp, datetime|None)] -- when each stage was first reached.""" - hits = [] - for stemp, label in stages.get(probe, []): - hit = next((r["_ts"] for r in sess - if (_num(r.get(f"probe{probe}_temp")) or -1) >= stemp), None) - hits.append((label, stemp, hit)) - return hits - - def build_report(sess, stages=None, names=None, title="Pellet Pilot cook report"): """Self-contained HTML string -- no external assets, safe to share. @@ -63,6 +55,7 @@ def build_report(sess, stages=None, names=None, title="Pellet Pilot cook report" stages = stages or {} names = names or {} s = summarize(sess) + note = cook_notes.get_note(session_key(sess)) or {} svg = render_svg(sess) # whole-cook overview: grill + every probe rows = [] @@ -78,14 +71,41 @@ def build_report(sess, stages=None, names=None, title="Pellet Pilot cook report" f'{int(p["start"])}°{int(p["peak"])}°{int(p["final"])}°' f"{tgt}{stall:.0f} min" ) - for lbl, stemp, hit in _stage_hits(sess, i, stages): + for lbl, stemp, hit in stage_hits(sess, i, stages): when = hit.strftime("%-I:%M %p") if hit else "not reached" rows.append( f'{label} — ' f"{_html.escape(lbl)} ({int(stemp)}°){when}" ) - hrs = s["duration_min"] / 60 + # --on-grill (see cook_notes.py) corrects the start time when sensor + # logging began after the meat actually went on -- use it for the + # headline duration when given, but keep showing the logged span too. + on_grill = note.get("on_grill") + if on_grill: + on_grill_dt = dt.datetime.fromisoformat(on_grill) + hrs = (s["end"] - on_grill_dt).total_seconds() / 3600 + started_at = on_grill_dt + else: + hrs = s["duration_min"] / 60 + started_at = s["start"] + + cut_bits = [] + if note.get("cut"): + cut_bits.append(_html.escape(str(note["cut"]))) + if note.get("weight_lb"): + cut_bits.append(f"{note['weight_lb']:g} lb") + cut_line = f" · {' · '.join(cut_bits)}" if cut_bits else "" + + verdict_html = "" + if note.get("verdict") or note.get("notes"): + parts = [] + if note.get("verdict"): + parts.append(f"{_html.escape(str(note['verdict']))}") + if note.get("notes"): + parts.append(_html.escape(str(note["notes"]))) + verdict_html = (f'

{" — ".join(parts)}

') + esc_title = _html.escape(title) return f""" @@ -93,11 +113,12 @@ def build_report(sess, stages=None, names=None, title="Pellet Pilot cook report"
-

{esc_title}

+

{esc_title}{cut_line}

- {s['start']:%A, %B} {s['start'].day} · started {s['start']:%-I:%M %p} · + {started_at:%A, %B} {started_at.day} · started {started_at:%-I:%M %p} · {hrs:.1f}h cook · grill peak {int(s['max_grill'] or 0)}°

+ {verdict_html}
{svg}
diff --git a/tests/smoke.py b/tests/smoke.py index 52b3f3d..5c3d9dc 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -26,6 +26,7 @@ import paho.mqtt.client as mqtt # noqa: E402 import alarms # noqa: E402 +import cook_notes # noqa: E402 import export # noqa: E402 import forecast as fc_mod # noqa: E402 import history # noqa: E402 @@ -126,6 +127,108 @@ def test_history_sessions(): assert 1 in s["probes"] and 2 in s["probes"], s["probes"] +def test_history_session_key_stable_and_unique(): + a = _rows(dt.datetime(2026, 7, 3, 8, 0), 5) + b = _rows(dt.datetime(2026, 7, 4, 8, 0), 5) + assert history.session_key(a) == history.session_key(a) # stable + assert history.session_key(a) != history.session_key(b) # unique per cook + + +def test_stage_hits_ignores_probe_reinsertion_spike(): + # Real-world bug this guards against: pulling the probe to wrap it, then + # reinserting, makes it briefly read grill-ambient air (a spike well + # above the true meat temp) before settling back down. A naive "first + # reading >= threshold" check reports the stage reached the moment that + # transient spike crosses it -- hours before the meat actually got there. + t0 = dt.datetime(2026, 7, 4, 14, 0) + # climbs to 165 (wrap), spikes to 231 (probe-out artifact), settles back + # to 165-170 (post-wrap reality), then genuinely climbs to 205 much later. + temps = [160, 165, 192, 219, 226, 231, 213, 170, 167, 165, 165, 165] + \ + [165 + i for i in range(1, 41)] # slow real climb: 166 -> 205 over 40 ticks + rows = [] + for i, temp in enumerate(temps): + t = t0 + dt.timedelta(minutes=i) + rows.append({"ts": t.isoformat(), "_ts": t, "thing": "g", "grill": "270", "set": "275", + "ambient": "100", "system_status": "6", + "probe1_temp": str(temp), "probe1_set": "205", + "probe1_connected": "True", "probe1_alarm": "0"}) + stages = {1: [(165.0, "wrap"), (205.0, "done")]} + hits = history.stage_hits(rows, 1, stages) + wrap_hit = next(h for lbl, _, h in hits if lbl == "wrap") + done_hit = next(h for lbl, _, h in hits if lbl == "done") + # wrap genuinely was reached early (160->165 is a real climb, not a spike) + assert wrap_hit == t0 + dt.timedelta(minutes=1), wrap_hit + # done must NOT be the momentary spike at minute 3-5 -- it's the real, + # sustained crossing much later in the slow climb back up to 205. + assert done_hit is not None and done_hit >= t0 + dt.timedelta(minutes=40), done_hit + + +def test_cook_notes_save_load_merge_and_size_cap(): + path = "/tmp/.pellet_pilot_test_cook_notes.json" + try: + cook_notes.save_note("2026-07-04T11:13:09", path=path, cut="pork butt", weight_lb=8.5) + note = cook_notes.get_note("2026-07-04T11:13:09", path=path) + assert note == {"cut": "pork butt", "weight_lb": 8.5}, note + + # a later call merges in new fields without clobbering existing ones + cook_notes.save_note("2026-07-04T11:13:09", path=path, verdict="amazing") + note = cook_notes.get_note("2026-07-04T11:13:09", path=path) + assert note["cut"] == "pork butt" and note["verdict"] == "amazing", note + + # unknown fields are ignored rather than silently stored + cook_notes.save_note("2026-07-04T11:13:09", path=path, bogus_field="nope") + note = cook_notes.get_note("2026-07-04T11:13:09", path=path) + assert "bogus_field" not in note, note + + # size cap + with open(path, "w") as f: + f.write('{"x": "' + "y" * cook_notes._MAX_NOTES_FILE_BYTES + '"}') + try: + cook_notes.load_notes(path) + assert False, "expected ValueError for an oversized notes file" + except ValueError: + pass + finally: + if os.path.exists(path): + os.remove(path) + + +def test_history_note_cli_and_show_display(): + path = "/tmp/.pellet_pilot_test_cook_notes_cli.json" + orig_notes_file = cook_notes.NOTES_FILE + cook_notes.NOTES_FILE = path + try: + rows = _rows(dt.datetime(2026, 7, 4, 8, 15), 10, p1=160, p1_set=205) + groups = [rows] + history.cmd_note(groups, 1, ["--cut", "pork butt", "--weight", "8.5", + "--on-grill", "8:15 AM", "--verdict", "amazing"]) + note = cook_notes.get_note(history.session_key(rows), path=path) + assert note["cut"] == "pork butt" and note["weight_lb"] == 8.5, note + assert note["on_grill"].startswith("2026-07-04T08:15"), note + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + history.cmd_show(groups, 1) + out = buf.getvalue() + assert "pork butt" in out and "8.5 lb" in out, out + assert "amazing" in out, out + finally: + cook_notes.NOTES_FILE = orig_notes_file + if os.path.exists(path): + os.remove(path) + + +def test_parse_time_of_day_accepts_common_formats(): + d = dt.date(2026, 7, 4) + assert history._parse_time_of_day("8:15 AM", d) == dt.datetime(2026, 7, 4, 8, 15) + assert history._parse_time_of_day("08:15", d) == dt.datetime(2026, 7, 4, 8, 15) + try: + history._parse_time_of_day("not a time", d) + assert False, "expected ValueError for an unparseable time" + except ValueError: + pass + + def test_plot_svg(): svg = plot.render_svg(_rows(dt.datetime(2026, 7, 4, 9, 0), 12, p2=100)) minidom.parseString(svg) # well-formed XML @@ -934,6 +1037,28 @@ def test_report_shows_stage_crossing_times(): assert "not reached" in html # 203 ("done") never reached in this short synthetic climb +def test_report_includes_manual_note_and_on_grill_override(): + path = "/tmp/.pellet_pilot_test_report_notes.json" + orig_notes_file = cook_notes.NOTES_FILE + cook_notes.NOTES_FILE = path + try: + sess = _rows(dt.datetime(2026, 7, 4, 11, 0), 12, p1=160, p1_set=203) + key = history.session_key(sess) + # on-grill 3 hours before logging started -- the headline duration + # must reflect that, not just the logged span. + cook_notes.save_note(key, cut="pork butt", weight_lb=8.5, + on_grill="2026-07-04T08:00:00", verdict="amazing") + html = report.build_report(sess, stages={}) + assert "pork butt" in html and "8.5 lb" in html, html + assert "amazing" in html, html + # 08:00 -> 09:22 (last reading, 11:00 + 11*2min) = 3h22m = 3.4h + assert "3.4h cook" in html, html + finally: + cook_notes.NOTES_FILE = orig_notes_file + if os.path.exists(path): + os.remove(path) + + def test_stall_minutes(): # Flat readings inside the classic 150-175F stall band count; a normal # climb through the same range does not.