diff --git a/README.md b/README.md index bd1b703..96788b2 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | @@ -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: diff --git a/poll.py b/poll.py index afc2933..ecf8e9a 100644 --- a/poll.py +++ b/poll.py @@ -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) @@ -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): @@ -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: @@ -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 {} @@ -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): @@ -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.") diff --git a/tests/smoke.py b/tests/smoke.py index f599180..76c0528 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -9,6 +9,7 @@ mocked HTTP responses). """ import contextlib +import csv import datetime as dt import io import json @@ -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")) @@ -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 @@ -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. diff --git a/traeger_client.py b/traeger_client.py index 9528f58..ad081ad 100644 --- a/traeger_client.py +++ b/traeger_client.py @@ -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 @@ -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", []):