Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ nohup.out
venv/
__pycache__/
*.pyc
*.egg-info/

# os
.DS_Store
.testvenv/
.cook_plan.json
.probe_names.json
.cook_notes.json
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
57 changes: 57 additions & 0 deletions cook_notes.py
Original file line number Diff line number Diff line change
@@ -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)
120 changes: 112 additions & 8 deletions history.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
history.py show <id> # 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 <id> --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
Expand All @@ -18,6 +22,7 @@

import numpy as np

import cook_notes
import plan
from poll import MAX_PROBES
from trend import sparkline
Expand Down Expand Up @@ -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):
Expand All @@ -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():
Expand All @@ -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):
Expand All @@ -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 <id> | summary")
sys.exit(f"Unknown command '{cmd}'. Use: list | show <id> | note <id> [...] | summary")


if __name__ == "__main__":
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ py-modules = [
"alarms",
"traeger_client",
"wrap_coach",
"cook_notes",
]

[tool.setuptools.package-data]
Expand Down
51 changes: 36 additions & 15 deletions report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.

Expand All @@ -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 = []
Expand All @@ -78,26 +71,54 @@ def build_report(sess, stages=None, names=None, title="Pellet Pilot cook report"
f'<td>{int(p["start"])}°</td><td>{int(p["peak"])}°</td><td>{int(p["final"])}°</td>'
f"<td>{tgt}</td><td>{stall:.0f} min</td></tr>"
)
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'<tr style="{_STAGE_ROW_STYLE}"><td colspan="5">{label} — '
f"{_html.escape(lbl)} ({int(stemp)}°)</td><td>{when}</td></tr>"
)

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" &middot; {' &middot; '.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"<b>{_html.escape(str(note['verdict']))}</b>")
if note.get("notes"):
parts.append(_html.escape(str(note["notes"])))
verdict_html = (f'<p style="color:#d8cbbf;font-size:15px">{" &mdash; ".join(parts)}</p>')

esc_title = _html.escape(title)
return f"""<!doctype html>
<meta charset="utf-8">
<title>{esc_title}</title>
<body style="margin:0;background:#0d0b09;color:#d8cbbf;font-family:ui-sans-serif,Helvetica,Arial,sans-serif;
display:flex;justify-content:center;padding:24px 12px">
<div style="max-width:960px;width:100%">
<h1 style="font-weight:600;margin-bottom:4px">{esc_title}</h1>
<h1 style="font-weight:600;margin-bottom:4px">{esc_title}{cut_line}</h1>
<p style="color:#8a7d70;margin-top:0">
{s['start']:%A, %B} {s['start'].day} &middot; started {s['start']:%-I:%M %p} &middot;
{started_at:%A, %B} {started_at.day} &middot; started {started_at:%-I:%M %p} &middot;
{hrs:.1f}h cook &middot; grill peak {int(s['max_grill'] or 0)}&deg;
</p>
{verdict_html}
<div>{svg}</div>
<table style="width:100%;border-collapse:collapse;margin-top:16px;font-size:14px">
<tr style="color:#8a7d70;text-align:left">
Expand Down
Loading