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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion pellet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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__":
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ py-modules = [
"presets",
"forecast",
"plot",
"report",
"history",
"trend",
"export",
Expand Down
136 changes: 136 additions & 0 deletions report.py
Original file line number Diff line number Diff line change
@@ -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"<tr><td>{label}</td>"
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):
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
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>
<p style="color:#8a7d70;margin-top:0">
{s['start']:%A, %B} {s['start'].day} &middot; started {s['start']:%-I:%M %p} &middot;
{hrs:.1f}h cook &middot; grill peak {int(s['max_grill'] or 0)}&deg;
</p>
<div>{svg}</div>
<table style="width:100%;border-collapse:collapse;margin-top:16px;font-size:14px">
<tr style="color:#8a7d70;text-align:left">
<th>probe</th><th>start</th><th>peak</th><th>final</th><th>target</th><th>stall time</th>
</tr>
{"".join(rows)}
</table>
<p style="color:#5a4f45;font-size:12px;margin-top:24px">
Generated by <a href="https://github.com/ctopherwilliams/pellet-pilot" style="color:#8a7d70">Pellet Pilot</a>
&mdash; unofficial, read-only Traeger monitoring.
</p>
</div>
"""


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()
53 changes: 47 additions & 6 deletions tests/smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 "<svg " in html and "</svg>" 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.
Expand Down