From 11e42c17f293086b02127219c5010e3d9ad08829 Mon Sep 17 00:00:00 2001 From: ctopherwilliams <35182714+ctopherwilliams@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:08:33 -0500 Subject: [PATCH] feat: unified `pellet` CLI + YAML cook presets Adds pellet.py as a thin dispatcher to the existing scripts (poll/history/ trend/plot/export) plus --preset expansion: `pellet watch --preset brisket` fills in --stage/--probe-name from presets/brisket.yaml instead of typing them out every cook. Presets are just the same spec strings plan.py/ probe_names.py already parse (stage_specs/name_specs) -- no separate schema to keep in sync. Ships 4 presets (brisket, pork-butt, ribs, chicken) and a minimal pyproject.toml with a console_scripts entry point for `pip install -e .`. Preset loading is size-capped and rejects path-traversal names, same bar as the existing plan.py/probe_names.py config-file handling. --- README.md | 19 +++++++++ SECURITY.md | 1 + pellet.py | 97 ++++++++++++++++++++++++++++++++++++++++++ presets.py | 56 ++++++++++++++++++++++++ presets/brisket.yaml | 6 +++ presets/chicken.yaml | 5 +++ presets/pork-butt.yaml | 6 +++ presets/ribs.yaml | 5 +++ pyproject.toml | 40 +++++++++++++++++ requirements.txt | 3 +- tests/smoke.py | 85 ++++++++++++++++++++++++++++++++++++ 11 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 pellet.py create mode 100644 presets.py create mode 100644 presets/brisket.yaml create mode 100644 presets/chicken.yaml create mode 100644 presets/pork-butt.yaml create mode 100644 presets/ribs.yaml create mode 100644 pyproject.toml diff --git a/README.md b/README.md index 0153060..ac1ed67 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 77a3664..4b40d19 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/pellet.py b/pellet.py new file mode 100644 index 0000000..3cae818 --- /dev/null +++ b/pellet.py @@ -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() diff --git a/presets.py b/presets.py new file mode 100644 index 0000000..db8e3c4 --- /dev/null +++ b/presets.py @@ -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 [])], + } diff --git a/presets/brisket.yaml b/presets/brisket.yaml new file mode 100644 index 0000000..d4253e4 --- /dev/null +++ b/presets/brisket.yaml @@ -0,0 +1,6 @@ +name: Brisket +stage_specs: + - "165:wrap" + - "203:done" +name_specs: + - "brisket" diff --git a/presets/chicken.yaml b/presets/chicken.yaml new file mode 100644 index 0000000..c549dd6 --- /dev/null +++ b/presets/chicken.yaml @@ -0,0 +1,5 @@ +name: Chicken +stage_specs: + - "165:done" +name_specs: + - "chicken" diff --git a/presets/pork-butt.yaml b/presets/pork-butt.yaml new file mode 100644 index 0000000..69be076 --- /dev/null +++ b/presets/pork-butt.yaml @@ -0,0 +1,6 @@ +name: Pork Butt +stage_specs: + - "165:wrap" + - "203:done" +name_specs: + - "pork butt" diff --git a/presets/ribs.yaml b/presets/ribs.yaml new file mode 100644 index 0000000..6414061 --- /dev/null +++ b/presets/ribs.yaml @@ -0,0 +1,5 @@ +name: Ribs +stage_specs: + - "203:done" +name_specs: + - "ribs" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..870efe3 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt index df3d43d..c7db7c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +numpy==2.5.1 +pyyaml==6.0.3 # cook presets (presets.py) -- safe_load only, never yaml.load \ No newline at end of file diff --git a/tests/smoke.py b/tests/smoke.py index 4426d25..92f290a 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -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 @@ -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.