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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The mobile app shows you a number. Pellet Pilot gives you the **curve** — and
- **🧠 Stall detection** — flags the classic 150–170° brisket/pork-shoulder plateau so you don't panic (or wrap early).
- **🎙 A coach, not just a number** — rule-based advice on whether to hold for bark or wrap, and natural spoken updates that say what actually changed since last time.
- **🔔 Temperature alarms** — desktop notification + spoken alert when the probe crosses your thresholds, by name ("the pork butt," not "probe 1").
- **⚠️ Pellet + anomaly alerts** — always-on warnings for a low hopper and for the flame-out/pellet-jam/firmware-error signatures the app's number alone won't tell you about.
- **🗒 Your own cook history** — every reading logged to CSV, because the cloud keeps none. Query, re-plot, or export a shareable report of any past cook.
- **🖥 Terminal-native** — no app, no dashboard server. Pipe it, grep it, graph it.

Expand All @@ -47,6 +48,7 @@ The mobile app shows you a number. Pellet Pilot gives you the **curve** — and
| Rate-of-rise + time-to-target ETA | ❌ | ✅ |
| Stall detection | ❌ | ✅ |
| Wrap/hold coaching | ❌ | ✅ rule-based |
| Low-pellet / flame-out alerts | ❌ | ✅ always on |
| Natural spoken updates | ❌ | ✅ opt-in, name-your-probes |
| Cook history | ❌ *(not stored server-side)* | ✅ every reading, forever, locally |
| Shareable cook report | ❌ | ✅ self-contained HTML |
Expand Down Expand Up @@ -439,7 +441,21 @@ The grill cloud does **not** expose past temperatures — the in-app graph is dr
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`.


**Pellet + anomaly alerts** are always on during `--watch` (no flag needed — these
are safety-relevant, not an opt-in convenience):

- **Low pellets** — alerts once when the hopper sensor drops to 20%, and re-arms
after a refill so a later re-drop alerts again.
- **Flame-out / pellet jam** — alerts if the grill temp has been sustained 40°+
below its set point for 10+ minutes during an active cook (a normal lid-open dip
recovers well under that).
- **Firmware error codes** — alerts on a *new* overheat, low-temp, or bad-thermocouple
event reported by the controller itself (not on whatever count already existed
before this run started).

An older `cook_log.csv` is upgraded automatically and losslessly the first time you
run the updated `poll.py` — existing rows are preserved, the new columns just come
back blank for readings taken before they existed.

**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
130 changes: 129 additions & 1 deletion poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ def _backoff_seconds(interval, consecutive_failures):
LOG = os.path.join(os.path.dirname(__file__), "cook_log.csv")

MAX_PROBES = 4 # widen the log to support multiple meat probes
_BASE_FIELDS = ["ts", "thing", "grill", "set", "ambient", "system_status"]
_BASE_FIELDS = ["ts", "thing", "grill", "set", "ambient", "system_status",
"pellet_level", "error_overheat", "error_lowtemp", "error_bad_thermocouple"]
FIELDS = _BASE_FIELDS + [
f"probe{i}_{suffix}"
for i in range(1, MAX_PROBES + 1)
Expand Down Expand Up @@ -435,6 +436,10 @@ def row_from(reading):
"set": reading["set"],
"ambient": reading["ambient"],
"system_status": reading["system_status"],
"pellet_level": reading.get("pellet_level"),
"error_overheat": reading.get("error_overheat"),
"error_lowtemp": reading.get("error_lowtemp"),
"error_bad_thermocouple": reading.get("error_bad_thermocouple"),
}
probes = reading["probes"]
for i in range(1, MAX_PROBES + 1):
Expand All @@ -446,6 +451,31 @@ def row_from(reading):
return row


def migrate_log_schema(path=LOG):
"""One-time migration for a cook_log.csv written before pellet_level/
error_* columns existed: rewrite it with the current FIELDS header so
old and new rows stay aligned (csv.DictReader matches columns by name
against the header, so appending new-schema rows under an old header
would silently misalign every field after it). No-op if the file
doesn't exist yet or its header already matches. New columns on
preserved old rows are left blank -- that data was never recorded.
"""
if not os.path.exists(path):
return
with open(path, newline="") as f:
reader = csv.DictReader(f)
if reader.fieldnames == FIELDS:
return
rows = list(reader)
tmp = path + ".tmp"
with open(tmp, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
for r in rows:
w.writerow({k: r.get(k, "") for k in FIELDS})
os.replace(tmp, path)


def append(row):
new = not os.path.exists(LOG)
with open(LOG, "a", newline="") as f:
Expand Down Expand Up @@ -555,6 +585,98 @@ def check_alarms(row, alarms, names=None):
print(f" 🔔 ALARM: probe {probe} crossed {int(thr)}°F")


# ---- pellet + grill anomaly alerts -------------------------------------
_LOW_PELLET_PCT = 20.0 # percent -- fire once when the hopper drops to/below this
_PELLET_REARM_PCT = 30.0 # percent -- must climb back above this (a refill) to re-arm
_pellet_alarm_fired = False


def check_pellet_alarm(row):
"""Fire once when the pellet hopper sensor reads at/below _LOW_PELLET_PCT.
Re-arms after a refill brings the level back above _PELLET_REARM_PCT (a
hysteresis gap), so a later re-drop alerts again instead of staying
permanently silenced for the rest of the log. No-op on grills without a
pellet sensor (the field is simply absent/blank).
"""
global _pellet_alarm_fired
level = row.get("pellet_level")
if level in (None, "", "None"):
return
level = float(level)
if not _pellet_alarm_fired and level <= _LOW_PELLET_PCT:
_pellet_alarm_fired = True
msg = f"Pellet hopper low: {level:.0f}% — top it off soon"
notify("Traeger pellets", msg)
notify_remote("Traeger pellets", msg)
print(f" 🔔 PELLETS: {level:.0f}% remaining")
elif _pellet_alarm_fired and level >= _PELLET_REARM_PCT:
_pellet_alarm_fired = False


_GRILL_DEVIATION_F = 40.0 # degrees below set point that counts as "off track"
_GRILL_SUSTAINED_MIN = 10.0 # minutes the deviation must persist before alerting
_ACTIVE_COOK_STATES = {"Manual cook", "Custom cook", "Running"}
_grill_low_since = None
_grill_anomaly_fired = False


def check_temp_anomaly(row, state):
"""Fire once if the grill temp has been sustained well below its set
point during an active cook -- the signature of a flame-out, pellet
jam, or auger failure, not a normal momentary lid-open dip (which
recovers within a minute or two, well under the sustained window).
Re-arms automatically once the grill recovers or the cook ends.
"""
global _grill_low_since, _grill_anomaly_fired
grill, set_t = row.get("grill"), row.get("set")
if state not in _ACTIVE_COOK_STATES or grill in (None, "", "None") or set_t in (None, "", "None", 0, "0"):
_grill_low_since, _grill_anomaly_fired = None, False
return
grill, set_t = float(grill), float(set_t)
if set_t - grill < _GRILL_DEVIATION_F:
_grill_low_since, _grill_anomaly_fired = None, False
return
now = dt.datetime.fromisoformat(row["ts"])
if _grill_low_since is None:
_grill_low_since = now
elapsed = (now - _grill_low_since).total_seconds() / 60
if elapsed >= _GRILL_SUSTAINED_MIN and not _grill_anomaly_fired:
_grill_anomaly_fired = True
msg = (f"Grill at {int(grill)}° vs a {int(set_t)}° set point for "
f"{elapsed:.0f}+ min — check for a flame-out or pellet jam")
notify("Traeger anomaly", msg)
notify_remote("Traeger anomaly", msg)
print(f" 🔔 ANOMALY: grill {int(grill)}° vs set {int(set_t)}° for {elapsed:.0f} min")


# Firmware-reported lifetime error counters -- only the temperature-relevant
# ones. These are CUMULATIVE counts, not live flags, so only an INCREASE
# since the last poll this run is a new event; the baseline on the very
# first reading is intentionally not alerted on (it may reflect some
# unrelated event from long before this run started).
_TEMP_ERROR_FIELDS = {
"error_overheat": "overheat",
"error_lowtemp": "low temp",
"error_bad_thermocouple": "bad thermocouple",
}
_error_counter_baseline = {}


def check_error_counters(row):
for field, label in _TEMP_ERROR_FIELDS.items():
val = row.get(field)
if val in (None, "", "None"):
continue
val = int(float(val))
prev = _error_counter_baseline.get(field)
_error_counter_baseline[field] = val
if prev is not None and val > prev:
msg = f"Grill reported a new {label} error (count now {val})"
notify("Traeger anomaly", msg)
notify_remote("Traeger anomaly", msg)
print(f" 🔔 ANOMALY: {label} error, count now {val}")


def one_shot(t, alarms=None, stages=None, speak_every_tick=False, chart_path=None, chart_probe=1,
names=None, coach=False):
alarms = alarms or {}
Expand Down Expand Up @@ -603,6 +725,11 @@ def one_shot(t, alarms=None, stages=None, speak_every_tick=False, chart_path=Non
if row.get(f"probe{i}_connected") and row.get(f"probe{i}_set")}
if active:
check_alarms(row, active, names)
# Always on (no flag needed) -- same spirit as the auto-armed probe
# alarms above: these are safety-relevant, not an opt-in convenience.
check_pellet_alarm(row)
check_temp_anomaly(row, state)
check_error_counters(row)


def resolve_password(user):
Expand Down Expand Up @@ -655,6 +782,7 @@ def reauth(t, user):

def main():
load_env()
migrate_log_schema() # one-time upgrade of an older cook_log.csv header, if needed
user = os.environ.get("TRAEGER_USERNAME")
if not user:
sys.exit("Missing TRAEGER_USERNAME. Set it in .env.")
Expand Down
137 changes: 136 additions & 1 deletion tests/smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
mocked HTTP responses).
"""
import contextlib
import csv
import datetime as dt
import io
import json
Expand Down Expand Up @@ -69,6 +70,20 @@ def test_parse_status():
assert r["probes"][0]["get_temp"] == 150 and r["probes"][0]["set_temp"] == 203, r


def test_parse_status_includes_pellet_and_temp_errors():
doc = _doc()
doc["status"]["pellet_level"] = 35
doc["usage"] = {"error_stats": {"overheat": 2, "lowtemp": 0, "bad_thermocouple": 1}}
r = tc.parse_status("g", doc)
assert r["pellet_level"] == 35, r
assert r["error_overheat"] == 2 and r["error_bad_thermocouple"] == 1, r

# a grill with no "usage" block at all (or an older firmware without
# error_stats) must not crash -- just come back as None
r2 = tc.parse_status("g", _doc())
assert r2["pellet_level"] is None and r2["error_overheat"] is None, r2


def test_mqtt_client_builds():
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, transport="websockets")
c.tls_set_context(tc._mqtt_tls_context("example.com"))
Expand All @@ -82,7 +97,7 @@ def test_status_decode():

def test_row_from_multiprobe():
row = poll.row_from(tc.parse_status("g", _doc(temps=((150, 203), (120, 165)))))
assert len(poll.FIELDS) == 6 + 4 * 4, poll.FIELDS
assert len(poll.FIELDS) == len(poll._BASE_FIELDS) + 4 * 4, poll.FIELDS
assert row["probe1_temp"] == 150 and row["probe2_temp"] == 120, row
assert row["probe3_temp"] is None, row

Expand Down Expand Up @@ -1008,6 +1023,126 @@ def test_print_coach_smoke():
assert "pork butt" in out, out


def test_check_pellet_alarm_fires_once_and_rearms():
poll._pellet_alarm_fired = False
fired = []
orig, poll.notify = poll.notify, lambda t, m: fired.append(m)
orig_r, poll.notify_remote = poll.notify_remote, lambda t, m: None
try:
poll.check_pellet_alarm({"pellet_level": 25}) # above threshold -- no alert
assert not fired, fired
poll.check_pellet_alarm({"pellet_level": 20}) # at threshold -- fires
poll.check_pellet_alarm({"pellet_level": 15}) # still low -- no repeat
assert len(fired) == 1, fired
poll.check_pellet_alarm({"pellet_level": 25}) # refilled, but below re-arm point yet
poll.check_pellet_alarm({"pellet_level": 10}) # drop again -- must NOT re-fire yet
assert len(fired) == 1, fired
poll.check_pellet_alarm({"pellet_level": 30}) # refilled past the re-arm point
poll.check_pellet_alarm({"pellet_level": 15}) # drops again -- fires again
assert len(fired) == 2, fired
# a grill with no pellet sensor (field absent) must never crash or alert
poll.check_pellet_alarm({})
assert len(fired) == 2, fired
finally:
poll.notify, poll.notify_remote = orig, orig_r


def test_check_temp_anomaly_requires_sustained_deviation():
poll._grill_low_since = None
poll._grill_anomaly_fired = False
fired = []
orig, poll.notify = poll.notify, lambda t, m: fired.append(m)
orig_r, poll.notify_remote = poll.notify_remote, lambda t, m: None
t0 = dt.datetime(2026, 7, 4, 12, 0, 0)
try:
# deviation just starting -- must not fire immediately
poll.check_temp_anomaly({"ts": t0.isoformat(), "grill": 200, "set": 275}, "Manual cook")
assert not fired, fired
# 5 min later, still deviated -- still under the sustained window
poll.check_temp_anomaly(
{"ts": (t0 + dt.timedelta(minutes=5)).isoformat(), "grill": 200, "set": 275}, "Manual cook")
assert not fired, fired
# 12 min in -- past the sustained window, fires
poll.check_temp_anomaly(
{"ts": (t0 + dt.timedelta(minutes=12)).isoformat(), "grill": 200, "set": 275}, "Manual cook")
assert len(fired) == 1, fired
# recovers -- resets, a later fresh long deviation can fire again
poll.check_temp_anomaly(
{"ts": (t0 + dt.timedelta(minutes=14)).isoformat(), "grill": 270, "set": 275}, "Manual cook")
assert poll._grill_low_since is None, "should reset once the grill recovers"
# a normal momentary dip that recovers quickly must never fire at all
poll._grill_low_since, poll._grill_anomaly_fired = None, False
poll.check_temp_anomaly(
{"ts": (t0 + dt.timedelta(minutes=20)).isoformat(), "grill": 200, "set": 275}, "Manual cook")
poll.check_temp_anomaly(
{"ts": (t0 + dt.timedelta(minutes=21)).isoformat(), "grill": 270, "set": 275}, "Manual cook")
assert len(fired) == 1, fired # unchanged -- the brief dip never sustained past 10 min
# not actively cooking (e.g. Idle) -- never fires even if "deviated"
poll._grill_low_since, poll._grill_anomaly_fired = None, False
poll.check_temp_anomaly({"ts": t0.isoformat(), "grill": 70, "set": 275}, "Idle")
poll.check_temp_anomaly(
{"ts": (t0 + dt.timedelta(minutes=20)).isoformat(), "grill": 70, "set": 275}, "Idle")
assert len(fired) == 1, fired
finally:
poll.notify, poll.notify_remote = orig, orig_r


def test_check_error_counters_fires_on_increment_only():
poll._error_counter_baseline.clear()
fired = []
orig, poll.notify = poll.notify, lambda t, m: fired.append(m)
orig_r, poll.notify_remote = poll.notify_remote, lambda t, m: None
try:
# first-ever reading establishes the baseline -- must NOT alert on it,
# even if the lifetime count is already nonzero from long before this run
poll.check_error_counters({"error_overheat": 3, "error_lowtemp": 0, "error_bad_thermocouple": 0})
assert not fired, fired
# unchanged -- no alert
poll.check_error_counters({"error_overheat": 3, "error_lowtemp": 0, "error_bad_thermocouple": 0})
assert not fired, fired
# a NEW overheat event during this run -- alerts
poll.check_error_counters({"error_overheat": 4, "error_lowtemp": 0, "error_bad_thermocouple": 0})
assert len(fired) == 1 and "overheat" in fired[0], fired
# a NEW bad-thermocouple event -- alerts too
poll.check_error_counters({"error_overheat": 4, "error_lowtemp": 0, "error_bad_thermocouple": 1})
assert len(fired) == 2 and "thermocouple" in fired[1], fired
finally:
poll.notify, poll.notify_remote = orig, orig_r


def test_migrate_log_schema_preserves_old_rows_and_adds_new_columns():
path = "/tmp/.pellet_pilot_test_migrate.csv"
old_fields = ["ts", "thing", "grill", "set", "ambient", "system_status"] + [
f"probe{i}_{suffix}" for i in range(1, poll.MAX_PROBES + 1)
for suffix in ("temp", "set", "connected", "alarm")]
try:
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=old_fields)
w.writeheader()
w.writerow({k: "" for k in old_fields} | {
"ts": "2026-07-04T12:00:00", "thing": "g", "grill": "250", "set": "250",
"ambient": "70", "system_status": "6", "probe1_temp": "160"})

poll.migrate_log_schema(path)

with open(path, newline="") as f:
reader = csv.DictReader(f)
assert reader.fieldnames == poll.FIELDS, reader.fieldnames
rows = list(reader)
assert len(rows) == 1, rows
assert rows[0]["grill"] == "250" and rows[0]["probe1_temp"] == "160", rows[0]
assert rows[0]["pellet_level"] == "", rows[0] # new column, blank for old data

# already-current schema -- no-op, and no data loss on a second call
poll.migrate_log_schema(path)
with open(path, newline="") as f:
rows2 = list(csv.DictReader(f))
assert rows2 == rows, (rows2, rows)
finally:
if os.path.exists(path):
os.remove(path)


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
9 changes: 9 additions & 0 deletions traeger_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ def on_message(c, u, msg):
def parse_status(thing_name, doc):
"""Flatten a thing document into a simple reading dict."""
st = doc.get("status", {})
# "usage" is a sibling of "status" in the raw doc, not nested inside it.
# error_stats are cumulative lifetime counters (not a live flag), so a
# caller must diff against the previous reading to detect a NEW error --
# see poll.py's check_error_counters().
error_stats = doc.get("usage", {}).get("error_stats", {})
reading = {
"thing": thing_name,
"grill": st.get("grill"), # current grill temp
Expand All @@ -247,6 +252,10 @@ def parse_status(thing_name, doc):
"system_status": st.get("system_status"),
"connected": st.get("connected"),
"units": "C" if st.get("units") == 0 else "F",
"pellet_level": st.get("pellet_level"), # 0-100, hopper sensor (if the grill has one)
"error_overheat": error_stats.get("overheat"),
"error_lowtemp": error_stats.get("lowtemp"),
"error_bad_thermocouple": error_stats.get("bad_thermocouple"),
"probes": [],
}
for acc in st.get("acc", []):
Expand Down