From 87d360964419cf8c7c69bbeb6caf9beac42c9d4a Mon Sep 17 00:00:00 2001 From: ctopherwilliams <35182714+ctopherwilliams@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:14:52 -0500 Subject: [PATCH] feat: shareable Cook Report (self-contained HTML) Adds report.py: one HTML file with an embedded SVG chart (grill + every probe, reusing plot.py's existing renderer) and per-probe stats -- start/ peak/final temp, genuine stall time, and stage-crossing timestamps. No server, no external assets -- safe to email or post. Deliberately omits the grill's raw thingName (a device identifier) that history.py's local-only summary carries, since this report is meant to leave the machine. Wired into the unified `pellet report` command. Fixed a real bug caught by its own test: the first stall-time draft only checked temperature *range* (150-175F), which would call a normal climb passing through that band "stalled" -- corrected to also require a near-flat rate (matching forecast.py's actual stall definition). --- README.md | 8 +++ SECURITY.md | 1 + pellet.py | 9 +++- pyproject.toml | 1 + report.py | 136 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/smoke.py | 53 ++++++++++++++++--- 6 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 report.py diff --git a/README.md b/README.md index ac1ed67..21c8f78 100644 --- a/README.md +++ b/README.md @@ -388,8 +388,16 @@ The grill cloud does **not** expose past temperatures — the in-app graph is dr # Grafana-friendly export (local files) or a localhost Prometheus endpoint ./venv/bin/python export.py --format influx --out cook.lp ./venv/bin/python export.py --serve # http://127.0.0.1:9109/metrics + +# Shareable Cook Report — one self-contained HTML file (chart + stats), no server +./venv/bin/python report.py --out cook_report.html ``` +The Cook Report embeds its chart inline and never includes the grill's device +identifier, so it's safe to send to someone or post — unlike the raw `cook_log.csv`. + + + **Remote alarms** (in addition to the local macOS notification) — set any of these env vars and probe-crossing alerts are delivered there too: diff --git a/SECURITY.md b/SECURITY.md index 4b40d19..aaa34d3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,7 @@ Pellet Pilot handles credentials for your Traeger grill account. This document d | Cognito IdToken (~1h) | Session hijack → read grill status, issue command `90` (status refresh only) | Held in memory only; renewed via **refresh token** on expiry (no password re-sent) during `--watch`, with backoff if renewal keeps failing | | Bitwarden session (`.bw_session`) | Full vault access | Gitignored; warn if file mode ≠ `600`; session key passed to `bw` via `BW_SESSION` env, not `--session` argv (avoids exposure via `ps`/procfs) | | Cook logs (`cook_log.csv`) | Privacy (temps, timing, thing names) | Gitignored | +| Cook Report (`report.py`, meant to be shared) | Leaking the grill's device identifier to whoever the report is sent/posted to | `thingName` is deliberately never included in the report body, unlike `history.py`'s local-only CLI output | | 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 | diff --git a/pellet.py b/pellet.py index 3cae818..db9edb6 100644 --- a/pellet.py +++ b/pellet.py @@ -9,6 +9,7 @@ pellet history [...history.py flags...] # browse past cooks pellet trend [...trend.py flags...] # rate-of-rise analysis pellet chart [...plot.py flags...] # render a cook chart + pellet report [...report.py flags...] # shareable self-contained Cook Report HTML pellet export [...export.py flags...] # Grafana-ingestible export pellet presets # list available --preset names @@ -84,13 +85,19 @@ def main(): plot.main() return + if cmd == "report": + import report + sys.argv = ["pellet report"] + rest + report.main() + return + if cmd == "export": import export sys.argv = ["pellet export"] + rest export.main() return - sys.exit(f"Unknown command {cmd!r}. Try: watch, history, trend, chart, export, presets, --help") + sys.exit(f"Unknown command {cmd!r}. Try: watch, history, trend, chart, report, export, presets, --help") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 870efe3..8c9d499 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ py-modules = [ "presets", "forecast", "plot", + "report", "history", "trend", "export", diff --git a/report.py b/report.py new file mode 100644 index 0000000..7c3576a --- /dev/null +++ b/report.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Build a shareable, self-contained Cook Report: one HTML file with an +embedded SVG chart and per-probe stats (start/peak/final temp, stall time, +stage-crossing timestamps) -- no server, no external assets, safe to email +or post anywhere. + +Usage: + report.py # latest cook -> cook_report.html + report.py --cook 2 --out r.html +""" +import html as _html +import sys + +import plan +import probe_names +from forecast import _FLAT, STALL_HI, STALL_LO +from history import _num, load_rows, sessions, summarize +from plot import render_svg + +_STAGE_ROW_STYLE = "color:#8a7d70;font-style:italic;font-size:12px" + + +def _stall_minutes(sess, probe): + """Minutes spent genuinely stalled: consecutive readings both inside the + classic 150-175F band AND with a near-flat rate between them (same + threshold forecast.py uses to call a live reading "stalled"). Checking + band membership alone would count a normal climb passing through 150-175F + as "stalled" time, which almost every cook does on the way to any higher + target.""" + ts = [r["_ts"] for r in sess] + temps = [_num(r.get(f"probe{probe}_temp")) for r in sess] + total = 0.0 + for (t0, v0), (t1, v1) in zip(zip(ts, temps), zip(ts[1:], temps[1:])): + if v0 is None or v1 is None: + continue + if not (STALL_LO <= v0 <= STALL_HI and STALL_LO <= v1 <= STALL_HI): + continue + elapsed = (t1 - t0).total_seconds() / 60 + if elapsed <= 0: + continue + if (v1 - v0) / elapsed < _FLAT: + total += elapsed + 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. + + Deliberately omits the raw grill `thingName` (a device identifier) that + history.summarize() carries -- this report is meant to leave the machine, + unlike history.py's local-only CLI output. + """ + stages = stages or {} + names = names or {} + s = summarize(sess) + svg = render_svg(sess) # whole-cook overview: grill + every probe + + rows = [] + for i, p in sorted(s["probes"].items()): + label = _html.escape(probe_names.label(i, names)) + stall = _stall_minutes(sess, i) + if p["target"]: + tgt = f"{int(p['target'])}°" + (" reached" if p["reached"] else "") + else: + tgt = "—" + rows.append( + f"
+ {s['start']:%A, %B} {s['start'].day} · started {s['start']:%-I:%M %p} · + {hrs:.1f}h cook · grill peak {int(s['max_grill'] or 0)}° +
+| probe | start | peak | final | target | stall time | +
|---|
+ Generated by Pellet Pilot + — unofficial, read-only Traeger monitoring. +
+