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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ cp .env.example .env # add your grill account email
⏱ P1 ~47 min to 203° (≈ 4:45 PM) · +0.75°/min
```

### One command, with presets

`pellet` wraps the scripts above behind one entry point, and `--preset` fills in
`--stage`/`--probe-name` for a cut of meat so you don't retype them every cook:

```bash
./venv/bin/pip install -e . # adds the `pellet` command (optional; scripts above still work standalone)
pellet presets # brisket, pork-butt, ribs, chicken
pellet watch --preset brisket --speak --chart
pellet history show
pellet trend
```

`pellet watch` is just `poll.py --watch 30` under the hood — every flag documented
above (`--alarm`, `--stage`, `--probe-name`, `--speak`, `--chart`, ...) still works;
`--preset` only pre-fills `--stage`/`--probe-name`, and an explicit flag you also
pass takes precedence. Add your own preset by dropping a YAML file in `presets/`
(see the shipped ones for the two-key `stage_specs`/`name_specs` shape).

---

## 💻 How to run it — Mac, Windows, or Claude Code
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Pellet Pilot handles credentials for your Traeger grill account. This document d
| 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 |
| Local config files (`.probe_names.json`, `presets/*.yaml`) | Oversized/malformed file handed to `json.load`/`yaml.safe_load` | Size-capped before parsing; YAML loaded with `safe_load` only (never `yaml.load`); `--preset` names are checked against path separators/`.`/`..` before being joined into a file path |

**Blast radius:** This tool is **read-only** against the grill — it polls status and sends command `90` (force status publish). It does **not** expose start/stop/set-temp. A stolen token cannot remotely ignite or change targets through this client.

Expand Down
97 changes: 97 additions & 0 deletions pellet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Unified Pellet Pilot CLI -- one command, thin dispatch to the existing
tested scripts (poll.py/history.py/trend.py/plot.py/export.py). Adds nothing
of its own except --preset expansion; every flag below is documented in the
script it dispatches to.

Usage:
pellet watch [--preset NAME] [...poll.py flags...] # log + optionally --speak/--chart
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 export [...export.py flags...] # Grafana-ingestible export
pellet presets # list available --preset names

--preset expands to --stage/--probe-name specs (see presets/*.yaml) BEFORE
any --stage/--probe-name flags you also pass, so your own flags still apply
(plan.py/probe_names.py keep the last value per probe on conflict).
"""
import sys

import presets as presets_mod


def _expand_preset(argv):
"""Replace a `--preset NAME` pair with the --stage/--probe-name specs it
stands for, prepended so any explicit flags you also typed take effect
the same way as before (they're just appended after)."""
if "--preset" not in argv:
return argv
i = argv.index("--preset")
if i + 1 >= len(argv):
sys.exit("--preset needs a name -- try `pellet presets` to list them")
name = argv[i + 1]
try:
data = presets_mod.load_preset(name)
except ValueError as e:
sys.exit(str(e))
extra = []
for spec in data["stage_specs"]:
extra += ["--stage", spec]
for spec in data["name_specs"]:
extra += ["--probe-name", spec]
rest = argv[:i] + argv[i + 2:]
print(f"Preset: {data['name']}")
return extra + rest


def main():
argv = sys.argv[1:]
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return
cmd, rest = argv[0], argv[1:]

if cmd == "presets":
names = presets_mod.list_presets()
print("\n".join(names) if names else "No presets found.")
return

if cmd == "watch":
import poll
rest = _expand_preset(rest)
if "--watch" not in rest:
rest = rest + ["--watch", "30"]
sys.argv = ["pellet watch"] + rest
poll.main()
return

if cmd == "history":
import history
sys.argv = ["pellet history"] + rest
history.main()
return

if cmd == "trend":
import trend
sys.argv = ["pellet trend"] + rest
trend.main()
return

if cmd == "chart":
import plot
sys.argv = ["pellet chart"] + rest
plot.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")


if __name__ == "__main__":
main()
56 changes: 56 additions & 0 deletions presets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Cook presets -- named YAML files bundling the --stage/--probe-name specs
for a cut of meat, so `pellet watch --preset brisket` replaces typing out
`--stage 165:wrap --stage 203:done --probe-name brisket` every cook.

A preset is intentionally just a bag of the SAME spec strings plan.py /
probe_names.py already parse (stage_specs, name_specs) -- no separate schema
or parsing logic to keep in sync with the real thing.

Preset file (presets/brisket.yaml):
name: Brisket
stage_specs:
- "165:wrap"
- "203:done"
name_specs:
- "brisket"
"""
import os

import yaml

HERE = os.path.dirname(os.path.abspath(__file__))
PRESETS_DIR = os.path.join(HERE, "presets")

# A preset is a small, trusted, repo-shipped config file -- this cap just
# guards against a corrupt/malicious file being handed to yaml.safe_load.
_MAX_PRESET_FILE_BYTES = 64 * 1024


def list_presets(presets_dir=PRESETS_DIR):
if not os.path.isdir(presets_dir):
return []
return sorted(f[:-5] for f in os.listdir(presets_dir) if f.endswith(".yaml"))


def load_preset(name, presets_dir=PRESETS_DIR):
"""Load a preset by name (no extension, no path separators). Returns a
dict with 'stage_specs' and 'name_specs' lists (either may be empty).
"""
if not name or os.sep in name or (os.altsep and os.altsep in name) or name in (".", ".."):
raise ValueError(f"Invalid preset name: {name!r}")
path = os.path.join(presets_dir, f"{name}.yaml")
if not os.path.exists(path):
available = ", ".join(list_presets(presets_dir)) or "none"
raise ValueError(f"Unknown preset {name!r}. Available: {available}")
if os.path.getsize(path) > _MAX_PRESET_FILE_BYTES:
raise ValueError(
f"{path} is larger than expected ({_MAX_PRESET_FILE_BYTES} bytes) -- refusing to parse")
with open(path) as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError(f"{path} must be a YAML mapping, got {type(data).__name__}")
return {
"name": data.get("name", name),
"stage_specs": [str(s) for s in (data.get("stage_specs") or [])],
"name_specs": [str(s) for s in (data.get("name_specs") or [])],
}
6 changes: 6 additions & 0 deletions presets/brisket.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: Brisket
stage_specs:
- "165:wrap"
- "203:done"
name_specs:
- "brisket"
5 changes: 5 additions & 0 deletions presets/chicken.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: Chicken
stage_specs:
- "165:done"
name_specs:
- "chicken"
6 changes: 6 additions & 0 deletions presets/pork-butt.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: Pork Butt
stage_specs:
- "165:wrap"
- "203:done"
name_specs:
- "pork butt"
5 changes: 5 additions & 0 deletions presets/ribs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: Ribs
stage_specs:
- "203:done"
name_specs:
- "ribs"
40 changes: 40 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "pellet-pilot"
version = "0.1.0"
description = "Unofficial, read-only CLI for monitoring a Traeger WiFire grill"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
dependencies = [
"requests==2.34.2",
"urllib3==2.7.0",
"paho-mqtt==2.1.0",
"numpy==2.5.1",
"pyyaml==6.0.3",
]

[project.scripts]
pellet = "pellet:main"

[tool.setuptools]
py-modules = [
"pellet",
"poll",
"plan",
"probe_names",
"presets",
"forecast",
"plot",
"history",
"trend",
"export",
"alarms",
"traeger_client",
]

[tool.setuptools.package-data]
"*" = ["presets/*.yaml"]
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
requests==2.34.2
urllib3==2.7.0 # pinned explicitly: alarms.py imports its connection internals for SSRF DNS-pinning
paho-mqtt==2.1.0
numpy==2.5.1
numpy==2.5.1
pyyaml==6.0.3 # cook presets (presets.py) -- safe_load only, never yaml.load
85 changes: 85 additions & 0 deletions tests/smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@
import export # noqa: E402
import forecast as fc_mod # noqa: E402
import history # noqa: E402
import pellet # noqa: E402
import plan # noqa: E402
import plot # noqa: E402
import poll # noqa: E402,F401
import presets # noqa: E402
import probe_names # noqa: E402
import traeger_client as tc # noqa: E402
import trend # noqa: E402,F401
Expand Down Expand Up @@ -802,6 +804,89 @@ def test_check_alarms_uses_probe_name():
poll.notify, poll.notify_remote = orig_notify, orig_remote


def test_presets_load_known():
for name in ("brisket", "pork-butt", "ribs", "chicken"):
p = presets.load_preset(name)
assert p["stage_specs"], (name, p)
assert p["name_specs"], (name, p)


def test_presets_list_includes_shipped_files():
names = presets.list_presets()
for expected in ("brisket", "pork-butt", "ribs", "chicken"):
assert expected in names, names


def test_presets_rejects_path_traversal_and_unknown():
for bad in ("../etc/passwd", "..", ".", "a/b"):
try:
presets.load_preset(bad)
assert False, f"expected ValueError for {bad!r}"
except ValueError:
pass
try:
presets.load_preset("no-such-preset")
assert False, "expected ValueError for an unknown preset"
except ValueError as e:
assert "Unknown preset" in str(e), e


def test_presets_size_cap():
path = "/tmp/whatever.yaml"
try:
with open(path, "w") as f:
f.write("x: " + "y" * (presets._MAX_PRESET_FILE_BYTES + 1))
try:
presets.load_preset("whatever", presets_dir="/tmp")
assert False, "expected ValueError for an oversized preset file"
except ValueError as e:
assert "refusing to parse" in str(e), e
finally:
if os.path.exists(path):
os.remove(path)


def test_pellet_cli_preset_expands_to_stage_and_name_flags():
# --preset must expand to the exact --stage/--probe-name spec strings
# plan.py/probe_names.py already parse -- no separate preset schema to
# keep in sync with the real thing.
captured = {}
orig_main = poll.main
poll.main = lambda: captured.setdefault("argv", list(sys.argv))
orig_argv = sys.argv
try:
sys.argv = ["pellet", "watch", "--preset", "brisket", "--speak"]
pellet.main()
argv = captured["argv"]
assert "--stage" in argv and "165:wrap" in argv, argv
assert "--stage" in argv and "203:done" in argv, argv
assert "--probe-name" in argv and "brisket" in argv, argv
assert "--speak" in argv, argv
assert "--watch" in argv, argv # `pellet watch` implies continuous watch by default
finally:
poll.main = orig_main
sys.argv = orig_argv


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")):
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")):
sys.argv = ["pellet", cmd]
pellet.main()
assert calls == ["history", "trend", "plot", "export"], calls
finally:
history.main, trend.main, plot.main, export.main = (
originals["history"], originals["trend"], originals["plot"], originals["export"])
sys.argv = orig_argv


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