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"{label}" + 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): + 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 + esc_title = _html.escape(title) + return f""" + +{esc_title} + +
+

{esc_title}

+

+ {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)}° +

+
{svg}
+ + + + + {"".join(rows)} +
probestartpeakfinaltargetstall time
+

+ Generated by Pellet Pilot + — unofficial, read-only Traeger monitoring. +

+
+""" + + +def main(): + argv = sys.argv[1:] + + def opt(name, default=None): + return argv[argv.index(name) + 1] if name in argv else default + + cook_id = int(opt("--cook")) if "--cook" in argv else None + groups = sessions(load_rows()) + if not groups: + sys.exit("No cook sessions found yet.") + sess = groups[cook_id - 1] if cook_id else groups[-1] + + out = opt("--out", "cook_report.html") + content = build_report(sess, plan.load_plan(), probe_names.load_names()) + with open(out, "w") as f: + f.write(content) + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/tests/smoke.py b/tests/smoke.py index 92f290a..1955a58 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -32,6 +32,7 @@ import poll # noqa: E402,F401 import presets # noqa: E402 import probe_names # noqa: E402 +import report # noqa: E402 import traeger_client as tc # noqa: E402 import trend # noqa: E402,F401 @@ -872,21 +873,61 @@ def test_pellet_cli_dispatches_to_each_subcommand(): orig_argv = sys.argv calls = [] originals = {} - for mod, name in ((history, "history"), (trend, "trend"), (plot, "plot"), (export, "export")): + mods = ((history, "history"), (trend, "trend"), (plot, "plot"), + (report, "report"), (export, "export")) + for mod, name in mods: originals[name] = mod.main mod.main = (lambda n: lambda: calls.append(n))(name) try: - for cmd, name in (("history", "history"), ("trend", "trend"), - ("chart", "plot"), ("export", "export")): + for cmd, name in (("history", "history"), ("trend", "trend"), ("chart", "plot"), + ("report", "report"), ("export", "export")): sys.argv = ["pellet", cmd] pellet.main() - assert calls == ["history", "trend", "plot", "export"], calls + assert calls == ["history", "trend", "plot", "report", "export"], calls finally: - history.main, trend.main, plot.main, export.main = ( - originals["history"], originals["trend"], originals["plot"], originals["export"]) + for mod, name in mods: + mod.main = originals[name] sys.argv = orig_argv +def test_report_includes_chart_and_probe_stats(): + sess = _rows(dt.datetime(2026, 7, 4, 9, 0), 12, p1=140, p1_set=203, p2=100) + html = report.build_report(sess, stages={}, names={1: "pork butt"}) + assert "" in html, html[:200] + assert "the pork butt" in html, html + assert "140°" in html # probe 1 start temp + assert "probe 2" in html # unnamed probe falls back to generic label + + +def test_report_omits_device_thing_id(): + # The report is meant to leave the machine -- the raw grill thingName + # (a device identifier) must never appear in it, unlike history.py's + # local-only CLI output. + secret_thing = "SECRETDEVICEID123" + sess = [dict(r, thing=secret_thing) for r in _rows(dt.datetime(2026, 7, 4, 9, 0), 5)] + html = report.build_report(sess) + assert secret_thing not in html, html + + +def test_report_shows_stage_crossing_times(): + sess = _rows(dt.datetime(2026, 7, 4, 9, 0), 12, p1=160, p1_set=203) # climbs 160 -> 171 + html = report.build_report(sess, stages={1: [(165.0, "wrap"), (203.0, "done")]}) + assert "wrap (165°)" in html, html + assert "not reached" in html # 203 ("done") never reached in this short synthetic climb + + +def test_stall_minutes(): + # Flat readings inside the classic 150-175F stall band count; a normal + # climb through the same range does not. + stalled = _rows(dt.datetime(2026, 7, 4, 9, 0), 6, p1=160, p1_set=None) + for r in stalled: + r["probe1_temp"] = "160" # flat, well inside the stall band + assert report._stall_minutes(stalled, 1) > 0, stalled + + climbing = _rows(dt.datetime(2026, 7, 4, 9, 0), 6, p1=160, p1_set=None) # 160..165, still climbing + assert report._stall_minutes(climbing, 1) == 0, "a genuine climb shouldn't count as stalled" + + def test_backoff_seconds(): # RT-4: exponential backoff, capped, so a persistent re-auth failure doesn't # hammer Cognito every `interval` seconds forever.