diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..295055c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ recipes:
+ name: Validate recipes
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Validate recipe JSON against schema
+ run: |
+ python -m pip install --quiet jsonschema
+ python3 tools/validate_recipe.py
+
+ firmware:
+ name: Build firmware (ESP32)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Cache PlatformIO
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.platformio
+ ~/.cache/pip
+ key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
+ - name: Install PlatformIO
+ run: python -m pip install --quiet platformio
+ - name: Build firmware (esp32dev)
+ run: pio run -e esp32dev
+ - name: Build firmware (esp32dev-16mb)
+ run: pio run -e esp32dev-16mb
+ - name: Build filesystem image
+ run: pio run -e esp32dev -t buildfs
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..925ea23
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+# PlatformIO
+.pio/
+.pioenvs/
+.piolibdeps/
+.vscode/.browse.c_cpp.db*
+.vscode/c_cpp_properties.json
+.vscode/launch.json
+.vscode/ipch/
+
+# Build artifacts
+*.bin
+*.elf
+*.map
+*.o
+
+# OS
+.DS_Store
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6757fb8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,31 @@
+MIT License
+
+Copyright (c) 2026 OpenEmber Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+--------------------------------------------------------------------------------
+
+OpenEmber is an independent, community project. It is NOT affiliated with,
+endorsed by, or connected to Traeger Pellet Grills LLC. "Traeger", "WiFIRE",
+and "Make Now" are trademarks of their respective owners and are used here only
+to describe interoperability and feature parity for reference purposes.
+
+This software controls mains-voltage heating appliances that produce fire.
+See docs/SAFETY.md. Use entirely at your own risk.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..94c10ee
--- /dev/null
+++ b/README.md
@@ -0,0 +1,121 @@
+# π₯ OpenEmber
+
+**Open-source, programmable firmware for wood-pellet grills β with the
+_Make Now_ style recipe engine the stock Traeger app keeps behind its cloud.**
+
+OpenEmber is an ESP32 firmware for a **replacement controller** that reuses your
+grill's existing auger, hot-rod igniter, induction fan, temperature sensor and
+meat probe, and adds:
+
+- π‘οΈ **PID temperature hold** with a windowed auger duty cycle (no more Β±30 Β°F swings)
+- π **Programmable multi-step recipes** β the Make Now analogue, but the recipes
+ live on _your_ device, not a vendor cloud, and you write your own
+- π¨ **Super Smoke**, **Keep Warm (165 Β°F)**, controlled **ignition & shutdown**
+- π¬ **Meat-probe target alarms**, step timers, and progress tracking
+- π **Home Assistant** integration over MQTT (auto-discovery) β dashboards,
+ notifications, Alexa/Google, and automations, **no phone-app build required**
+- πΈοΈ A self-hosted **web UI** and a clean **REST API**
+- π A safety-first controller: over-temp cutoff, flameout & ignition-failure
+ detection, sensor-loss lockout, fail-safe outputs
+
+> β οΈ **This drives a mains-powered appliance that lights a fire.**
+> Read [`docs/SAFETY.md`](docs/SAFETY.md) **before** you build or flash anything.
+> No warranty. Not affiliated with or endorsed by Traeger.
+
+Designed and documented against the **Traeger Pro 575** (D2 WiFIRE controller,
+part `KIT0402`), and portable to any pellet grill with an auger + igniter + fan.
+
+---
+
+## Why
+
+The Pro 575's hardware is excellent, but its best software features β WiFIRE app
+control and the ~1,600 guided **Make Now** recipes β are gated behind Traeger's
+cloud and mobile app. If the cloud changes, your grill's smarts change with it.
+OpenEmber puts a comparable (and programmable) experience entirely on hardware
+you own, and hands off the "app" to Home Assistant so there's nothing
+proprietary in the loop.
+
+See [`docs/FEATURE_PARITY.md`](docs/FEATURE_PARITY.md) for a feature-by-feature
+comparison with the stock Pro 575.
+
+## How it works
+
+```
+Recipe (JSON, "Make Now") β RecipeEngine β GrillController β auger/igniter/fan
+ β β
+ TempSensor (RTD + meat probe)
+ Web UI / REST / MQTT βββββββββββ
+```
+
+All fire safety lives in `GrillController`; recipes and the network can only ask
+it to do safe things. Full design in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
+
+## Hardware
+
+You need a small controller board: an ESP32, an RTD amplifier (MAX31865) for the
+grill probe, NTC dividers for the meat probes, and an isolated relay/SSR board
+for the three mains loads. It plugs into the grill's **existing wiring harness**,
+so every original sensor and actuator is reused.
+
+Full teardown of the Pro 575 (boards, ICs, connectors, pinouts, screen, buttons)
+and the reference build: [`docs/HARDWARE.md`](docs/HARDWARE.md).
+Default pin map: [`include/pins.h`](include/pins.h).
+
+## Build & flash
+
+```bash
+# 1. Install PlatformIO (pip install platformio) and clone the repo
+git clone https://github.com/superbeetle1973/openember.git && cd openember
+
+# 2. Build + flash the firmware
+pio run -t upload
+
+# 3. Upload the web UI to the device filesystem
+pio run -t uploadfs
+
+# 4. First boot: join Wi-Fi "OpenEmber-Setup", open http://192.168.4.1/,
+# enter your Wi-Fi + (optional) MQTT under the config, reboot.
+```
+
+Step-by-step flashing, wiring, first-light dry-run, and PID tuning:
+[`docs/FLASHING.md`](docs/FLASHING.md).
+
+## Writing recipes
+
+Recipes are small JSON files β a list of steps, each with a temperature and a
+condition that advances the cook (a timer, a probe target, a grill target, or a
+manual tap). Ends in Keep Warm or shutdown. Format and examples:
+[`docs/RECIPE_FORMAT.md`](docs/RECIPE_FORMAT.md) Β· seeds in [`recipes/`](recipes/).
+
+```bash
+python3 tools/validate_recipe.py recipes/*.json # validate before uploading
+```
+
+## API
+
+REST + MQTT, same payload on both. See [`docs/API.md`](docs/API.md).
+
+## Project layout
+
+| Path | What |
+| --- | --- |
+| `src/hardware/` | `Outputs` (relays), `TempSensor` (RTD + NTC probes) |
+| `src/control/` | `Pid`, `GrillController` (state machine + all safety) |
+| `src/recipe/` | `Recipe`, `RecipeEngine` (Make Now) |
+| `src/storage/` | `Storage` (LittleFS: config, recipes, resume) |
+| `src/net/` | Wi-Fi, web server, MQTT/HA, shared status JSON |
+| `data/www/` | web UI (uploaded to LittleFS) |
+| `recipes/` | example recipes + JSON schema |
+| `docs/` | hardware, flashing, safety, architecture, API, parity |
+| `tools/` | recipe validator |
+
+## Contributing & license
+
+MIT β see [`LICENSE`](LICENSE). PRs welcome, especially teardown data and pin
+maps for other grills. Please keep all fire-safety logic in `GrillController`
+and never let a new feature bypass the hard limits in `config.h`.
+
+**Independent project. Not affiliated with, endorsed by, or supported by
+Traeger. "Traeger", "WiFIRE", and "Make Now" are trademarks of their respective
+owners, used here only to describe interoperability.**
diff --git a/data/www/app.js b/data/www/app.js
new file mode 100644
index 0000000..5ba6a0b
--- /dev/null
+++ b/data/www/app.js
@@ -0,0 +1,125 @@
+// OpenEmber web UI β talks to the REST API and polls /api/status.
+const $ = (id) => document.getElementById(id);
+const api = async (path, body) => {
+ const opt = body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
+ : { method: path.startsWith('/api/grill/') && !path.includes('setpoint') ? 'POST' : 'GET' };
+ const r = await fetch(path, opt);
+ return r.json().catch(() => ({}));
+};
+const post = (path, body) => fetch(path, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+}).then(r => r.json().catch(() => ({})));
+
+// --- grill controls ---
+$('spRange').addEventListener('input', e => $('spOut').textContent = e.target.value + 'Β°F');
+$('spRange').addEventListener('change', e => post('/api/grill/setpoint', { tempF: +e.target.value }));
+$('btnStart').onclick = () => post('/api/grill/start');
+$('btnShutdown').onclick = () => post('/api/grill/shutdown');
+$('btnClear').onclick = () => post('/api/grill/clear');
+let smokeOn = false;
+$('btnSmoke').onclick = () => post('/api/grill/supersmoke', { on: !smokeOn });
+
+// --- make now ---
+$('btnLoad').onclick = () => post('/api/cook/load', { id: $('recipeSelect').value });
+$('btnBegin').onclick = () => post('/api/cook/begin');
+$('btnStop').onclick = () => post('/api/cook/stop');
+$('btnResume').onclick = () => post('/api/cook/resume');
+$('btnNext').onclick = () => post('/api/cook/next');
+$('btnCancel').onclick = () => confirm('Cancel this cook and shut down?') && post('/api/cook/cancel');
+
+// --- recipe editor ---
+$('recipeSelect').addEventListener('change', async () => {
+ const id = $('recipeSelect').value;
+ if (!id) return;
+ const r = await fetch('/api/recipes?id=' + encodeURIComponent(id)).then(x => x.json());
+ $('recipeJson').value = JSON.stringify(r, null, 2);
+});
+$('btnSaveRecipe').onclick = async () => {
+ let doc; try { doc = JSON.parse($('recipeJson').value); }
+ catch (e) { return alert('Invalid JSON: ' + e.message); }
+ const res = await post('/api/recipes', doc);
+ alert(res.ok ? 'Saved ' + res.msg : 'Error: ' + res.error);
+ loadRecipes();
+};
+$('btnDeleteRecipe').onclick = async () => {
+ const id = $('recipeSelect').value;
+ if (id && confirm('Delete ' + id + '?')) { await post('/api/recipes/delete', { id }); loadRecipes(); }
+};
+
+async function loadRecipes() {
+ const list = await fetch('/api/recipes').then(r => r.json());
+ const sel = $('recipeSelect');
+ sel.innerHTML = list.map(r => `${r.name} (${r.stepCount} steps) `).join('');
+}
+
+const setDot = (el, on, smoke) => { el.classList.toggle('active', !!on); if (smoke) el.classList.add('smoke'); };
+
+async function tick() {
+ let s; try { s = await fetch('/api/status').then(r => r.json()); } catch { return; }
+ $('fwver').textContent = s.fw || '';
+ const net = s.net || {};
+ const nb = $('netbadge');
+ nb.textContent = net.mode + (net.ip ? ' Β· ' + net.ip : '');
+ nb.classList.toggle('on', !!net.connected);
+
+ const g = s.grill || {};
+ $('grillTemp').textContent = g.tempF == null ? '--' : Math.round(g.tempF);
+ $('setpoint').textContent = g.setpointF ?? '--';
+ const gs = $('grillState'); gs.textContent = g.state; gs.className = 'pill ' + g.state;
+ setDot($('indAuger'), g.augerOn);
+ setDot($('indIgniter'), g.igniterOn);
+ setDot($('indFan'), g.fanOn);
+ setDot($('indSmoke'), g.superSmoke, true);
+ smokeOn = !!g.superSmoke;
+ $('btnSmoke').classList.toggle('on', smokeOn);
+
+ const err = $('errbar');
+ if (g.state === 'ERROR') { err.textContent = 'β ' + (g.error || 'fault'); err.classList.remove('hidden'); $('btnClear').classList.remove('hidden'); }
+ else { err.classList.add('hidden'); $('btnClear').classList.add('hidden'); }
+
+ // probes
+ $('probes').innerHTML = (s.probes || []).map(p =>
+ `
Probe ${p.index + 1} ` +
+ `${p.connected ? Math.round(p.tempF) + 'Β°F' : 'not connected'}
`).join('');
+
+ // cook / make now
+ const c = s.cook || {};
+ const live = $('cookLive');
+ if (c.recipe) {
+ live.classList.remove('hidden');
+ $('cookRecipe').textContent = c.recipe;
+ const cst = $('cookState'); cst.textContent = c.state; cst.className = 'pill ' + c.state;
+ $('stepIdx').textContent = (c.stepIndex ?? 0) + 1;
+ $('stepCount').textContent = c.stepCount;
+ $('stepName').textContent = c.stepName || '';
+ $('stepSp').textContent = c.stepSetpointF;
+ $('stepSmoke').classList.toggle('hidden', !c.stepSuperSmoke);
+ let adv = '';
+ if (c.advanceType === 'time') adv = `advances in ${fmt(c.stepRemainingS)}`;
+ else if (c.advanceType === 'probe') adv = `advances when probe ${(c.probeIndex ?? 0) + 1} hits ${c.probeTargetF}Β°F`;
+ else if (c.advanceType === 'grill') adv = `advances when grill reaches target`;
+ else if (c.advanceType === 'manual') adv = `waiting β tap Next Step`;
+ $('cookAdv').textContent = adv;
+ $('stepProg').value = Math.round((c.stepProgress || 0) * 100);
+ // dots
+ $('stepDots').innerHTML = Array.from({ length: c.stepCount }, (_, i) =>
+ ` `).join('');
+ // button visibility by state
+ const st = c.state;
+ show('btnBegin', st === 'IDLE');
+ show('btnStop', st === 'RUNNING' || st === 'KEEP_WARM');
+ show('btnResume', st === 'PAUSED');
+ show('btnNext', st === 'RUNNING' && c.advanceType === 'manual');
+ show('btnCancel', st !== 'IDLE' && st !== 'COMPLETE');
+ } else {
+ live.classList.add('hidden');
+ }
+}
+const show = (id, on) => $(id).classList.toggle('hidden', !on);
+const fmt = (s) => { s = Math.max(0, s | 0); const h = s / 3600 | 0, m = (s % 3600) / 60 | 0, ss = s % 60;
+ return (h ? h + 'h ' : '') + (m || h ? m + 'm ' : '') + ss + 's'; };
+
+loadRecipes();
+tick();
+setInterval(tick, 2000);
diff --git a/data/www/index.html b/data/www/index.html
new file mode 100644
index 0000000..cb84c42
--- /dev/null
+++ b/data/www/index.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+ OpenEmber
+
+
+
+
+
+
+
+
+
+
-- Β°F
+
set -- Β°F Β· OFF
+
+
+ auger
+ igniter
+ fan
+ smoke
+
+
+
+
+
Setpoint
+
+ 225Β°F
+
+
+ Ignite
+ Shutdown
+ Super Smoke
+
+
Clear Fault
+
+
+
+
+
+
+
+
+ Make Now
+
+
+ Load
+
+
+
+
+
+
+
+
+ Step / :
+ β Β°F
+ smoke
+
+
+
+
+ Begin Cook
+ Stop
+ Resume
+ Next Step βΈ
+ Cancel
+
+
+
+
+
+ Recipe editor
+ Paste or edit recipe JSON, then Save. See docs/RECIPE_FORMAT.md.
+
+
+ Save Recipe
+ Delete
+
+
+
+
+ OpenEmber Β· not affiliated with Traeger Β· source
+
+
+
diff --git a/data/www/style.css b/data/www/style.css
new file mode 100644
index 0000000..91d6da4
--- /dev/null
+++ b/data/www/style.css
@@ -0,0 +1,76 @@
+:root {
+ --bg: #14100e; --card: #211a16; --line: #3a2e26;
+ --ink: #f4ede4; --dim: #b3a496; --ember: #ff6a1a; --ember2: #ffb020;
+ --ok: #4caf50; --off: #5a4a3e; --danger: #e0483a; --smoke: #7d8ba0;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0; font: 15px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+ background: var(--bg); color: var(--ink);
+}
+header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 18px; border-bottom: 1px solid var(--line);
+ position: sticky; top: 0; background: var(--bg); z-index: 5;
+}
+h1 { font-size: 20px; margin: 0; }
+h2 { font-size: 15px; margin: 0 0 10px; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; }
+main { max-width: 640px; margin: 0 auto; padding: 16px; display: grid; gap: 16px; }
+.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px; }
+.badge { font-size: 12px; padding: 3px 9px; border-radius: 999px; background: var(--line); color: var(--dim); }
+.badge.on { background: #143d18; color: #9be29e; }
+
+.readout { text-align: center; margin-bottom: 12px; }
+.big { font-size: 68px; font-weight: 700; line-height: 1; color: var(--ember2); }
+.big .unit { font-size: 24px; color: var(--dim); margin-left: 4px; }
+.sub { color: var(--dim); margin-top: 6px; }
+.pill { padding: 2px 10px; border-radius: 999px; background: var(--off); color: #fff; font-size: 12px; font-weight: 600; }
+.pill.RUNNING, .pill.KEEP_WARM { background: var(--ok); }
+.pill.IGNITING { background: var(--ember); }
+.pill.ERROR { background: var(--danger); }
+
+.ind { display: flex; gap: 8px; justify-content: center; margin: 10px 0; flex-wrap: wrap; }
+.dot { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--off);
+ border: 1px solid var(--line); border-radius: 999px; padding: 3px 10px; }
+.dot.active { color: #fff; border-color: var(--ember); background: #3a1c0c; }
+.dot.smoke.active { border-color: var(--smoke); background: #1e2836; }
+
+.errbar { background: #3a1512; border: 1px solid var(--danger); color: #ffb3ab;
+ padding: 8px 12px; border-radius: 8px; margin: 8px 0; font-size: 14px; }
+
+.controls label { display: block; color: var(--dim); font-size: 13px; margin-bottom: 12px; }
+input[type=range] { width: 100%; accent-color: var(--ember); }
+output { color: var(--ember2); font-weight: 600; }
+.row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
+button {
+ flex: 1; min-width: 96px; padding: 11px 12px; border-radius: 10px; border: 1px solid var(--line);
+ background: #2c221c; color: var(--ink); font-size: 14px; font-weight: 600; cursor: pointer;
+}
+button:hover { border-color: var(--ember); }
+button.primary { background: var(--ember); border-color: var(--ember); color: #1a0e05; }
+button.danger { background: #3a1512; border-color: var(--danger); color: #ffb3ab; }
+button.toggle.on { background: #1e2836; border-color: var(--smoke); color: #cfe0f5; }
+select, textarea { width: 100%; padding: 10px; border-radius: 10px; border: 1px solid var(--line);
+ background: #191310; color: var(--ink); font-size: 14px; }
+textarea { font-family: ui-monospace, Menlo, monospace; font-size: 13px; }
+
+.probes { display: grid; gap: 8px; }
+.probe { display: flex; justify-content: space-between; align-items: center;
+ padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; }
+.probe .t { font-size: 22px; font-weight: 700; color: var(--ember2); }
+.probe.disc .t { color: var(--off); font-size: 15px; font-weight: 400; }
+
+.cook-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
+.dots { display: flex; gap: 6px; margin: 8px 0; }
+.dots i { width: 12px; height: 12px; border-radius: 50%; background: var(--off); display: inline-block; }
+.dots i.done { background: var(--ok); }
+.dots i.cur { background: var(--ember); box-shadow: 0 0 0 3px #3a1c0c; }
+.cook-step { margin: 6px 0; }
+.cook-adv { color: var(--dim); font-size: 13px; margin: 4px 0 8px; }
+.tag { font-size: 11px; background: #1e2836; color: #cfe0f5; padding: 1px 7px; border-radius: 999px; }
+progress { width: 100%; height: 10px; accent-color: var(--ember); }
+details summary { cursor: pointer; font-weight: 600; color: var(--dim); }
+.hint { color: var(--dim); font-size: 13px; }
+footer { text-align: center; color: var(--dim); font-size: 12px; padding: 24px; }
+footer a { color: var(--ember2); }
+.hidden { display: none !important; }
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 0000000..0eb43ca
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,94 @@
+# HTTP & MQTT API
+
+Two transports expose the **same** status structure (built by `StatusJson`), so
+a client can use whichever it prefers. All HTTP bodies are JSON.
+
+## REST
+
+Base URL is the device IP (or `http://openember.local/` if mDNS resolves).
+
+### Status
+`GET /api/status` β full snapshot:
+
+```json
+{
+ "fw": "0.1.0",
+ "uptimeS": 4210,
+ "grill": {
+ "state": "RUNNING", "tempF": 224.3, "setpointF": 225,
+ "augerOn": true, "augerDuty": 0.22, "igniterOn": false, "fanOn": true,
+ "superSmoke": false, "lit": true, "secondsInState": 3600
+ },
+ "probes": [
+ { "index": 0, "connected": true, "tempF": 149.8 },
+ { "index": 1, "connected": false, "tempF": null }
+ ],
+ "cook": {
+ "state": "RUNNING", "recipe": "Smoked Brisket",
+ "stepIndex": 1, "stepCount": 4, "stepName": "Cook to the stall",
+ "stepSetpointF": 225, "stepSuperSmoke": false,
+ "advanceType": "probe", "probeIndex": 0, "probeTargetF": 165,
+ "stepProgress": 0.9, "stepElapsedS": 5400, "stepRemainingS": 0,
+ "percentComplete": 25
+ },
+ "net": { "mode": "STA", "connected": true, "ip": "192.168.1.42" }
+}
+```
+
+A `null` temperature means the sensor is not present or is faulted β never treat
+it as a number.
+
+### Grill control
+
+| Method & path | Body | Effect |
+| --- | --- | --- |
+| `POST /api/grill/setpoint` | `{"tempF": 225}` | Set target temp (rejected 409 while a cook is RUNNING). |
+| `POST /api/grill/supersmoke` | `{"on": true}` | Toggle Super Smoke (rejected 409 while a cook is RUNNING). |
+| `POST /api/grill/start` | β | Begin the ignition sequence. |
+| `POST /api/grill/shutdown` | β | Controlled shutdown + cool-down. |
+| `POST /api/grill/clear` | β | Clear a latched ERROR (409 until cool & sensor valid). |
+
+### Recipes
+
+| Method & path | Body | Effect |
+| --- | --- | --- |
+| `GET /api/recipes` | β | List `{id,name,stepCount}`. |
+| `GET /api/recipes?id=` | β | Full recipe JSON. |
+| `POST /api/recipes` | recipe JSON | Create/replace a recipe. |
+| `POST /api/recipes/delete` | `{"id":""}` | Delete. |
+
+### Make Now (cook)
+
+| Method & path | Body | Effect |
+| --- | --- | --- |
+| `POST /api/cook/load` | `{"id":""}` | Stage a recipe. |
+| `POST /api/cook/begin` | β | Begin Cook. |
+| `POST /api/cook/stop` | β | Stop β PAUSED, grill back to manual. |
+| `POST /api/cook/resume` | β | Resume from the saved step. |
+| `POST /api/cook/next` | β | Advance a manual step / skip. |
+| `POST /api/cook/cancel` | β | Abandon the cook and shut down. |
+
+### Config
+`GET /api/config` (secrets redacted) Β· `POST /api/config` with any subset of:
+`hostname, wifiSsid, wifiPass, kp, ki, kd, grillOffsetF, mqttHost, mqttPort,
+mqttUser, mqttPass, haDiscovery`. PID gains and the grill offset apply live;
+network changes apply on reboot.
+
+## MQTT
+
+Enabled when `mqttHost` is set. Base topic = `hostname` (default `openember`).
+
+| Topic | Dir | Payload |
+| --- | --- | --- |
+| ` /state` | pub (retained) | same JSON as `GET /api/status` |
+| ` /availability` | pub (LWT) | `online` / `offline` |
+| ` /cmd/setpoint` | sub | temperature Β°F, e.g. `225` |
+| ` /cmd/supersmoke` | sub | `on` / `off` |
+| ` /cmd/power` | sub | `start` / `shutdown` / `clear` |
+| ` /cmd/cook` | sub | a recipe id (load+begin), or `stop`/`resume`/`next`/`cancel` |
+
+With `haDiscovery: true` the device publishes Home Assistant MQTT-discovery
+configs for grill temperature, grill state, each meat probe, the setpoint
+(as a `number`) and Super Smoke (as a `switch`) β so the grill appears in Home
+Assistant automatically with no YAML. This is the recommended way to get an app
+experience (dashboards, notifications, automations) without building a phone app.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..0fb378b
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,78 @@
+# Architecture
+
+OpenEmber is layered so that **fire safety lives in one place** and everything
+above it β recipes, the web UI, MQTT β can only ever ask the controller to do
+things through a small, safe API.
+
+```
+ βββββββββββββββββββββββββββββββββββββββββββββ
+ β main loop β
+ β (main.cpp: sample β control β net, 2 Hz) β
+ βββββββββββββββββββββββββββββββββββββββββββββ
+ β
+ ββββββββββββββββ¬ββββββββββββββββΌββββββββββββββββ¬ββββββββββββββββ
+ βΌ βΌ βΌ βΌ βΌ
+ TempSensor GrillController RecipeEngine WebUi MqttClient
+ (RTD + NTC) (state machine, (Make Now step (REST + UI) (HA discovery)
+ β PID, safety) sequencing) β β
+ β β β βββββ StatusJson β
+ β βΌ β
+ β Outputs βββββββββββββββ (only via setSetpoint/start/β¦)
+ β (auger/igniter/fan relays)
+ βββ Storage (LittleFS: config, recipes, resume) βββ
+```
+
+## Layers
+
+**Hardware (`src/hardware/`)**
+- `Outputs` β dumb, safe GPIO wrapper for the relays. Knows polarity and
+ `allOff()`; knows nothing about grilling.
+- `TempSensor` β grill RTD via a MAX31865 (CallendarβVan Dusen), meat probes via
+ NTC dividers on the ADC (beta model). Returns `NAN` for any faulted channel so
+ callers can never mistake a dead sensor for a real temperature.
+
+**Control (`src/control/`)**
+- `Pid` β normalised (0..1 duty) PID with anti-windup and derivative-on-measurement.
+- `GrillController` β the **only** code that energises a load. A five-state
+ machine (`OFF β IGNITING β RUNNING β SHUTDOWN`, plus latched `ERROR`) that owns
+ the ignition sequence, the windowed auger/fan duty cycles, Super Smoke, the
+ controlled shutdown, and every safety trip. Everything else calls just
+ `start() / shutdown() / setSetpoint() / setSuperSmoke()`.
+
+**Recipe (`src/recipe/`)**
+- `Recipe` β fixed-size, heap-free model of a multi-step cook, plus JSON (de)serialisation.
+- `RecipeEngine` β the Make Now behaviour: drives the controller through steps,
+ advances on time/probe/grill/manual conditions, re-asserts the step setpoint
+ so manual changes are ignored mid-cook, ends in Keep Warm or shutdown, and
+ persists progress for resume-after-reboot.
+
+**Storage (`src/storage/`)**
+- `Storage` β LittleFS-backed config, per-recipe files, and the resume record.
+
+**Net (`src/net/`)**
+- `WifiManager` β STA with SoftAP fallback for first-time setup.
+- `WebUi` β async REST API + serves the SPA in `data/www`.
+- `MqttClient` β optional MQTT bridge with Home Assistant auto-discovery.
+- `StatusJson` β the single serializer both REST and MQTT use, so every
+ transport reports identical fields.
+
+## The control tick
+
+`main.cpp` samples temperatures at ~2 Hz. `GrillController::update()` is
+internally rate-limited to `control::TICK_MS` (1 s). On each control tick it:
+
+1. runs global safety checks (over-temp, sensor loss) whenever a fire could burn,
+2. executes the current state's behaviour,
+3. recomputes the auger duty from the PID once per `AUGER_WINDOW_MS` window,
+4. publishes a fresh `GrillStatus` snapshot.
+
+Network I/O never blocks control: the web server is async and MQTT/Wi-Fi work is
+non-blocking with backoff. If Wi-Fi drops, the grill keeps cooking.
+
+## Why the layering matters
+
+A malformed recipe, a hostile MQTT message, or a UI bug can do at most what the
+public `GrillController` API allows β request a setpoint in range, start, stop.
+It cannot bypass the ignition sequence, hold the igniter on, or defeat the
+over-temp cutoff, because none of those are reachable from outside the
+controller.
diff --git a/docs/FEATURE_PARITY.md b/docs/FEATURE_PARITY.md
new file mode 100644
index 0000000..04a22cb
--- /dev/null
+++ b/docs/FEATURE_PARITY.md
@@ -0,0 +1,43 @@
+# Feature parity with the Traeger Pro 575
+
+How OpenEmber maps to the stock Pro 575 (D2 WiFIRE) feature set from Traeger's
+marketing and app. β
= implemented Β· π‘ = partial / different path Β· β = OpenEmber
+does more than stock.
+
+| Stock feature | OpenEmber | Notes |
+| --- | --- | --- |
+| **Temperature range 165β500 Β°F, 5 Β°F steps** | β
| `TEMP_MIN_F`/`TEMP_MAX_F`; UI slider steps 5 Β°F. (Stock reportedly ships capped at 450 Β°F and unlocks 500 Β°F via an app update β OpenEmber has no such cap.) |
+| **PID hold with variable-speed auger + fan** | β
| The D2 uses PID + continuously variable DC drive (no P-setting). OpenEmber does the same: continuous auger/fan speed proportional to PID demand on DC builds. |
+| **TurboTemp** (fast heat-up & lid-open recovery) | β
| Aggressive full-speed prime/ramp during ignition; PID's derivative term drives fast recovery after a temp drop. |
+| **Automatic ignition & controlled shutdown** | β
| `IGNITING` (reverse un-jam β prime β confirm lit β cut igniter) and `SHUTDOWN` (burn-out + fan cool). |
+| **Keep Warm (165 Β°F)** | β
| `KEEP_WARM_F`; auto-engaged at the end of a recipe, or set manually. |
+| **Meat probe with target-temp alarm** | β
| Probe temps in status; a recipe `probe` step advances at target. HA fires the notification (see below). |
+| **Make Now β guided multi-step recipes** | β | The `RecipeEngine`: multi-step recipes with time/probe/grill/manual advance, progress dots, Keep Warm finish, and **stop/resume** β but recipes live **on your device**, and you author your own (no vendor cloud). |
+| **WiFIRE app: monitor & control over Wi-Fi** | π‘ | Self-hosted **web UI + REST**, and **MQTT β Home Assistant** for phone dashboards, push notifications, and remote control. No Traeger cloud, no iOS/Android build needed. |
+| **Alexa / Google voice control** | β
| Via Home Assistant's native Alexa/Google integrations once the grill is discovered over MQTT. |
+| **1,600+ recipes in-app** | π‘ | You bring your own recipes (simple JSON). Seeds included; share via files/PRs. Quantity is a content library, not firmware. |
+| **Super Smoke** (Ironwood/Timberline; **not** on stock 575) | β | OpenEmber **adds** it to the 575: capped auger duty + reduced fan for a smoulder, active β€ 225 Β°F. |
+| **6-in-1 versatility** (grill/smoke/bake/roast/braise/BBQ) | β
| All just setpoint ranges β no special modes needed. |
+| **Safety auto-shutdown + error codes (LEr/HEr/Er1/Er2)** | β
| `ERROR` state with reasons: over-temp (β HEr 550 Β°F), flameout/low-temp (β LEr <125 Β°F), sensor loss (β Er1/Er2), ignition failure. Fan cools on fault. |
+| **Pellet-level sensor / low-pellet alert** | π‘ | Not native to the stock 575 either (accessory `BAC523`, app-only). OpenEmber infers fuel-out indirectly via flameout detection; a hopper sensor could publish to a probe/HA input as a future add. |
+| **Physical LCD + rotary-dial UI** | π‘ | Replaced by the web UI + HA. A local rotary-encoder + display is a clean future addition β the UI is just another consumer of `GrillController`. |
+| **Over-the-air firmware updates** | β
| Dual OTA app partitions reserved; add ArduinoOTA / web-update to push builds over Wi-Fi. |
+
+## Where OpenEmber intentionally differs
+
+- **No cloud.** Stock WiFIRE runs through Traeger's AWS IoT + Cognito cloud and
+ the Traeger app. OpenEmber keeps everything local: your recipes, your control,
+ your data. Home Assistant supplies the "app" so there's nothing proprietary in
+ the loop and no phone app to maintain.
+- **Programmable, not curated.** Make Now runs Traeger's recipes; OpenEmber runs
+ *yours* β any multi-step temperature/probe/timer program you can express in a
+ small JSON file, versioned and shareable.
+- **Right to repair.** It runs on a controller you built and can inspect, on a
+ grill whose excellent hardware outlives any given cloud API.
+
+## Honest gaps
+
+- No reproduction of Traeger's recipe **content library** β that's user-supplied.
+- No native **pellet-level** hardware (neither has the stock 575).
+- The stock **cloud/app account** features (social, cook history sync) are out of
+ scope by design; Home Assistant covers logging/history/automation locally.
diff --git a/docs/FLASHING.md b/docs/FLASHING.md
new file mode 100644
index 0000000..84c0a85
--- /dev/null
+++ b/docs/FLASHING.md
@@ -0,0 +1,149 @@
+# Flashing & first light
+
+End-to-end: build the firmware, flash the ESP32, upload the web UI, wire it to
+the grill, and do a safe first ignition. **Read [`SAFETY.md`](SAFETY.md) first.**
+
+The golden rule: **prove the firmware and state machine with the mains loads
+disconnected before you ever let it light a fire.**
+
+---
+
+## 0. Prerequisites
+
+- An ESP32 dev board (ESP32-WROOM-32) and a USB cable.
+- [PlatformIO](https://platformio.org/) (`pip install platformio`) or the VS Code
+ extension. PlatformIO pulls the toolchain and libraries automatically.
+- The reference controller board from [`HARDWARE.md`](HARDWARE.md) Β§7
+ (MAX31865, motor driver, MOSFET, bucks) β **not needed for the dry-run in Β§3.**
+
+```bash
+git clone https://github.com/superbeetle1973/openember.git && cd openember
+```
+
+Check the settings in `include/pins.h` and `include/config.h`:
+- `DRIVE_DC_PWM` β `1` for a Traeger D2 (Pro 575) DC drive, `0` for a legacy AC
+ grill with relays/SSRs.
+- `RELAY_ACTIVE_LOW` β match your relay board (AC builds only).
+- Pin numbers β match your wiring.
+- `safety::*` hard limits β leave them alone.
+
+---
+
+## 1. Build & flash the firmware
+
+```bash
+pio run # compile
+pio run -t upload # flash over USB (auto-detects the port)
+pio device monitor # 115200 baud β watch it boot
+```
+
+You should see:
+
+```
+OpenEmber x.y.z booting
+Wi-Fi AP http://192.168.4.1/
+```
+
+## 2. Upload the web UI
+
+The UI in `data/www/` lives on the device's LittleFS, flashed separately:
+
+```bash
+pio run -t uploadfs # uploads data/ to the littlefs partition
+```
+
+(The bundled recipes in `recipes/` are **not** auto-installed β upload them from
+the web UI's Recipe editor, or `POST /api/recipes`, once you're online.)
+
+## 3. First boot & network setup (no grill attached yet)
+
+1. On its first boot with no saved Wi-Fi, OpenEmber starts a SoftAP
+ **`OpenEmber-Setup`** (password in `config.h` β `net_defaults::AP_PASS`,
+ default `grillon123` β change it).
+2. Join that network, open **http://192.168.4.1/**.
+3. Open the config (or `POST /api/config`) and set your 2.4 GHz Wi-Fi SSID +
+ password, hostname, and β recommended β your MQTT broker for Home Assistant.
+4. Reboot. It now joins your Wi-Fi; find it at `http://.local/` (mDNS)
+ or the IP shown on the serial monitor. The `netbadge` in the UI turns green.
+
+## 4. Bench dry-run (loads still disconnected)
+
+With the ESP32 powered but **auger / igniter / fan not connected**, drive the
+state machine from the UI or curl and confirm the logic on the serial monitor
+and the indicator dots:
+
+```bash
+curl -XPOST http:///api/grill/start # β IGNITING (igniter+fan indicators on)
+curl http:///api/status # watch state, augerDuty, fanOn
+curl -XPOST http:///api/grill/shutdown # β SHUTDOWN then OFF
+```
+
+Verify:
+- `IGNITING` energises igniter + fan; the DC auger does a brief **reverse un-jam
+ kick** then primes. Without a real RTD it will hit `ERROR: ignition failed`
+ after the timeout β that is correct behaviour (no temperature rise).
+- Attach the **RTD** and confirm `grill.tempF` reads a sane room temperature. If
+ it reads `null`, check the MAX31865 wiring / `MAX31865_3WIRE` vs `_2WIRE`.
+- Plug in a **meat probe** and confirm `probes[0].tempF` tracks (adjust
+ `ProbeConfig` / `grillOffsetF` if it's off; see below).
+- Unplug the RTD mid-run β the controller must trip to `ERROR: grill temp sensor
+ lost`. This is your most important safety check β **do not skip it.**
+
+## 5. Connect the loads & first light
+
+Only after Β§4 passes:
+
+1. Power off. Wire the auger, igniter, and fan through the DC power stage
+ (H-bridge / MOSFET / fan driver) per [`HARDWARE.md`](HARDWARE.md) Β§7. Confirm
+ **loads are de-energised when the ESP32 is in reset** (fail-safe).
+2. With the hopper loaded and the grill lid **open**, start a cook at ~225 Β°F and
+ **watch the whole ignition**: reverse kick β prime β fire catches β igniter
+ cuts out β temperature climbs β PID settles. Keep an extinguisher nearby and
+ never walk away.
+3. Run a full `shutdown` and confirm the fan keeps running to burn out pellets
+ and cool the firepot before it stops.
+
+## 6. PID tuning
+
+Defaults (`pid_defaults` in `config.h`) are a sane starting point. If the grill
+overshoots or oscillates, tune live via `POST /api/config` (gains apply
+immediately, no reflash):
+
+- **Overshoot on the way up** β lower `kp`, raise `kd`.
+- **Slow steady-state offset** β raise `ki` slightly.
+- **Hunting / oscillation** β lower `ki` and `kp`.
+
+Watch `augerDuty` and `grill.tempF` in `/api/status` while tuning. On DC drive
+the auger runs at a *continuous variable speed* proportional to duty (that's what
+holds temperature tight); on relay builds it pulses over a window.
+
+Set a `grillOffsetF` if your RTD reads consistently high/low against a reference
+thermometer, and a per-probe `offsetF` for the (often low-reading) meat probe.
+
+## 7. Home Assistant
+
+If you set an MQTT broker with `haDiscovery: true`, the grill auto-registers in
+Home Assistant: grill temperature, grill state, each meat probe, the setpoint
+(as a `number`) and Super Smoke (as a `switch`). From there you get dashboards,
+push notifications (e.g. "probe hit 203 Β°F"), and Alexa/Google β no phone app
+build required. See [`API.md`](API.md) for the topics.
+
+## 8. OTA updates (optional)
+
+The partition table reserves two OTA app slots. Once you've flashed once over
+USB you can add an ArduinoOTA or the async web-update handler and push future
+builds over Wi-Fi β handy when the controller is bolted to the grill.
+
+---
+
+## Troubleshooting
+
+| Symptom | Likely cause / fix |
+| --- | --- |
+| `grill.tempF` is `null` | MAX31865 wiring or wrong wire count (`MAX31865_3WIRE`/`_2WIRE`); check the 4300 Ξ© ref for PT1000. |
+| Boots to AP every time | Wi-Fi didn't save or 5 GHz SSID β the ESP32 is **2.4 GHz only**. |
+| `ERROR: ignition failed` on the bench | Expected with no real RTD/fire. On the grill: pellets/igniter/auger jam. |
+| Auger runs backwards | Swap `PIN_AUGER_DIR` sense or the H-bridge motor leads. |
+| Web UI 404 | You flashed firmware but not the filesystem β run `pio run -t uploadfs`. |
+| `mklittlefs: Bad CPU type in executable` on Apple Silicon | PlatformIO installed an x86_64 filesystem tool; install Rosetta (`softwareupdate --install-rosetta`) or build/upload from Linux. |
+| MQTT not connecting | Broker host/port/creds; the state topic is `/state`. |
diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md
new file mode 100644
index 0000000..5866ef5
--- /dev/null
+++ b/docs/HARDWARE.md
@@ -0,0 +1,225 @@
+# Hardware β Traeger Pro 575 teardown & OpenEmber reference build
+
+This is a reference teardown of the **Traeger Pro 575** (model `TFB57GLE`, 2019),
+which uses the first-generation **"Pro D2" WiFIRE controller** (service part
+`KIT0402`), plus the wiring you need to drop in an ESP32 running OpenEmber.
+
+Every claim is tagged: **[CONFIRMED]** (official docs / OEM part listings / FCC /
+multiple corroborating reports) Β· **[MARKETING]** (manufacturer claim, not
+independently verified) Β· **[COMMUNITY]** (forum/DIY, no official teardown) Β·
+**[UNCONFIRMED]** (could not verify). Sources are listed at the end.
+
+> β οΈ Read [`SAFETY.md`](SAFETY.md) first. A physical teardown + multimeter is the
+> only way to close the "[UNCONFIRMED]" gaps for *your* specific unit; part
+> revisions change. Verify before you cut a wire.
+
+---
+
+## 0. The one fact that changes everything: the Pro 575 is a DC grill
+
+Unlike older AC Traegers (Digital Pro / Pro Series 22/34), which switch 120 V
+loads on the control board with relays/triacs, the **Pro 575 D2 runs entirely on
+low-voltage DC.** [CONFIRMED]
+
+- The grill plugs into the wall, but an **external power brick** converts AC to
+ **20 V DC**, and *every* load runs off that DC bus. There is **no line voltage
+ inside the grill body** and **no relay/triac on the controller** β loads are
+ switched by MOSFETs and motor drivers. [CONFIRMED]
+- Consequence for a replacement controller: you do **not** build an SSR/relay
+ power stage (that's for AC grills). You build a DC power stage β an H-bridge
+ for the auger, a MOSFET for the igniter, a PWM fan driver β plus RTD front-ends.
+
+Retail listings that say "120 V AC, 95 W" describe only the wall input; the
+internal bus is 20 V DC. [CONFIRMED]
+
+---
+
+## 1. Controllers & service parts
+
+| Part | OEM # | Notes |
+| --- | --- | --- |
+| Pro 575 D2 WiFIRE controller | **`KIT0402`** | The target board. [CONFIRMED] |
+| Pro 780 controller | `KIT0410` | Different SKU; same D2 harness family, feature-tiered firmware. [CONFIRMED] |
+| Controller knob | `KIT0580` | Shared across D2 WiFIRE grills. [CONFIRMED] |
+| Power brick | `KIT0480` / `ELE143` / `KIT0258` | Model **`ZF120A-2004500`**, in 100β240 VAC β out **20 V DC 4.5 A (90 W)**. [CONFIRMED] |
+| AC power cord | `KIT0257` | Detachable NEMA. [CONFIRMED] |
+
+The D2 controllers share a common connector/harness architecture across the
+line; a Pro 575 board can be swapped for a higher-tier D2 board (e.g. one with
+Super Smoke). [COMMUNITY]
+
+**Board architecture:** external AC/DC brick (a *separate* part) β a **single
+control PCB** behind the faceplate, fed 20 V DC. The heavy supply is not on the
+logic board. [CONFIRMED that it's brick-fed; single-PCB is well-supported, not
+proven by a die-level teardown]
+
+---
+
+## 2. Main ICs (what's on the stock board)
+
+Traeger publishes nothing at chip level and no public teardown documents the
+KIT0402 silicon, so this section is deliberately cautious.
+
+| Function | Stock part | Confidence |
+| --- | --- | --- |
+| MCU + Wi-Fi | **ESP32-WROOM-32** family (Espressif) | [COMMUNITY-CONFIRMED] via network-scan OUI "ESPRESSIF" and the whole reverse-engineering community; exact module variant undocumented. FCC grantee for Traeger is `2AZAM`, but the controller filings' internals are sealed. |
+| Grill RTD front-end | RTD-to-digital path (divider+ADC or a conditioner) | [UNCONFIRMED] exact IC. A **MAX31865** is what the DIY community uses and is the clean path for a rebuild. |
+| Auger driver | 3-phase BLDC driver / H-bridge | [UNCONFIRMED] exact IC (variable speed + reverse confirmed; driver part not documented). |
+| Fan driver | 4-wire PWM fan control | [UNCONFIRMED] exact IC. |
+| Igniter switch | Low-side MOSFET | [COMMUNITY/inferred] (DC resistive load, no relay). |
+| Buck to 5 V / 3.3 V | SMPS from the 20 V bus | [UNCONFIRMED] exact IC. |
+
+Bottom line: the **ESP32** identification is solid; the analog front-ends and
+power-stage part numbers are **not publicly known** and would need a physical
+teardown to confirm.
+
+---
+
+## 3. Temperature sensing
+
+### Grill / firepot sensor β **2-wire PT1000 RTD** [CONFIRMED]
+- Platinum RTD, **1000 Ξ© @ 0 Β°C**, ~3.85 Ξ©/Β°C. Traeger sells it as the "RTD
+ Temperature Sensor." **Not** PT100, **not** a thermocouple. (One generic
+ Traeger doc loosely lists "thermocouple" for D2 β treat as a doc error; the
+ part itself and every sensor-specific source say PT1000 RTD.) [CONFIRMED]
+- **Non-polarized**, two white wires landing on a **2-position green screw
+ terminal** on the controller. [CONFIRMED]
+- **OpenEmber:** read with a **MAX31865 configured for PT1000 (ref resistor
+ 4300 Ξ©)**. This is exactly `TempSensor(1000.0, 4300.0)` β the default in
+ `main.cpp`.
+
+### Meat probe β 3.5 mm mono jack; sensor element is contested
+- Connector: **3.5 mm (1/8") mono/TRS plug**, 2-conductor (tip + sleeve),
+ cross-compatible with Pit Boss / GMG / Louisiana probes. One jack on the Pro
+ 575 ("1 included, capacity to add another"). [CONFIRMED]
+- **Element type is a genuine community disagreement:**
+ - **NTC thermistor (likely ~100 kΞ© @ 25 Β°C, Ξ²β3950):** the whole cross-compatible
+ pellet-probe industry uses NTCs; a 2-conductor plug fits. [COMMUNITY / strongest inference]
+ - **PT1000 RTD:** some Traeger forum posts + aftermarket listings call the meat
+ probe a PT1000 (chosen for >266 Β°F survivability). [COMMUNITY / conflicting]
+- **How to settle it for your unit:** measure the probe at room temp and in
+ boiling water. **~100 kΞ© falling with heat β NTC 100k/Ξ²3950**; **~1080 Ξ© rising
+ with heat β PT1000.**
+- **OpenEmber default:** NTC thermistor front-end (voltage divider into the ADC,
+ Ξ²-model in `TempSensor`), which matches the ubiquitous 3.5 mm aftermarket
+ probes. Tune `ProbeConfig` (seriesResistor / nominalR / betaCoeff / offsetF)
+ to your probe, or, if yours is a PT1000, read it with a second MAX31865
+ instead. The stock probe is known to read low, so a per-probe `offsetF` is
+ provided. [COMMUNITY caution]
+
+---
+
+## 4. Loads / actuators
+
+| Load | OEM # | Electrical | Connector | Switched by |
+| --- | --- | --- | --- | --- |
+| **Auger motor** | `KIT0577` (harness `ELE161`) | **DC, ~20β24 V**, variable-speed, **reversible** (jam clearing) | **5-pin Molex** | H-bridge / commutation + PWM + direction. [CONFIRMED DC/variable/reverse; "brushless" is [MARKETING]; 5 pins imply hall/encoder feedback β [SPECULATION]] |
+| **Hot-rod igniter** | `KIT0255` (`ELE145`) | **20 V DC, 55 W** (~2.75 A β the largest single load) | **2-pin Molex, clasp** (purple lead) | Low-side MOSFET, on/off. [CONFIRMED 20 V/55 W DC] |
+| **Induction fan** | `KIT0411` (`ELE173`) | **12 V DC, 0.86 A (~10 W), ~3500 RPM**, 4-wire PWM (Sunon 11925SA-12R-class) | **4-pin Molex** (black/red/white/tan) | PWM speed control. One fan does both combustion + convection. [CONFIRMED 12 V variable] |
+
+Notes:
+- The "20-volt / brushless / industry-first" language is Traeger marketing; the
+ **confirmable** facts are *DC + variable-speed + reversible auger + independent
+ variable-speed 12 V fan + 20 V 55 W DC igniter*. [MARKETING vs CONFIRMED]
+- The fan runs on a **12 V rail**, not the 20 V bus β your build needs a 12 V
+ buck for it (or a 12 V fan driver). [CONFIRMED]
+
+---
+
+## 5. Connectors / harness (control board β grill)
+
+Traeger's official "D2 WiFIRE Grill Wiring" doc confirms **each connector is
+uniquely keyed** (mates one way only); hot-rod and power use a pinch clasp.
+**Per-pin pinouts are not published** and no teardown documents them pin-by-pin,
+so this is connector type + wire color (confirmed) with pin function noted as
+speculation. [CONFIRMED types/colors; [SPECULATION] pin assignment]
+
+| Load | Connector | Wire colors | Function notes |
+| --- | --- | --- | --- |
+| Auger | 5-pin Molex (`ELE161`) | red at ctrl | 2 motor leads + likely 3 feedback/dir |
+| Fan | 4-pin Molex | black/red/white/tan | GND / +12 V / tach / PWM (standard 4-wire) |
+| Igniter | 2-pin Molex, clasp | purple | 20 V DC, 55 W |
+| Grill RTD | 2-wire β **green screw terminal** | white / white | non-polarized PT1000 |
+| Power in | clasp/barrel | β | 20 V DC from brick |
+| Ground | green / green-yellow | β | bonded to auger bushing / chassis |
+| Meat probe | 3.5 mm TRS jack on faceplate | β | tip + sleeve = sense element |
+
+There is **no user-serviceable fuse** on the D2 β protection is electronic in the
+power brick (OCP/OVP/short). A dead 575 is almost always a failed brick. [CONFIRMED]
+
+---
+
+## 6. User interface (stock)
+
+- **Display:** monochrome **LCD**, non-touch, dot-matrix-style (renders menu text,
+ temps, and error strings like `LEr`/`HEr`). Not OLED, not color. Exact backlight
+ colour and pixel resolution are **[UNCONFIRMED]** (no citable source; the
+ owner's manual is the place to confirm). [CONFIRMED monochrome LCD]
+- **Controls:** a **single rotary dial** β rotate to change temperature in **5 Β°F**
+ increments and to navigate menus, **press** to select (`KIT0580`) β plus a
+ dedicated **IGNITE** button and a **power switch**. Keep Warm, Timer, and the
+ probe alarm are **menu items reached with the dial**, not face buttons.
+ [CONFIRMED] (Web descriptions of "arrow buttons" or "side buttons for Menu /
+ Timer / Keep Warm / Super Smoke" are *other* Traeger controllers β do not apply
+ them to the Pro 575.)
+
+OpenEmber replaces this UI with a phone/desktop **web UI** and Home Assistant; an
+optional local rotary-encoder + display is a natural future addition (the code is
+structured so a UI is just another consumer of `GrillController`).
+
+---
+
+## 7. OpenEmber reference build (drop-in replacement controller)
+
+Reuse the grill's existing sensors and actuators via their connectors; replace
+only the brains.
+
+| Grill side | Connect to | Board part |
+| --- | --- | --- |
+| 20 V DC brick | board V_in; buck to 12 V (fan), 5 V, 3.3 V (ESP32) | DC-DC bucks |
+| PT1000 grill RTD (green terminal) | MAX31865 (PT1000, 4300 Ξ© ref) on VSPI | `PIN_RTD_CS/SCK/MISO/MOSI` |
+| Meat probe (3.5 mm) | NTC divider β ADC1 (or 2nd MAX31865 if PT1000) | `PIN_PROBE_1/2` |
+| Auger (5-pin, 20β24 V, reversible) | H-bridge (BTS7960 / DRV8871): PWM + DIR | `PIN_AUGER` (PWM), `PIN_AUGER_DIR` |
+| Igniter (2-pin, 20 V 55 W) | logic-level MOSFET (β₯5 A, heatsinked) | `PIN_IGNITER` |
+| Fan (4-pin, 12 V PWM) | MOSFET / fan driver on 12 V rail | `PIN_FAN` (PWM) |
+
+Set `DRIVE_DC_PWM 1` in `pins.h` (the default) for this build. For a **legacy AC
+Traeger** instead, set `DRIVE_DC_PWM 0` and switch the AC auger/igniter/fan
+through opto-isolated SSRs β the control logic is identical.
+
+### Reference open-source projects worth reading
+- **[jazzmonger/Traeger-PID-Controller](https://github.com/jazzmonger/Traeger-PID-Controller)** β
+ ESP32 replacement controller with KiCad board files + BOM (targets AC grills
+ with SSRs, so reuse the *logic*, not the power stage, for a DC 575).
+- **[nebhead/PiFire](https://github.com/nebhead/PiFire)** β Pi-based controller;
+ excellent reference for MAX31865 (RTD) + ADC probe front-ends and probe tuning.
+- **[ESPHome MAX31865](https://esphome.io/components/sensor/max31865/)** β confirms
+ PT1000 β 4300 Ξ© reference resistor.
+- **pytraeger / go-traeger** β reverse-engineered *cloud* API (AWS IoT + MQTT/
+ Cognito); no hardware content, but useful if you want to talk to stock grills.
+
+---
+
+## 8. Biggest unknowns (need a physical teardown to close)
+1. Exact **primary MCU** part and **ESP32 module variant** on KIT0402.
+2. Exact **pin-by-pin** assignment inside each Molex (only type + colour public).
+3. Whether the auger is truly **brushless BLDC** vs brushed-with-encoder.
+4. The stock **temperature-amplifier**, **BLDC driver**, and **buck** IC parts.
+5. The official **RTD calibration curve** the stock controller expects.
+6. The **meat-probe element** (NTC vs PT1000) β verify by measurement.
+
+## Sources
+- Traeger β D2 WiFIRE grill wiring: https://support.traeger.com/hc/en-us/articles/4407213082907-D2-WiFIRE-Grill-Wiring
+- Traeger β power bricks for D2: https://support.traeger.com/hc/en-us/articles/37324012853659-Power-Bricks-for-D2-WiFIRE-Grills
+- Traeger β RTD replacement: https://support.traeger.com/hc/en-us/articles/4417251505051-RTD-Replacement-Instructions
+- Traeger β D2 Direct Drive: https://www.traeger.com/learn/d2-direct-drive
+- Traeger β wired meat probes: https://support.traeger.com/hc/en-us/articles/4477084589595-Wired-Meat-Probes
+- Traeger β diagnosing D2 induction fan: https://support.traeger.com/hc/en-us/articles/19565879234715-Diagnosing-Induction-Fan-Issues-on-a-D2-WiFIRE-Grill
+- Controller KIT0402: https://pellet-stove-parts-4less.com/products/traeger-pro-575-d2-wifire-controller-kit-kit0402
+- Auger `KIT0577`/`ELE144`: https://pellet-stove-parts-4less.com/products/traeger-auger-motor-for-pro-575-780-kit0577 Β· https://pellet-stove-parts-4less.com/products/traeger-dc-auger-motor-kit0271-ele144
+- Igniter `KIT0255` (20 V/55 W): https://pellet-stove-parts-4less.com/products/ignitor-for-all-dc-motor-traeger-pellet-stoves-kit0255
+- Fan `KIT0411` (12 V): https://pellet-stove-parts-4less.com/products/traeger-dc-fan-motor-kit0411
+- Power brick `ZF120A-2004500`: https://www.amazon.com/dp/B0B5L2ZXBD
+- ESP32 in WiFIRE (community): https://www.traegerforum.com/threads/eero-dual-band-mesh-router-and-wifire.871/
+- Meat probe type debate: https://www.traegerforum.com/threads/what-type-of-temperature-probe-are-the-traeger-meat-probes.4931/
+- Error codes LEr/HEr: https://support.traeger.com/hc/en-us/articles/4407219738139-LEr-Error-Code Β· https://support.traeger.com/hc/en-us/articles/4407219741211-HEr-Error-Code
diff --git a/docs/RECIPE_FORMAT.md b/docs/RECIPE_FORMAT.md
new file mode 100644
index 0000000..2c4eb1a
--- /dev/null
+++ b/docs/RECIPE_FORMAT.md
@@ -0,0 +1,70 @@
+# Recipe format (programmable "Make Now")
+
+A recipe is a small JSON document describing an ordered list of cook steps. It is
+the OpenEmber analogue of Traeger's **Make Now**: load a recipe, tap **Begin
+Cook**, and the grill drives itself through each step, advancing automatically
+when the step's condition is met, and finishing in **Keep Warm** (165 Β°F) β or
+shutting down.
+
+Validate any recipe with `python3 tools/validate_recipe.py your_recipe.json`.
+The machine-readable schema is [`recipes/schema.json`](../recipes/schema.json).
+
+## Top-level fields
+
+| Field | Type | Default | Notes |
+| --- | --- | --- | --- |
+| `id` | string | β (required) | Filename-safe unique id (`[a-z0-9_-]`, β€ 39 chars). |
+| `name` | string | = `id` | Human name shown in the UI. |
+| `author` | string | `""` | Optional. |
+| `keepWarm` | bool | `true` | When the last step finishes: `true` β hold 165 Β°F; `false` β controlled shutdown. |
+| `steps` | array | β (required) | 1β16 steps, run in order. |
+
+## Step fields
+
+| Field | Type | Default | Notes |
+| --- | --- | --- | --- |
+| `name` | string | `"Step"` | Shown in the UI. |
+| `setpointF` | number | `180` | Grill target for this step, 150β500 Β°F. |
+| `superSmoke` | bool | `false` | Enable Super Smoke. Only effective at `setpointF β€ 225 Β°F`. |
+| `advance` | object | β (required) | How the step ends. |
+
+### Advance conditions
+
+| `type` | Extra fields | Advances when⦠|
+| --- | --- | --- |
+| `time` | `seconds` (int) | that many seconds have elapsed in the step |
+| `probe` | `probe` (0/1), `targetF` | meat probe `probe` reaches `targetF` |
+| `grill` | `targetF` | grill temperature reaches `targetF` (e.g. preheat before a sear) |
+| `manual` | β | the user taps **Next Step** (or `POST /api/cook/next`) |
+
+While a cook is `RUNNING`, manual setpoint / Super Smoke changes are rejected β
+the recipe owns the grill (mirroring the stock app locking the controls).
+
+## Example β reverse-seared ribeye
+
+```json
+{
+ "id": "reverse_sear_steak",
+ "name": "Reverse-Sear Ribeye",
+ "keepWarm": false,
+ "steps": [
+ { "name": "Low smoke to temp", "setpointF": 225, "superSmoke": true,
+ "advance": { "type": "probe", "probe": 0, "targetF": 118 } },
+ { "name": "Preheat for sear", "setpointF": 500,
+ "advance": { "type": "grill", "targetF": 480 } },
+ { "name": "Sear β flip at the beep", "setpointF": 500,
+ "advance": { "type": "manual" } },
+ { "name": "Sear side two", "setpointF": 500,
+ "advance": { "type": "probe", "probe": 0, "targetF": 130 } }
+ ]
+}
+```
+
+## Uploading recipes
+
+- **Web UI:** open the *Recipe editor*, paste JSON, **Save**.
+- **REST:** `POST /api/recipes` with the JSON body.
+- **Bundled seeds:** the files in [`recipes/`](../recipes/) can be uploaded as-is.
+- Recipes are stored one file per id at `/recipes/.json` on the device's
+ LittleFS and survive reboots. An in-progress cook is also persisted, so a
+ power blip returns the grill to a **paused** cook you can resume.
diff --git a/docs/SAFETY.md b/docs/SAFETY.md
new file mode 100644
index 0000000..b175747
--- /dev/null
+++ b/docs/SAFETY.md
@@ -0,0 +1,57 @@
+# Safety
+
+**OpenEmber controls a mains-powered appliance that lights a fire and reaches
+500 Β°F. Read this before you build, flash, or run it. You are responsible for
+your own safety and property. There is no warranty (see LICENSE).**
+
+## The hazards are real
+
+A pellet grill controller switches three mains-voltage loads and manages an
+open flame in a hopper full of combustible fuel. The failure modes that hurt
+people are:
+
+| Failure | Consequence | How OpenEmber guards against it |
+| --- | --- | --- |
+| Auger runs continuously | Firepot overfills β **flare-up / grease fire** on next ignition | Auger is only ever driven by a windowed duty cycle with a hard max-on; `allOff()` on any fault |
+| Igniter stays energised | Element burns out; sustained ignition risk | `IGNITER_MAX_MS` hard cap; igniter cut the instant a fire is confirmed |
+| Grill runs away hot | Fire, warping, damage | `safety::GRILL_OVERTEMP_F` (550 Β°F) trips a latched `ERROR` and kills auger + igniter |
+| Fire goes out but auger keeps feeding | Unburned pellets pile up, then **ignite all at once** | Flameout detector: sustained low temp while calling for heat β `ERROR` |
+| Temperature sensor fails/disconnects | Controller "flies blind" | Stale/NaN grill temp β `ERROR`; the control loop never trusts a missing reading |
+
+These are defence-in-depth, **not** a substitute for supervision.
+
+## Non-negotiable build rules
+
+1. **Never wire mains directly to an ESP32 GPIO.** Switch the auger, igniter,
+ and fan through properly rated relays or solid-state relays with opto
+ isolation, on a separate board, with correct creepage/clearance.
+2. **Fuse the mains input** and use a grounded metal enclosure. The igniter
+ alone can draw several hundred watts.
+3. **Keep low-voltage (ESP32, probes, RTD) physically and electrically
+ isolated** from the mains section. Use a proper isolated SMPS for the 5 V/3.3 V
+ rails β do not "borrow" neutral.
+4. **The loads must fail safe.** With the ESP32 unpowered or in reset, every
+ relay must be de-energised (loads OFF). Choose relay polarity accordingly and
+ set `RELAY_ACTIVE_LOW` in `pins.h` to match.
+5. **Add an independent hardware safety** where you can β e.g. a thermal cutoff
+ (klixon) in series with the igniter, and a mechanical over-temp switch. The
+ firmware is one layer, not the only layer.
+
+## Operating rules
+
+- **Never leave a lit grill unattended**, regardless of firmware. Keep a fire
+ extinguisher rated for grease fires nearby.
+- Do a **dry run with the loads disconnected** first: watch the serial log and
+ the web UI confirm the state machine (ignition β run β shutdown) before you
+ ever connect the auger/igniter/fan.
+- After any `ERROR`, find the cause before clearing. `clearError()` deliberately
+ refuses until the grill is cool and the sensor reads valid again.
+- The hard limits in `config.h` under `namespace safety` are the last line of
+ defence and are **not** overridable from `config.json`. Do not raise them.
+
+## What this project is not
+
+OpenEmber is an independent right-to-repair / hobbyist project. It is not
+affiliated with, endorsed by, or supported by Traeger. Replacing your
+controller may void your warranty and, depending on how you do it, your
+insurance posture. Understand that before you start.
diff --git a/include/config.h b/include/config.h
new file mode 100644
index 0000000..1041507
--- /dev/null
+++ b/include/config.h
@@ -0,0 +1,72 @@
+// config.h β compile-time defaults and safety limits for OpenEmber.
+//
+// Runtime-tunable values (Wi-Fi credentials, PID gains, temperature offsets)
+// live in /config.json on LittleFS and override these defaults at boot. The
+// hard safety limits below are deliberately NOT runtime-overridable.
+#pragma once
+
+#ifndef OPENEMBER_VERSION
+#define OPENEMBER_VERSION "0.0.0-dev"
+#endif
+
+// ---------------------------------------------------------------------------
+// Temperature units: OpenEmber works internally in Fahrenheit (like the stock
+// controller). The UI can present Celsius; conversion happens at the edges.
+// ---------------------------------------------------------------------------
+static constexpr float KEEP_WARM_F = 165.0f; // matches "Keep Warm" behaviour
+static constexpr float TEMP_MIN_F = 150.0f; // lowest selectable setpoint
+static constexpr float TEMP_MAX_F = 500.0f; // highest selectable setpoint
+
+// ---------------------------------------------------------------------------
+// HARD SAFETY LIMITS β these trip the controller into a locked-out ERROR state
+// and cannot be raised from config.json. Treat them as the last line of defence,
+// not as normal operating parameters.
+// ---------------------------------------------------------------------------
+namespace safety {
+ static constexpr float GRILL_OVERTEMP_F = 550.0f; // firepot runaway cutoff
+ static constexpr float PROBE_MAX_VALID_F = 600.0f; // above this = sensor fault
+ static constexpr float PROBE_MIN_VALID_F = -40.0f; // below this = open/fault
+ static constexpr uint32_t IGNITE_TIMEOUT_S = 600; // must reach ignition rise in 10 min
+ static constexpr uint32_t FLAMEOUT_TIMEOUT_S = 300; // sustained low-temp while running = flameout
+ static constexpr float IGNITE_RISE_F = 115.0f; // temp that confirms a lit fire
+ static constexpr float FLAMEOUT_FLOOR_F = 125.0f; // below this while running -> flameout watch (cf. Traeger LEr)
+ static constexpr uint32_t SENSOR_STALE_MS = 15000; // no fresh grill temp = fault
+}
+
+// ---------------------------------------------------------------------------
+// Control loop timing.
+// ---------------------------------------------------------------------------
+namespace control {
+ static constexpr uint32_t TICK_MS = 1000; // controller update cadence
+ static constexpr uint32_t AUGER_WINDOW_MS = 60000; // one auger duty window (matches ~P2 timing)
+ static constexpr uint32_t AUGER_MIN_ON_MS = 3000; // shortest meaningful auger pulse
+ static constexpr uint32_t PRIME_MS = 20000; // initial pellet prime at startup
+ static constexpr uint32_t IGNITER_MAX_MS = 300000;// igniter never energised longer than this
+ static constexpr uint32_t SHUTDOWN_FAN_MS = 600000;// fan runs to burn out & cool (10 min)
+ static constexpr float SHUTDOWN_COOL_F = 150.0f;// consider cool once grill drops below this
+}
+
+// ---------------------------------------------------------------------------
+// Default PID gains (auger duty per Β°F of error). Retunable via config.json.
+// ---------------------------------------------------------------------------
+namespace pid_defaults {
+ static constexpr float KP = 0.020f;
+ static constexpr float KI = 0.0009f;
+ static constexpr float KD = 0.030f;
+ // Super Smoke: bias toward a lower, cyclic burn (160β225Β°F band) that
+ // smoulders pellets for more smoke. Caps auger duty and drops fan speed.
+ static constexpr float SUPERSMOKE_MAX_DUTY = 0.35f;
+ static constexpr float SUPERSMOKE_FAN_DUTY = 0.55f;
+ static constexpr float SUPERSMOKE_MAX_F = 225.0f; // super smoke is disabled above this setpoint
+}
+
+// ---------------------------------------------------------------------------
+// Networking / identity.
+// ---------------------------------------------------------------------------
+namespace net_defaults {
+ static constexpr char HOSTNAME[] = "openember";
+ static constexpr char AP_SSID[] = "OpenEmber-Setup";
+ static constexpr char AP_PASS[] = "grillon123"; // change me β WPA2 min 8 chars
+ static constexpr uint16_t HTTP_PORT = 80;
+ static constexpr uint16_t MQTT_PORT = 1883;
+}
diff --git a/include/pins.h b/include/pins.h
new file mode 100644
index 0000000..967f569
--- /dev/null
+++ b/include/pins.h
@@ -0,0 +1,60 @@
+// pins.h β GPIO assignments for the OpenEmber controller board.
+//
+// These defaults target a plain ESP32 dev board wired to a small relay/SSR
+// board that switches the mains-voltage loads (auger, igniter, fan). Adjust to
+// match your own carrier board. See docs/HARDWARE.md for the reference wiring.
+//
+// SAFETY: the auger, igniter, and fan run on mains voltage. They MUST be
+// switched through appropriately rated relays or solid-state relays with proper
+// isolation and fusing. Never wire mains directly to a GPIO. See docs/SAFETY.md.
+#pragma once
+
+// --- Drive mode -------------------------------------------------------------
+// The Traeger D2 line (Pro 575/780, Ironwood, Timberline) runs LOW-VOLTAGE DC
+// loads: a ~20-24V variable-speed, reversible DC auger (ELE144/KIT0577) and an
+// independent variable-speed DC fan, fed by an external DC power brick
+// (KIT0480 / ZF120A-2004500). For those grills use DRIVE_DC_PWM: the auger and
+// fan are speed-controlled with PWM through a motor driver (e.g. a BTS7960 /
+// DRV8871 H-bridge for the reversible auger, a logic-level MOSFET for the fan).
+//
+// Older AC-induction Traegers (non-WiFi Digital Pro, etc.) switch mains loads
+// on/off through relays/SSRs β use DRIVE_RELAY for those.
+#define DRIVE_DC_PWM 1 // 1 = variable-speed DC (D2); 0 = on/off relays (AC)
+
+// --- Load outputs -----------------------------------------------------------
+// In DRIVE_DC_PWM mode PIN_AUGER and PIN_FAN are PWM speed signals into a motor
+// driver; PIN_AUGER_DIR selects auger direction (LOW = feed, HIGH = reverse to
+// clear a jam). In DRIVE_RELAY mode PIN_AUGER/PIN_FAN drive relays on/off and
+// PIN_AUGER_DIR is unused.
+#define PIN_AUGER 25 // auger: PWM speed (DC) or relay (AC)
+#define PIN_AUGER_DIR 33 // auger direction (DC drive only)
+#define PIN_IGNITER 26 // hot-rod igniter (switched on/off via MOSFET/SSR)
+#define PIN_FAN 27 // fan: PWM speed (DC) or relay (AC)
+#define PIN_AUX 14 // spare output (e.g. grill light)
+
+// Set to 1 if your relay board is active-LOW (most cheap opto relay boards are).
+// Applies only to on/off outputs (igniter, aux, and loads in DRIVE_RELAY mode).
+#define RELAY_ACTIVE_LOW 0
+
+// PWM (LEDC) configuration for DC drive. 20 kHz keeps the motors out of the
+// audible range; 10-bit gives 0..1023 duty steps.
+#define PWM_FREQ_HZ 20000
+#define PWM_RES_BITS 10
+#define PWM_CH_AUGER 0
+#define PWM_CH_FAN 1
+
+// --- Grill temperature: MAX31865 RTD amplifier on the VSPI bus (PT1000) ---
+#define PIN_RTD_CS 5 // MAX31865 chip-select
+#define PIN_SPI_SCK 18
+#define PIN_SPI_MISO 19
+#define PIN_SPI_MOSI 23
+
+// --- Meat probes: NTC thermistors on a divider into the ADC ---
+// Up to two probes by default. ADC1 channels are used so Wi-Fi stays usable.
+#define PIN_PROBE_1 34 // ADC1_CH6, input-only pin
+#define PIN_PROBE_2 35 // ADC1_CH7, input-only pin
+#define PROBE_COUNT 2
+
+// --- User interface ---
+#define PIN_BUZZER 32 // active buzzer for alarms
+#define PIN_STATUS_LED 2 // onboard LED (heartbeat / fault indicator)
diff --git a/partitions.csv b/partitions.csv
new file mode 100644
index 0000000..09d0dc0
--- /dev/null
+++ b/partitions.csv
@@ -0,0 +1,7 @@
+# OpenEmber partition table for a 4MB ESP32.
+# Name, Type, SubType, Offset, Size, Flags
+nvs, data, nvs, 0x9000, 0x5000,
+otadata, data, ota, 0xe000, 0x2000,
+app0, app, ota_0, 0x10000, 0x1A0000,
+app1, app, ota_1, 0x1B0000, 0x1A0000,
+littlefs, data, spiffs, 0x350000, 0xB0000,
diff --git a/partitions_16mb.csv b/partitions_16mb.csv
new file mode 100644
index 0000000..fc5c6a4
--- /dev/null
+++ b/partitions_16mb.csv
@@ -0,0 +1,9 @@
+# OpenEmber partition table for a 16MB ESP32 (e.g. ESP32-WROOM-32E-N16).
+# Two ~6.25MB OTA app slots plus a large LittleFS for the web UI + recipes.
+# Name, Type, SubType, Offset, Size, Flags
+nvs, data, nvs, 0x9000, 0x5000,
+otadata, data, ota, 0xe000, 0x2000,
+app0, app, ota_0, 0x10000, 0x640000,
+app1, app, ota_1, 0x650000, 0x640000,
+littlefs, data, spiffs, 0xC90000, 0x360000,
+coredump, data, coredump, 0xFF0000, 0x10000,
diff --git a/platformio.ini b/platformio.ini
new file mode 100644
index 0000000..f2543d7
--- /dev/null
+++ b/platformio.ini
@@ -0,0 +1,46 @@
+; OpenEmber β open-source programmable pellet-grill firmware
+; PlatformIO project configuration.
+;
+; Build: pio run
+; Flash: pio run -t upload
+; Web UI: pio run -t uploadfs (uploads data/ to LittleFS)
+; Monitor: pio device monitor
+
+[platformio]
+default_envs = esp32dev
+data_dir = data
+
+[env]
+platform = espressif32@^6.9.0
+framework = arduino
+monitor_speed = 115200
+board_build.filesystem = littlefs
+board_build.partitions = partitions.csv
+
+lib_deps =
+ bblanchon/ArduinoJson@^7.2.0
+ adafruit/Adafruit MAX31865 library@^1.6.2
+ adafruit/Adafruit BusIO@^1.16.1
+ ; The original me-no-dev ESP Async WebServer / AsyncTCP packages are no
+ ; longer maintained and no longer resolve in the PlatformIO registry. Use
+ ; the maintained ESP32Async forks instead (drop-in; same headers/API).
+ esp32async/ESPAsyncWebServer@^3.4.0
+ esp32async/AsyncTCP@^3.2.0
+ knolleary/PubSubClient@^2.8
+ ; If the registry names ever fail to resolve, replace the two lines above
+ ; with these git URLs (always resolvable):
+ ; https://github.com/ESP32Async/ESPAsyncWebServer.git
+ ; https://github.com/ESP32Async/AsyncTCP.git
+
+build_flags =
+ -DCORE_DEBUG_LEVEL=2
+ -DOPENEMBER_VERSION=\"0.1.0\"
+
+[env:esp32dev]
+board = esp32dev
+
+; A second target with a larger flash layout for boards that have 8/16MB.
+[env:esp32dev-16mb]
+board = esp32dev
+board_build.partitions = partitions_16mb.csv
+board_upload.flash_size = 16MB
diff --git a/recipes/baby_back_ribs.json b/recipes/baby_back_ribs.json
new file mode 100644
index 0000000..23e34dc
--- /dev/null
+++ b/recipes/baby_back_ribs.json
@@ -0,0 +1,24 @@
+{
+ "id": "baby_back_ribs",
+ "name": "3-2-1 Baby Back Ribs",
+ "author": "OpenEmber",
+ "keepWarm": true,
+ "steps": [
+ {
+ "name": "Smoke unwrapped (3h)",
+ "setpointF": 180,
+ "superSmoke": true,
+ "advance": { "type": "time", "seconds": 10800 }
+ },
+ {
+ "name": "Wrap in foil (2h)",
+ "setpointF": 225,
+ "advance": { "type": "time", "seconds": 7200 }
+ },
+ {
+ "name": "Sauce & set (1h)",
+ "setpointF": 225,
+ "advance": { "type": "time", "seconds": 3600 }
+ }
+ ]
+}
diff --git a/recipes/reverse_sear_steak.json b/recipes/reverse_sear_steak.json
new file mode 100644
index 0000000..05d7aea
--- /dev/null
+++ b/recipes/reverse_sear_steak.json
@@ -0,0 +1,29 @@
+{
+ "id": "reverse_sear_steak",
+ "name": "Reverse-Sear Ribeye",
+ "author": "OpenEmber",
+ "keepWarm": false,
+ "steps": [
+ {
+ "name": "Low smoke to temp",
+ "setpointF": 225,
+ "superSmoke": true,
+ "advance": { "type": "probe", "probe": 0, "targetF": 118 }
+ },
+ {
+ "name": "Preheat for sear",
+ "setpointF": 500,
+ "advance": { "type": "grill", "targetF": 480 }
+ },
+ {
+ "name": "Sear β flip at the beep",
+ "setpointF": 500,
+ "advance": { "type": "manual" }
+ },
+ {
+ "name": "Sear side two",
+ "setpointF": 500,
+ "advance": { "type": "probe", "probe": 0, "targetF": 130 }
+ }
+ ]
+}
diff --git a/recipes/schema.json b/recipes/schema.json
new file mode 100644
index 0000000..aa08fc9
--- /dev/null
+++ b/recipes/schema.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "OpenEmber Recipe",
+ "type": "object",
+ "required": ["id", "steps"],
+ "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string", "pattern": "^[a-z0-9_\\-]{1,39}$", "description": "unique filename-safe id" },
+ "name": { "type": "string", "maxLength": 47 },
+ "author": { "type": "string", "maxLength": 39 },
+ "keepWarm": { "type": "boolean", "default": true, "description": "drop to 165Β°F when finished instead of shutting down" },
+ "steps": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 16,
+ "items": {
+ "type": "object",
+ "required": ["setpointF", "advance"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "maxLength": 31 },
+ "setpointF": { "type": "number", "minimum": 150, "maximum": 500 },
+ "superSmoke": { "type": "boolean", "default": false, "description": "only effective at setpoints <= 225Β°F" },
+ "advance": {
+ "type": "object",
+ "required": ["type"],
+ "properties": {
+ "type": { "enum": ["time", "probe", "grill", "manual"] },
+ "seconds": { "type": "integer", "minimum": 0, "description": "for type=time" },
+ "probe": { "type": "integer", "minimum": 0, "maximum": 1, "description": "for type=probe" },
+ "targetF": { "type": "number", "description": "for type=probe or type=grill" }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/recipes/smoked_brisket.json b/recipes/smoked_brisket.json
new file mode 100644
index 0000000..9b0bd96
--- /dev/null
+++ b/recipes/smoked_brisket.json
@@ -0,0 +1,29 @@
+{
+ "id": "smoked_brisket",
+ "name": "Smoked Brisket",
+ "author": "OpenEmber",
+ "keepWarm": true,
+ "steps": [
+ {
+ "name": "Super Smoke",
+ "setpointF": 180,
+ "superSmoke": true,
+ "advance": { "type": "time", "seconds": 10800 }
+ },
+ {
+ "name": "Cook to the stall",
+ "setpointF": 225,
+ "advance": { "type": "probe", "probe": 0, "targetF": 165 }
+ },
+ {
+ "name": "Wrap & finish",
+ "setpointF": 250,
+ "advance": { "type": "probe", "probe": 0, "targetF": 204 }
+ },
+ {
+ "name": "Rest",
+ "setpointF": 165,
+ "advance": { "type": "time", "seconds": 3600 }
+ }
+ ]
+}
diff --git a/src/control/GrillController.cpp b/src/control/GrillController.cpp
new file mode 100644
index 0000000..37f8692
--- /dev/null
+++ b/src/control/GrillController.cpp
@@ -0,0 +1,268 @@
+#include "control/GrillController.h"
+#include
+
+// Fixed feed rate used only while trying to establish a fire.
+static constexpr float IGNITE_AUGER_DUTY = 0.55f;
+static constexpr uint32_t FAN_WINDOW_MS = 12000;
+// DC variable-speed drive: the slowest speed at which the auger still reliably
+// turns and meters pellets, and a brief reverse "un-jam" kick at ignition.
+static constexpr float AUGER_MIN_SPEED = 0.25f;
+static constexpr float FAN_MIN_SPEED = 0.30f;
+static constexpr uint32_t UNJAM_MS = 1200;
+
+const char* grillStateName(GrillState s) {
+ switch (s) {
+ case GrillState::OFF: return "OFF";
+ case GrillState::IGNITING: return "IGNITING";
+ case GrillState::RUNNING: return "RUNNING";
+ case GrillState::SHUTDOWN: return "SHUTDOWN";
+ case GrillState::ERROR: return "ERROR";
+ }
+ return "?";
+}
+
+void GrillController::begin(Outputs* out, TempSensor* temp) {
+ out_ = out;
+ temp_ = temp;
+ pid_.setGains(pid_defaults::KP, pid_defaults::KI, pid_defaults::KD);
+ pid_.setOutputLimits(0.0f, 1.0f);
+ enter(GrillState::OFF);
+}
+
+void GrillController::enter(GrillState s) {
+ state_ = s;
+ stateEnterMs_ = millis();
+ augerWindowStartMs_ = stateEnterMs_;
+ fanWindowStartMs_ = stateEnterMs_;
+ if (s == GrillState::OFF || s == GrillState::ERROR) {
+ lit_ = false;
+ }
+}
+
+void GrillController::setSetpoint(float f) {
+ if (f < TEMP_MIN_F) f = TEMP_MIN_F;
+ if (f > TEMP_MAX_F) f = TEMP_MAX_F;
+ setpointF_ = f;
+}
+
+void GrillController::start() {
+ if (isActive()) return;
+ if (state_ == GrillState::ERROR) return; // must clearError() first
+ if (!sensorHealthy()) { emergencyStop("no grill temp sensor at start"); return; }
+ igniteStartMs_ = millis();
+ igniteBaseTempF_ = temp_->grillTempF();
+ flameoutSinceMs_ = 0;
+ pid_.reset(temp_->grillTempF());
+ enter(GrillState::IGNITING);
+}
+
+void GrillController::shutdown() {
+ if (state_ == GrillState::OFF || state_ == GrillState::ERROR) return;
+ out_->setAuger(false);
+ out_->setIgniter(false);
+ enter(GrillState::SHUTDOWN);
+}
+
+void GrillController::emergencyStop(const char* reason) {
+ errorReason_ = reason;
+ out_->setAuger(false);
+ out_->setIgniter(false);
+ // Leave the fan running β a hot firepot needs airflow to cool safely.
+ out_->setFan(true);
+ enter(GrillState::ERROR);
+}
+
+void GrillController::clearError() {
+ if (state_ != GrillState::ERROR) return;
+ // Only allow leaving ERROR once the grill is cool and the sensor is back.
+ if (sensorHealthy() && (isnan(temp_->grillTempF()) == false) &&
+ temp_->grillTempF() < control::SHUTDOWN_COOL_F) {
+ out_->allOff();
+ errorReason_ = "";
+ enter(GrillState::OFF);
+ }
+}
+
+bool GrillController::sensorHealthy() const {
+ if (!temp_) return false;
+ if (isnan(temp_->grillTempF())) return false;
+ if (millis() - temp_->lastGoodGrillMs() > safety::SENSOR_STALE_MS) return false;
+ return true;
+}
+
+// --- auger / fan drive -----------------------------------------------------
+// On D2 (variable-speed DC) grills the auger and fan are driven continuously at
+// a speed proportional to demand β this is what "Direct Drive" buys you and it
+// holds temperature far tighter than pulsed feeding. On legacy relay/SSR grills
+// the same demand is expressed as an on/off duty over a fixed window.
+
+void GrillController::applyAuger(float duty) {
+ curAugerDuty_ = duty;
+#if DRIVE_DC_PWM
+ float speed = duty;
+ if (speed > 0.0f && speed < AUGER_MIN_SPEED) speed = AUGER_MIN_SPEED;
+ out_->setAugerSpeed(speed);
+#else
+ uint32_t now = millis();
+ uint32_t elapsed = now - augerWindowStartMs_;
+ if (elapsed >= control::AUGER_WINDOW_MS) { augerWindowStartMs_ = now; elapsed = 0; }
+ uint32_t onMs = (uint32_t)(duty * control::AUGER_WINDOW_MS);
+ if (duty > 0.0f && onMs < control::AUGER_MIN_ON_MS) onMs = control::AUGER_MIN_ON_MS;
+ out_->setAuger(elapsed < onMs);
+#endif
+}
+
+void GrillController::applyFan(float duty) {
+ curFanDuty_ = duty;
+#if DRIVE_DC_PWM
+ float speed = duty;
+ if (speed > 0.0f && speed < FAN_MIN_SPEED) speed = FAN_MIN_SPEED;
+ out_->setFanSpeed(speed);
+#else
+ if (duty >= 0.999f) { out_->setFan(true); return; }
+ uint32_t now = millis();
+ uint32_t elapsed = now - fanWindowStartMs_;
+ if (elapsed >= FAN_WINDOW_MS) { fanWindowStartMs_ = now; elapsed = 0; }
+ uint32_t onMs = (uint32_t)(duty * FAN_WINDOW_MS);
+ out_->setFan(elapsed < onMs);
+#endif
+}
+
+// --- main update -----------------------------------------------------------
+
+void GrillController::update() {
+ uint32_t now = millis();
+ if (now - lastTickMs_ < control::TICK_MS) return;
+ float dtS = (lastTickMs_ == 0) ? (control::TICK_MS / 1000.0f)
+ : (now - lastTickMs_) / 1000.0f;
+ lastTickMs_ = now;
+
+ float grill = temp_ ? temp_->grillTempF() : NAN;
+
+ // Global safety checks that apply whenever a fire could be burning.
+ if (state_ == GrillState::IGNITING || state_ == GrillState::RUNNING) {
+ if (!sensorHealthy()) { emergencyStop("grill temp sensor lost"); }
+ else if (grill > safety::GRILL_OVERTEMP_F) { emergencyStop("over-temperature cutoff"); }
+ }
+
+ switch (state_) {
+ case GrillState::OFF: runOff(); break;
+ case GrillState::IGNITING: runIgniting(dtS); break;
+ case GrillState::RUNNING: runRunning(dtS); break;
+ case GrillState::SHUTDOWN: runShutdown(); break;
+ case GrillState::ERROR: runError(); break;
+ }
+
+ // Publish status snapshot.
+ status_.state = state_;
+ status_.setpointF = setpointF_;
+ status_.grillTempF = grill;
+ status_.augerDuty = curAugerDuty_;
+ status_.augerOn = out_->auger();
+ status_.igniterOn = out_->igniter();
+ status_.fanOn = out_->fan();
+ status_.superSmoke = superSmoke_ && setpointF_ <= pid_defaults::SUPERSMOKE_MAX_F;
+ status_.lit = lit_;
+ status_.secondsInState = (now - stateEnterMs_) / 1000;
+ status_.errorReason = errorReason_;
+}
+
+void GrillController::runOff() {
+ out_->allOff();
+ curAugerDuty_ = 0.0f;
+}
+
+void GrillController::runIgniting(float dtS) {
+ (void)dtS;
+ uint32_t now = millis();
+ uint32_t sinceStart = now - igniteStartMs_;
+
+ applyFan(1.0f); // full airflow to establish the fire
+ // Igniter energised, but never past its hard time cap.
+ out_->setIgniter(sinceStart < control::IGNITER_MAX_MS);
+
+#if DRIVE_DC_PWM
+ // Brief reverse kick first to clear any pellet jam before priming (D2 augers
+ // are reversible; this is what the stock controller does on start).
+ if (sinceStart < UNJAM_MS) {
+ out_->setAugerSpeed(0.8f, /*reverse=*/true);
+ return;
+ }
+#endif
+ if (sinceStart < control::PRIME_MS) {
+ applyAuger(1.0f); // fast prime to fill the firepot (TurboTemp)
+ } else {
+ applyAuger(IGNITE_AUGER_DUTY); // steady feed to build the fire
+ }
+
+ // Confirm a lit fire: temperature has risen meaningfully from the cold start.
+ float grill = temp_->grillTempF();
+ float base = isnan(igniteBaseTempF_) ? 80.0f : igniteBaseTempF_;
+ if (!isnan(grill) && grill >= base + safety::IGNITE_RISE_F) {
+ lit_ = true;
+ out_->setIgniter(false);
+ pid_.reset(grill);
+ flameoutSinceMs_ = 0;
+ enter(GrillState::RUNNING);
+ return;
+ }
+
+ if (sinceStart > safety::IGNITE_TIMEOUT_S * 1000UL) {
+ emergencyStop("ignition failed (no temperature rise)");
+ }
+}
+
+void GrillController::runRunning(float dtS) {
+ float grill = temp_->grillTempF();
+ bool smoke = superSmoke_ && setpointF_ <= pid_defaults::SUPERSMOKE_MAX_F;
+
+ float duty = pid_.compute(setpointF_, grill, dtS);
+ float fanDuty = 1.0f;
+ if (smoke) {
+ if (duty > pid_defaults::SUPERSMOKE_MAX_DUTY) duty = pid_defaults::SUPERSMOKE_MAX_DUTY;
+ fanDuty = pid_defaults::SUPERSMOKE_FAN_DUTY;
+ }
+ applyAuger(duty);
+ applyFan(fanDuty);
+
+ // Flameout detection: while we are asking for heat but the firepot is going
+ // cold, start a timer. Sustained low temperature means the fire is out.
+ uint32_t now = millis();
+ if (!isnan(grill) && grill < safety::FLAMEOUT_FLOOR_F && setpointF_ > safety::FLAMEOUT_FLOOR_F) {
+ if (flameoutSinceMs_ == 0) flameoutSinceMs_ = now;
+ else if (now - flameoutSinceMs_ > safety::FLAMEOUT_TIMEOUT_S * 1000UL) {
+ emergencyStop("flameout (firepot went cold)");
+ }
+ } else {
+ flameoutSinceMs_ = 0;
+ }
+}
+
+void GrillController::runShutdown() {
+ out_->setAuger(false);
+ out_->setIgniter(false);
+ out_->setFan(true); // keep clearing smoke and cooling
+ curAugerDuty_ = 0.0f;
+ lit_ = false;
+
+ uint32_t inState = millis() - stateEnterMs_;
+ float grill = temp_ ? temp_->grillTempF() : NAN;
+ bool cool = !isnan(grill) && grill < control::SHUTDOWN_COOL_F;
+ if (cool || inState > control::SHUTDOWN_FAN_MS) {
+ out_->allOff();
+ enter(GrillState::OFF);
+ }
+}
+
+void GrillController::runError() {
+ // Auger and igniter are already off (set when we entered ERROR). Keep the
+ // fan running until the firepot is cool, then stop it. State stays latched.
+ out_->setAuger(false);
+ out_->setIgniter(false);
+ curAugerDuty_ = 0.0f;
+ float grill = temp_ ? temp_->grillTempF() : NAN;
+ uint32_t inState = millis() - stateEnterMs_;
+ if ((!isnan(grill) && grill < control::SHUTDOWN_COOL_F) || inState > control::SHUTDOWN_FAN_MS) {
+ out_->setFan(false);
+ }
+}
diff --git a/src/control/GrillController.h b/src/control/GrillController.h
new file mode 100644
index 0000000..90992b4
--- /dev/null
+++ b/src/control/GrillController.h
@@ -0,0 +1,95 @@
+// GrillController.h β the pellet-grill state machine.
+//
+// Responsibilities:
+// * ignition sequence (prime -> igniter + fan -> confirm fire is lit)
+// * steady-state temperature hold via a windowed PID auger duty cycle
+// * Super Smoke mode (capped auger duty + reduced fan for a smoulder)
+// * controlled shutdown (burn out remaining pellets, run fan to cool)
+// * safety supervision (over-temp, ignition failure, flameout, sensor loss)
+//
+// The recipe engine sits ON TOP of this and only ever calls setSetpoint(),
+// setSuperSmoke(), start() and shutdown(). All fire safety lives here so that a
+// broken recipe can never bypass it.
+#pragma once
+#include
+#include "hardware/Outputs.h"
+#include "hardware/TempSensor.h"
+#include "control/Pid.h"
+#include "config.h"
+
+enum class GrillState : uint8_t {
+ OFF, // idle, all loads off
+ IGNITING, // priming + igniter on, waiting for a confirmed temperature rise
+ RUNNING, // fire is lit, PID holding the setpoint
+ SHUTDOWN, // auger off, fan running to burn out and cool
+ ERROR // latched fault; requires clearError()
+};
+
+const char* grillStateName(GrillState s);
+
+struct GrillStatus {
+ GrillState state = GrillState::OFF;
+ float setpointF = 0;
+ float grillTempF = NAN;
+ float augerDuty = 0; // 0..1 commanded for the current window
+ bool augerOn = false;
+ bool igniterOn = false;
+ bool fanOn = false;
+ bool superSmoke = false;
+ bool lit = false;
+ uint32_t secondsInState = 0;
+ const char* errorReason = "";
+};
+
+class GrillController {
+public:
+ void begin(Outputs* out, TempSensor* temp);
+
+ // High-level commands (used by the UI and the recipe engine).
+ void start(); // begin ignition (no-op if already active)
+ void shutdown(); // controlled shutdown/cool-down
+ void emergencyStop(const char* reason); // latch ERROR, kill auger+igniter
+ void clearError(); // ERROR -> OFF (only if the fault has cleared)
+
+ void setSetpoint(float f);
+ void setSuperSmoke(bool on) { superSmoke_ = on; }
+ void setPidGains(float kp, float ki, float kd) { pid_.setGains(kp, ki, kd); }
+
+ void update(); // call every loop; internally rate-limited
+
+ const GrillStatus& status() const { return status_; }
+ bool isActive() const { return state_ == GrillState::IGNITING || state_ == GrillState::RUNNING; }
+
+private:
+ void enter(GrillState s);
+ void runOff();
+ void runIgniting(float dtS);
+ void runRunning(float dtS);
+ void runShutdown();
+ void runError();
+
+ void applyAuger(float duty); // DC: variable speed; relay: windowed on/off
+ void applyFan(float duty); // DC: variable speed; relay: windowed on/off
+ bool sensorHealthy() const;
+
+ Outputs* out_ = nullptr;
+ TempSensor* temp_ = nullptr;
+ Pid pid_;
+
+ GrillState state_ = GrillState::OFF;
+ GrillStatus status_;
+ float setpointF_ = KEEP_WARM_F;
+ bool superSmoke_ = false;
+ bool lit_ = false;
+
+ uint32_t lastTickMs_ = 0;
+ uint32_t stateEnterMs_ = 0;
+ uint32_t augerWindowStartMs_ = 0;
+ uint32_t fanWindowStartMs_ = 0;
+ uint32_t igniteStartMs_ = 0;
+ uint32_t flameoutSinceMs_ = 0;
+ float igniteBaseTempF_ = NAN;
+ float curAugerDuty_ = 0.0f;
+ float curFanDuty_ = 1.0f;
+ const char* errorReason_ = "";
+};
diff --git a/src/control/Pid.cpp b/src/control/Pid.cpp
new file mode 100644
index 0000000..6432d08
--- /dev/null
+++ b/src/control/Pid.cpp
@@ -0,0 +1,43 @@
+#include "Pid.h"
+#include
+
+void Pid::reset(float measurement) {
+ integral_ = 0.0f;
+ prevMeas_ = measurement;
+ primed_ = true;
+}
+
+float Pid::compute(float setpoint, float measurement, float dtSeconds) {
+ if (!primed_ || isnan(prevMeas_)) reset(measurement);
+ if (dtSeconds <= 0.0f) dtSeconds = 0.001f;
+
+ float error = setpoint - measurement;
+
+ // Derivative on measurement (not error) avoids a kick when the setpoint
+ // changes between recipe steps.
+ float dMeas = (measurement - prevMeas_) / dtSeconds;
+ prevMeas_ = measurement;
+
+ float pTerm = kp_ * error;
+ float dTerm = -kd_ * dMeas;
+
+ // Tentative output before committing the integral, so we can decide whether
+ // integrating would only push us further into saturation.
+ float candidateI = integral_ + ki_ * error * dtSeconds;
+ float out = pTerm + candidateI + dTerm;
+
+ bool saturatedHigh = out > outHi_ && error > 0;
+ bool saturatedLow = out < outLo_ && error < 0;
+ if (!saturatedHigh && !saturatedLow) {
+ integral_ = candidateI; // accept integration
+ }
+ // Clamp the stored integral independently so it can never dominate.
+ float iClamp = (outHi_ - outLo_);
+ if (integral_ > iClamp) integral_ = iClamp;
+ if (integral_ < -iClamp) integral_ = -iClamp;
+
+ out = pTerm + integral_ + dTerm;
+ if (out > outHi_) out = outHi_;
+ if (out < outLo_) out = outLo_;
+ return out;
+}
diff --git a/src/control/Pid.h b/src/control/Pid.h
new file mode 100644
index 0000000..36321ea
--- /dev/null
+++ b/src/control/Pid.h
@@ -0,0 +1,26 @@
+// Pid.h β a small, well-behaved PID controller with anti-windup.
+//
+// Output is a normalised duty in [0, 1] (fraction of the auger window to feed
+// pellets). Integral accumulation is clamped and frozen while the output is
+// saturated so we don't wind up during long preheats.
+#pragma once
+#include
+
+class Pid {
+public:
+ void setGains(float kp, float ki, float kd) { kp_ = kp; ki_ = ki; kd_ = kd; }
+ void setOutputLimits(float lo, float hi) { outLo_ = lo; outHi_ = hi; }
+ void reset(float measurement);
+
+ // dtSeconds is the elapsed time since the previous compute() call.
+ float compute(float setpoint, float measurement, float dtSeconds);
+
+ float integral() const { return integral_; }
+
+private:
+ float kp_ = 0, ki_ = 0, kd_ = 0;
+ float outLo_ = 0.0f, outHi_ = 1.0f;
+ float integral_ = 0.0f;
+ float prevMeas_ = NAN;
+ bool primed_ = false;
+};
diff --git a/src/hardware/Outputs.cpp b/src/hardware/Outputs.cpp
new file mode 100644
index 0000000..419c00a
--- /dev/null
+++ b/src/hardware/Outputs.cpp
@@ -0,0 +1,68 @@
+#include "Outputs.h"
+
+static inline uint32_t dutyMax() { return (1u << PWM_RES_BITS) - 1u; }
+
+void Outputs::writeOnOff(uint8_t pin, bool logicalOn) {
+#if RELAY_ACTIVE_LOW
+ digitalWrite(pin, logicalOn ? LOW : HIGH);
+#else
+ digitalWrite(pin, logicalOn ? HIGH : LOW);
+#endif
+}
+
+void Outputs::begin() {
+ pinMode(PIN_IGNITER, OUTPUT);
+ pinMode(PIN_AUX, OUTPUT);
+ pinMode(PIN_BUZZER, OUTPUT);
+
+#if DRIVE_DC_PWM
+ pinMode(PIN_AUGER_DIR, OUTPUT);
+ digitalWrite(PIN_AUGER_DIR, LOW); // forward = feed
+ ledcSetup(PWM_CH_AUGER, PWM_FREQ_HZ, PWM_RES_BITS);
+ ledcSetup(PWM_CH_FAN, PWM_FREQ_HZ, PWM_RES_BITS);
+ ledcAttachPin(PIN_AUGER, PWM_CH_AUGER);
+ ledcAttachPin(PIN_FAN, PWM_CH_FAN);
+#else
+ pinMode(PIN_AUGER, OUTPUT);
+ pinMode(PIN_FAN, OUTPUT);
+#endif
+ allOff();
+}
+
+void Outputs::setAugerSpeed(float speed, bool reverse) {
+ if (speed < 0) speed = 0;
+ if (speed > 1) speed = 1;
+ augerSpeed_ = speed;
+ augerReverse_ = reverse;
+#if DRIVE_DC_PWM
+ digitalWrite(PIN_AUGER_DIR, reverse ? HIGH : LOW);
+ ledcWrite(PWM_CH_AUGER, (uint32_t)(speed * dutyMax()));
+#else
+ writeOnOff(PIN_AUGER, speed > 0.0f); // relay grills: on/off only
+#endif
+}
+
+void Outputs::setFanSpeed(float speed) {
+ if (speed < 0) speed = 0;
+ if (speed > 1) speed = 1;
+ fanSpeed_ = speed;
+#if DRIVE_DC_PWM
+ ledcWrite(PWM_CH_FAN, (uint32_t)(speed * dutyMax()));
+#else
+ writeOnOff(PIN_FAN, speed > 0.0f);
+#endif
+}
+
+void Outputs::setIgniter(bool on) { igniter_ = on; writeOnOff(PIN_IGNITER, on); }
+void Outputs::setAux(bool on) { aux_ = on; writeOnOff(PIN_AUX, on); }
+
+void Outputs::setBuzzer(bool on) {
+ digitalWrite(PIN_BUZZER, on ? HIGH : LOW); // plain active-high device
+}
+
+void Outputs::allOff() {
+ setAugerSpeed(0.0f);
+ setIgniter(false);
+ setFanSpeed(0.0f);
+ setAux(false);
+}
diff --git a/src/hardware/Outputs.h b/src/hardware/Outputs.h
new file mode 100644
index 0000000..dd634da
--- /dev/null
+++ b/src/hardware/Outputs.h
@@ -0,0 +1,49 @@
+// Outputs.h β safe wrapper over the load outputs.
+//
+// Two drive models are supported (selected by DRIVE_DC_PWM in pins.h):
+// * DC variable-speed (Traeger D2): the auger and fan are PWM speed signals
+// into motor drivers; the auger has a direction line so it can reverse to
+// clear a jam. The igniter is a switched DC resistive load.
+// * Relay/SSR on-off (legacy AC grills): auger/fan are simply on or off.
+//
+// This class knows nothing about grill logic. It guarantees that every load is
+// OFF at boot, applies output polarity in one place, and offers allOff() as a
+// single fallback the safety layer can always call.
+#pragma once
+#include
+#include "pins.h"
+
+class Outputs {
+public:
+ void begin();
+
+ // Variable-speed API (speed 0..1). In relay mode any speed > 0 == ON.
+ void setAugerSpeed(float speed, bool reverse = false);
+ void setFanSpeed(float speed);
+
+ // Convenience on/off API (maps to full speed).
+ void setAuger(bool on) { setAugerSpeed(on ? 1.0f : 0.0f); }
+ void setFan(bool on) { setFanSpeed(on ? 1.0f : 0.0f); }
+ void setIgniter(bool on);
+ void setAux(bool on);
+ void setBuzzer(bool on);
+
+ bool auger() const { return augerSpeed_ > 0.0f; }
+ float augerSpeed() const { return augerSpeed_; }
+ bool augerReverse() const { return augerReverse_; }
+ bool igniter() const { return igniter_; }
+ bool fan() const { return fanSpeed_ > 0.0f; }
+ float fanSpeed() const { return fanSpeed_; }
+
+ // Kill everything that could sustain combustion. Safe to call any time.
+ void allOff();
+
+private:
+ void writeOnOff(uint8_t pin, bool logicalOn);
+
+ float augerSpeed_ = 0.0f;
+ bool augerReverse_ = false;
+ float fanSpeed_ = 0.0f;
+ bool igniter_ = false;
+ bool aux_ = false;
+};
diff --git a/src/hardware/TempSensor.cpp b/src/hardware/TempSensor.cpp
new file mode 100644
index 0000000..d5794b9
--- /dev/null
+++ b/src/hardware/TempSensor.cpp
@@ -0,0 +1,79 @@
+#include "TempSensor.h"
+#include
+
+const uint8_t TempSensor::probePins_[PROBE_COUNT] = {
+ PIN_PROBE_1,
+#if PROBE_COUNT > 1
+ PIN_PROBE_2,
+#endif
+};
+
+TempSensor::TempSensor(float rNominal, float rRef)
+ : rtd_(PIN_RTD_CS, PIN_SPI_MOSI, PIN_SPI_MISO, PIN_SPI_SCK),
+ rNominal_(rNominal), rRef_(rRef) {
+ for (uint8_t i = 0; i < PROBE_COUNT; i++) probeF_[i] = NAN;
+}
+
+void TempSensor::begin() {
+ rtd_.begin(MAX31865_3WIRE); // change to _2WIRE / _4WIRE to match your RTD
+ analogReadResolution(12); // 0..4095
+ // 11 dB attenuation gives a usable range up to ~3.1 V on the ESP32 ADC.
+ analogSetPinAttenuation(PIN_PROBE_1, ADC_11db);
+#if PROBE_COUNT > 1
+ analogSetPinAttenuation(PIN_PROBE_2, ADC_11db);
+#endif
+}
+
+void TempSensor::update() {
+ uint16_t rtdRaw = rtd_.readRTD();
+ rtdFault_ = rtd_.readFault();
+ if (rtdFault_) {
+ rtd_.clearFault();
+ grillF_ = NAN;
+ } else {
+ float ratio = rtdRaw / 32768.0f;
+ float resistance = ratio * rRef_;
+ float c = rtd_.temperature(rNominal_, rRef_); // CallendarβVan Dusen
+ (void)resistance;
+ grillF_ = c * 9.0f / 5.0f + 32.0f + grillOffsetF_;
+ lastGrillMs_ = millis();
+ }
+
+ for (uint8_t i = 0; i < PROBE_COUNT; i++) {
+ probeF_[i] = readProbeF(i);
+ }
+}
+
+float TempSensor::readProbeF(uint8_t idx) {
+ // Average a handful of samples to tame ADC noise.
+ uint32_t acc = 0;
+ const int N = 8;
+ for (int i = 0; i < N; i++) acc += analogRead(probePins_[idx]);
+ float raw = acc / (float)N;
+
+ // Open circuit reads near full-scale (pulled to 3V3 through the divider).
+ if (raw >= 4085.0f || raw <= 8.0f) return NAN;
+
+ const ProbeConfig& p = probeCfg_[idx];
+ // Divider: 3V3 -- Rseries -- (node=ADC) -- thermistor -- GND
+ // Vadc/Vref = Rtherm / (Rseries + Rtherm) => Rtherm = Rs * ratio/(1-ratio)
+ float ratio = raw / 4095.0f;
+ if (ratio >= 0.999f) return NAN;
+ float rTherm = p.seriesResistor * (ratio / (1.0f - ratio));
+
+ // Beta model: 1/T = 1/T0 + (1/B) ln(R/R0)
+ float steinhart = logf(rTherm / p.nominalR) / p.betaCoeff;
+ steinhart += 1.0f / (p.nominalTempC + 273.15f);
+ float tempC = 1.0f / steinhart - 273.15f;
+ return tempC * 9.0f / 5.0f + 32.0f + p.offsetF;
+}
+
+float TempSensor::probeTempF(uint8_t idx) const {
+ if (idx >= PROBE_COUNT) return NAN;
+ return probeF_[idx];
+}
+
+bool TempSensor::probeConnected(uint8_t idx) const {
+ if (idx >= PROBE_COUNT) return false;
+ return !isnan(probeF_[idx]);
+}
diff --git a/src/hardware/TempSensor.h b/src/hardware/TempSensor.h
new file mode 100644
index 0000000..a420048
--- /dev/null
+++ b/src/hardware/TempSensor.h
@@ -0,0 +1,52 @@
+// TempSensor.h β grill RTD (via MAX31865) plus NTC meat probes (via ADC).
+//
+// Readings are exposed in Fahrenheit. A reading of NAN means "no valid sensor"
+// (open circuit, short, or out of physical range); callers must treat NAN as a
+// fault, never as a number.
+#pragma once
+#include
+#include
+#include "pins.h"
+
+struct ProbeConfig {
+ // SteinhartβHart / beta-model parameters for a typical 100k food probe.
+ float seriesResistor = 100000.0f; // divider resistor to 3V3 (ohms)
+ float nominalR = 100000.0f; // thermistor R at nominalTempC (ohms)
+ float nominalTempC = 25.0f;
+ float betaCoeff = 3950.0f;
+ float offsetF = 0.0f; // per-probe calibration trim
+};
+
+class TempSensor {
+public:
+ // rNominal/rRef are the RTD nominal and reference resistances. For a PT1000
+ // amplifier board the standard values are 1000.0 / 4300.0.
+ explicit TempSensor(float rNominal = 1000.0f, float rRef = 4300.0f);
+
+ void begin();
+ void update(); // sample all channels; call each tick
+
+ float grillTempF() const { return grillF_; } // NAN on fault
+ float probeTempF(uint8_t idx) const; // NAN if unplugged/fault
+ bool probeConnected(uint8_t idx) const;
+
+ uint8_t rtdFault() const { return rtdFault_; } // MAX31865 fault bits, 0 = ok
+ uint32_t lastGoodGrillMs() const { return lastGrillMs_; }
+
+ void setGrillOffsetF(float f) { grillOffsetF_ = f; }
+ ProbeConfig& probe(uint8_t idx) { return probeCfg_[idx]; }
+
+private:
+ float readProbeF(uint8_t idx);
+
+ Adafruit_MAX31865 rtd_;
+ float rNominal_, rRef_;
+ float grillF_ = NAN;
+ float grillOffsetF_ = 0.0f;
+ uint8_t rtdFault_ = 0;
+ uint32_t lastGrillMs_ = 0;
+
+ float probeF_[PROBE_COUNT];
+ ProbeConfig probeCfg_[PROBE_COUNT];
+ static const uint8_t probePins_[PROBE_COUNT];
+};
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..c62ed2d
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,89 @@
+// main.cpp β OpenEmber firmware entry point.
+//
+// Boot order matters for safety: outputs are forced OFF before anything else,
+// then sensors come up, then the controller, and only then networking. The
+// control loop runs continuously and is never blocked by network I/O (the web
+// server is async and MQTT/Wi-Fi work is non-blocking).
+#include
+#include "config.h"
+#include "pins.h"
+#include "hardware/Outputs.h"
+#include "hardware/TempSensor.h"
+#include "control/GrillController.h"
+#include "recipe/RecipeEngine.h"
+#include "storage/Storage.h"
+#include "net/WifiManager.h"
+#include "net/WebServer.h"
+#include "net/MqttClient.h"
+
+static Outputs outputs;
+static TempSensor tempSensor; // defaults: PT1000 RTD (1000/4300)
+static GrillController grill;
+static RecipeEngine engine;
+static Storage storage;
+static WifiManager wifi;
+static WebUi web;
+static MqttClient mqtt;
+static DeviceConfig config;
+
+static uint32_t lastSensorMs = 0;
+static uint32_t lastBeatMs = 0;
+
+void setup() {
+ Serial.begin(115200);
+ delay(50);
+ Serial.printf("\nOpenEmber %s booting\n", OPENEMBER_VERSION);
+
+ // 1) Loads OFF first β nothing should energise before we are in control.
+ outputs.begin();
+ pinMode(PIN_STATUS_LED, OUTPUT);
+
+ // 2) Persistence + config (falls back to compile-time defaults).
+ storage.begin();
+ storage.loadConfig(config);
+
+ // 3) Sensors.
+ tempSensor.begin();
+ tempSensor.setGrillOffsetF(config.grillOffsetF);
+
+ // 4) Controller with configured (or default) PID gains.
+ grill.begin(&outputs, &tempSensor);
+ grill.setPidGains(config.kp ? config.kp : pid_defaults::KP,
+ config.ki ? config.ki : pid_defaults::KI,
+ config.kd ? config.kd : pid_defaults::KD);
+
+ // 5) Recipe engine + resume an interrupted cook (comes back PAUSED).
+ engine.begin(&grill, &tempSensor, &storage);
+ if (engine.restoreFromStorage())
+ Serial.println("Restored an interrupted cook (paused β confirm to resume)");
+
+ // 6) Networking last.
+ wifi.begin(config);
+ web.begin(&grill, &engine, &tempSensor, &storage, &wifi, &config);
+ mqtt.begin(&grill, &engine, &tempSensor, &storage, &wifi, &config);
+
+ Serial.printf("Wi-Fi %s http://%s/\n",
+ wifi.modeString().c_str(), wifi.ip().toString().c_str());
+}
+
+void loop() {
+ uint32_t now = millis();
+
+ // Sample sensors at ~2 Hz (SPI + ADC); the controller consumes the latest.
+ if (now - lastSensorMs >= 500) {
+ lastSensorMs = now;
+ tempSensor.update();
+ }
+
+ grill.update(); // internally rate-limited to control::TICK_MS
+ engine.update();
+ wifi.loop();
+ mqtt.loop();
+
+ // Heartbeat: slow blink normally, fast blink on a latched fault.
+ uint32_t beat = (grill.status().state == GrillState::ERROR) ? 150 : 1000;
+ if (now - lastBeatMs >= beat) {
+ lastBeatMs = now;
+ digitalWrite(PIN_STATUS_LED, !digitalRead(PIN_STATUS_LED));
+ }
+}
diff --git a/src/net/MqttClient.cpp b/src/net/MqttClient.cpp
new file mode 100644
index 0000000..09695c0
--- /dev/null
+++ b/src/net/MqttClient.cpp
@@ -0,0 +1,160 @@
+#include "net/MqttClient.h"
+#include "net/StatusJson.h"
+#include
+
+void MqttClient::begin(GrillController* grill, RecipeEngine* engine, TempSensor* temp,
+ Storage* store, WifiManager* wifi, DeviceConfig* cfg) {
+ grill_ = grill; engine_ = engine; temp_ = temp;
+ store_ = store; wifi_ = wifi; cfg_ = cfg;
+ enabled_ = cfg_->mqttHost.length() > 0;
+ if (!enabled_) return;
+ mqtt_.setBufferSize(1536);
+ mqtt_.setServer(cfg_->mqttHost.c_str(), cfg_->mqttPort);
+ mqtt_.setCallback([this](char* t, uint8_t* p, unsigned int l) { onMessage(t, p, l); });
+}
+
+void MqttClient::loop() {
+ if (!enabled_ || !wifi_->connected()) return;
+ if (!mqtt_.connected()) {
+ if (millis() - lastReconnectMs_ < 5000) return;
+ lastReconnectMs_ = millis();
+ if (!reconnect()) return;
+ }
+ mqtt_.loop();
+ if (millis() - lastPublishMs_ > 3000) {
+ lastPublishMs_ = millis();
+ publishState();
+ }
+}
+
+bool MqttClient::reconnect() {
+ String avail = base() + "/availability";
+ bool connected = mqtt_.connect(
+ base().c_str(),
+ cfg_->mqttUser.length() ? cfg_->mqttUser.c_str() : nullptr,
+ cfg_->mqttPass.length() ? cfg_->mqttPass.c_str() : nullptr,
+ avail.c_str(), 0, true, "offline");
+ if (!connected) return false;
+
+ mqtt_.publish(avail.c_str(), "online", true);
+ String cmd = base() + "/cmd/#";
+ mqtt_.subscribe(cmd.c_str());
+ if (cfg_->haDiscovery && !discoverySent_) { publishDiscovery(); discoverySent_ = true; }
+ return true;
+}
+
+void MqttClient::onMessage(char* topic, uint8_t* payload, unsigned int len) {
+ String t(topic);
+ String msg;
+ msg.reserve(len);
+ for (unsigned int i = 0; i < len; i++) msg += (char)payload[i];
+ msg.trim();
+
+ String b = base();
+ if (t == b + "/cmd/setpoint") {
+ float v = msg.toFloat();
+ if (v > 0 && engine_->status().state != CookState::RUNNING) grill_->setSetpoint(v);
+ } else if (t == b + "/cmd/supersmoke") {
+ if (engine_->status().state != CookState::RUNNING)
+ grill_->setSuperSmoke(msg == "on" || msg == "true" || msg == "1");
+ } else if (t == b + "/cmd/power") {
+ if (msg == "start") grill_->start();
+ else if (msg == "shutdown") grill_->shutdown();
+ else if (msg == "clear") grill_->clearError();
+ } else if (t == b + "/cmd/cook") {
+ if (msg == "stop") engine_->stop();
+ else if (msg == "resume") engine_->resume();
+ else if (msg == "next") engine_->nextStep();
+ else if (msg == "cancel") engine_->cancel();
+ else {
+ // Treat the payload as a recipe id: load and begin immediately.
+ Recipe r;
+ if (store_->loadRecipe(msg.c_str(), r) && engine_->load(r)) engine_->beginCook();
+ }
+ }
+}
+
+void MqttClient::publishState() {
+ JsonDocument doc;
+ buildStatusJson(doc.to(), *grill_, *engine_, *temp_, *wifi_);
+ String out;
+ serializeJson(doc, out);
+ String topic = base() + "/state";
+ mqtt_.publish(topic.c_str(), out.c_str(), true);
+}
+
+// Publish a minimal set of Home Assistant MQTT-discovery configs so the grill,
+// its setpoint, Super Smoke, and probes appear automatically. Values are read
+// from the retained /state JSON via value_template.
+void MqttClient::publishDiscovery() {
+ String b = base();
+ String stateTopic = b + "/state";
+ String availTopic = b + "/availability";
+
+ auto device = [&](JsonObject dev) {
+ JsonArray ids = dev["ids"].to();
+ ids.add(b);
+ dev["name"] = "OpenEmber Grill";
+ dev["mf"] = "OpenEmber";
+ dev["mdl"] = "ESP32 Pellet Controller";
+ dev["sw"] = OPENEMBER_VERSION;
+ };
+
+ auto publishCfg = [&](const String& component, const String& objId, JsonDocument& doc) {
+ doc["avty_t"] = availTopic;
+ device(doc["dev"].to());
+ String topic = "homeassistant/" + component + "/" + b + "/" + objId + "/config";
+ String out; serializeJson(doc, out);
+ mqtt_.publish(topic.c_str(), out.c_str(), true);
+ };
+
+ { // grill temperature sensor
+ JsonDocument d;
+ d["name"] = "Grill Temperature";
+ d["uniq_id"] = b + "_grill_temp";
+ d["stat_t"] = stateTopic;
+ d["unit_of_meas"] = "Β°F";
+ d["dev_cla"] = "temperature";
+ d["val_tpl"] = "{{ value_json.grill.tempF }}";
+ publishCfg("sensor", "grill_temp", d);
+ }
+ { // grill state
+ JsonDocument d;
+ d["name"] = "Grill State";
+ d["uniq_id"] = b + "_grill_state";
+ d["stat_t"] = stateTopic;
+ d["val_tpl"] = "{{ value_json.grill.state }}";
+ publishCfg("sensor", "grill_state", d);
+ }
+ for (uint8_t i = 0; i < PROBE_COUNT; i++) {
+ JsonDocument d;
+ d["name"] = String("Meat Probe ") + (i + 1);
+ d["uniq_id"] = b + "_probe" + i;
+ d["stat_t"] = stateTopic;
+ d["unit_of_meas"] = "Β°F";
+ d["dev_cla"] = "temperature";
+ d["val_tpl"] = String("{{ value_json.probes[") + i + "].tempF }}";
+ publishCfg("sensor", String("probe") + i, d);
+ }
+ { // setpoint as a HA number entity
+ JsonDocument d;
+ d["name"] = "Grill Setpoint";
+ d["uniq_id"] = b + "_setpoint";
+ d["stat_t"] = stateTopic;
+ d["cmd_t"] = b + "/cmd/setpoint";
+ d["val_tpl"] = "{{ value_json.grill.setpointF }}";
+ d["min"] = (int)TEMP_MIN_F; d["max"] = (int)TEMP_MAX_F; d["step"] = 5;
+ d["unit_of_meas"] = "Β°F";
+ publishCfg("number", "setpoint", d);
+ }
+ { // Super Smoke switch
+ JsonDocument d;
+ d["name"] = "Super Smoke";
+ d["uniq_id"] = b + "_supersmoke";
+ d["stat_t"] = stateTopic;
+ d["cmd_t"] = b + "/cmd/supersmoke";
+ d["val_tpl"] = "{{ 'on' if value_json.grill.superSmoke else 'off' }}";
+ d["pl_on"] = "on"; d["pl_off"] = "off";
+ publishCfg("switch", "supersmoke", d);
+ }
+}
diff --git a/src/net/MqttClient.h b/src/net/MqttClient.h
new file mode 100644
index 0000000..4721f96
--- /dev/null
+++ b/src/net/MqttClient.h
@@ -0,0 +1,48 @@
+// MqttClient.h β optional MQTT bridge with Home Assistant auto-discovery.
+//
+// Topics (base = hostname, default "openember"):
+// /state retained JSON status (same shape as GET /api/status)
+// /availability "online" / "offline" (LWT)
+// /cmd/setpoint payload: temperature in Β°F
+// /cmd/supersmoke payload: "on" / "off"
+// /cmd/power payload: "start" / "shutdown"
+// /cmd/cook payload: "" to load+begin, "stop"/"resume"/"next"
+//
+// MQTT is disabled when cfg.mqttHost is empty.
+#pragma once
+#include
+#include
+#include
+#include "control/GrillController.h"
+#include "recipe/RecipeEngine.h"
+#include "hardware/TempSensor.h"
+#include "storage/Storage.h"
+#include "net/WifiManager.h"
+
+class MqttClient {
+public:
+ void begin(GrillController* grill, RecipeEngine* engine, TempSensor* temp,
+ Storage* store, WifiManager* wifi, DeviceConfig* cfg);
+ void loop();
+
+private:
+ bool reconnect();
+ void onMessage(char* topic, uint8_t* payload, unsigned int len);
+ void publishState();
+ void publishDiscovery();
+ String base() const { return cfg_ ? cfg_->hostname : String("openember"); }
+
+ WiFiClient net_;
+ PubSubClient mqtt_{net_};
+ GrillController* grill_ = nullptr;
+ RecipeEngine* engine_ = nullptr;
+ TempSensor* temp_ = nullptr;
+ Storage* store_ = nullptr;
+ WifiManager* wifi_ = nullptr;
+ DeviceConfig* cfg_ = nullptr;
+
+ bool enabled_ = false;
+ bool discoverySent_ = false;
+ uint32_t lastPublishMs_ = 0;
+ uint32_t lastReconnectMs_ = 0;
+};
diff --git a/src/net/StatusJson.cpp b/src/net/StatusJson.cpp
new file mode 100644
index 0000000..9ea6998
--- /dev/null
+++ b/src/net/StatusJson.cpp
@@ -0,0 +1,68 @@
+#include "net/StatusJson.h"
+#include
+
+static void putTemp(JsonObject o, const char* key, float f) {
+ if (isnan(f)) o[key] = nullptr;
+ else o[key] = roundf(f * 10) / 10.0f;
+}
+
+void buildStatusJson(JsonObject root,
+ GrillController& grill,
+ RecipeEngine& engine,
+ TempSensor& temp,
+ WifiManager& wifi) {
+ root["fw"] = OPENEMBER_VERSION;
+ root["uptimeS"] = (uint32_t)(millis() / 1000);
+
+ // --- grill ---
+ const GrillStatus& g = grill.status();
+ JsonObject gr = root["grill"].to();
+ gr["state"] = grillStateName(g.state);
+ putTemp(gr, "tempF", g.grillTempF);
+ gr["setpointF"] = g.setpointF;
+ gr["augerOn"] = g.augerOn;
+ gr["augerDuty"] = roundf(g.augerDuty * 100) / 100.0f;
+ gr["igniterOn"] = g.igniterOn;
+ gr["fanOn"] = g.fanOn;
+ gr["superSmoke"] = g.superSmoke;
+ gr["lit"] = g.lit;
+ gr["secondsInState"] = g.secondsInState;
+ if (g.state == GrillState::ERROR) gr["error"] = g.errorReason;
+
+ // --- probes ---
+ JsonArray probes = root["probes"].to();
+ for (uint8_t i = 0; i < PROBE_COUNT; i++) {
+ JsonObject p = probes.add();
+ p["index"] = i;
+ p["connected"] = temp.probeConnected(i);
+ putTemp(p, "tempF", temp.probeTempF(i));
+ }
+
+ // --- cook (Make Now) ---
+ const CookStatus& c = engine.status();
+ JsonObject co = root["cook"].to();
+ co["state"] = cookStateName(c.state);
+ if (engine.hasRecipe()) {
+ co["recipe"] = c.recipeName;
+ co["stepIndex"] = c.stepIndex;
+ co["stepCount"] = c.stepCount;
+ co["stepName"] = c.stepName;
+ co["stepSetpointF"] = c.stepSetpointF;
+ co["stepSuperSmoke"] = c.stepSuperSmoke;
+ co["advanceType"] = c.advanceType;
+ co["stepProgress"] = roundf(c.stepProgress * 100) / 100.0f;
+ co["stepElapsedS"] = c.stepElapsedS;
+ co["stepRemainingS"] = c.stepRemainingS;
+ co["percentComplete"] = c.percentComplete;
+ if (String(c.advanceType) == "probe") {
+ co["probeIndex"] = c.probeIndex;
+ co["probeTargetF"] = c.probeTargetF;
+ }
+ }
+
+ // --- net ---
+ JsonObject n = root["net"].to();
+ n["mode"] = wifi.modeString();
+ n["connected"] = wifi.connected();
+ n["ip"] = wifi.ip().toString();
+}
diff --git a/src/net/StatusJson.h b/src/net/StatusJson.h
new file mode 100644
index 0000000..6fef43c
--- /dev/null
+++ b/src/net/StatusJson.h
@@ -0,0 +1,17 @@
+// StatusJson.h β single source of truth for the device status payload.
+//
+// Both the REST API (GET /api/status) and the MQTT state topic serialise the
+// exact same structure, so a client can subscribe to either transport and get
+// identical fields.
+#pragma once
+#include
+#include "control/GrillController.h"
+#include "recipe/RecipeEngine.h"
+#include "hardware/TempSensor.h"
+#include "net/WifiManager.h"
+
+void buildStatusJson(JsonObject root,
+ GrillController& grill,
+ RecipeEngine& engine,
+ TempSensor& temp,
+ WifiManager& wifi);
diff --git a/src/net/WebServer.cpp b/src/net/WebServer.cpp
new file mode 100644
index 0000000..7badc90
--- /dev/null
+++ b/src/net/WebServer.cpp
@@ -0,0 +1,191 @@
+#include "net/WebServer.h"
+#include "net/StatusJson.h"
+#include "config.h"
+#include
+#include
+#include
+
+WebUi::WebUi() {}
+
+static void sendJson(AsyncWebServerRequest* req, int code, JsonDocument& doc) {
+ String out;
+ serializeJson(doc, out);
+ req->send(code, "application/json", out);
+}
+
+static void ok(AsyncWebServerRequest* req, const char* msg = "ok") {
+ JsonDocument d; d["ok"] = true; d["msg"] = msg;
+ sendJson(req, 200, d);
+}
+static void fail(AsyncWebServerRequest* req, int code, const char* msg) {
+ JsonDocument d; d["ok"] = false; d["error"] = msg;
+ sendJson(req, code, d);
+}
+
+void WebUi::begin(GrillController* grill, RecipeEngine* engine, TempSensor* temp,
+ Storage* store, WifiManager* wifi, DeviceConfig* cfg) {
+ grill_ = grill; engine_ = engine; temp_ = temp;
+ store_ = store; wifi_ = wifi; cfg_ = cfg;
+ server_ = new AsyncWebServer(net_defaults::HTTP_PORT);
+ routes();
+ server_->begin();
+}
+
+void WebUi::routes() {
+ AsyncWebServer& s = *server_;
+
+ // --- status ---
+ s.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* req) {
+ JsonDocument doc;
+ buildStatusJson(doc.to(), *grill_, *engine_, *temp_, *wifi_);
+ sendJson(req, 200, doc);
+ });
+
+ // --- grill setpoint ---
+ s.addHandler(new AsyncCallbackJsonWebHandler("/api/grill/setpoint",
+ [this](AsyncWebServerRequest* req, JsonVariant json) {
+ float t = json["tempF"] | NAN;
+ if (isnan(t)) return fail(req, 400, "tempF required");
+ // While a Make Now cook owns the grill, manual setpoint is ignored.
+ if (engine_->status().state == CookState::RUNNING)
+ return fail(req, 409, "cook running; controls locked");
+ grill_->setSetpoint(t);
+ ok(req);
+ }));
+
+ s.addHandler(new AsyncCallbackJsonWebHandler("/api/grill/supersmoke",
+ [this](AsyncWebServerRequest* req, JsonVariant json) {
+ if (engine_->status().state == CookState::RUNNING)
+ return fail(req, 409, "cook running; controls locked");
+ grill_->setSuperSmoke(json["on"] | false);
+ ok(req);
+ }));
+
+ s.on("/api/grill/start", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ grill_->start(); ok(req);
+ });
+ s.on("/api/grill/shutdown", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ grill_->shutdown(); ok(req);
+ });
+ s.on("/api/grill/clear", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ grill_->clearError();
+ if (grill_->status().state == GrillState::ERROR)
+ return fail(req, 409, "grill not cool yet or sensor still faulted");
+ ok(req);
+ });
+
+ // --- recipes ---
+ s.on("/api/recipes", HTTP_GET, [this](AsyncWebServerRequest* req) {
+ if (req->hasParam("id")) {
+ Recipe r;
+ if (!store_->loadRecipe(req->getParam("id")->value().c_str(), r))
+ return fail(req, 404, "recipe not found");
+ JsonDocument doc; r.toJson(doc.to());
+ return sendJson(req, 200, doc);
+ }
+ JsonDocument doc;
+ store_->listRecipes(doc.to());
+ sendJson(req, 200, doc);
+ });
+
+ s.addHandler(new AsyncCallbackJsonWebHandler("/api/recipes",
+ [this](AsyncWebServerRequest* req, JsonVariant json) {
+ Recipe r;
+ if (!r.fromJson(json.as()))
+ return fail(req, 400, "invalid recipe (need id + steps, <=16 steps)");
+ if (!store_->saveRecipe(r)) return fail(req, 500, "save failed");
+ ok(req, r.id);
+ }));
+
+ s.addHandler(new AsyncCallbackJsonWebHandler("/api/recipes/delete",
+ [this](AsyncWebServerRequest* req, JsonVariant json) {
+ const char* id = json["id"] | "";
+ if (!id[0] || !store_->deleteRecipe(id)) return fail(req, 404, "not found");
+ ok(req);
+ }));
+
+ // --- cook (Make Now) ---
+ s.addHandler(new AsyncCallbackJsonWebHandler("/api/cook/load",
+ [this](AsyncWebServerRequest* req, JsonVariant json) {
+ const char* id = json["id"] | "";
+ Recipe r;
+ if (!id[0] || !store_->loadRecipe(id, r)) return fail(req, 404, "recipe not found");
+ if (!engine_->load(r)) return fail(req, 400, "recipe invalid");
+ ok(req);
+ }));
+ s.on("/api/cook/begin", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ if (!engine_->beginCook()) return fail(req, 409, "no recipe loaded");
+ ok(req);
+ });
+ s.on("/api/cook/stop", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ engine_->stop(); ok(req);
+ });
+ s.on("/api/cook/resume", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ if (!engine_->resume()) return fail(req, 409, "nothing to resume");
+ ok(req);
+ });
+ s.on("/api/cook/next", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ engine_->nextStep(); ok(req);
+ });
+ s.on("/api/cook/cancel", HTTP_POST, [this](AsyncWebServerRequest* req) {
+ engine_->cancel(); ok(req);
+ });
+
+ // --- config ---
+ s.on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* req) {
+ JsonDocument doc;
+ doc["hostname"] = cfg_->hostname;
+ doc["wifiSsid"] = cfg_->wifiSsid; // SSID shown, password redacted
+ doc["wifiPass"] = cfg_->wifiPass.length() ? "********" : "";
+ doc["kp"] = cfg_->kp; doc["ki"] = cfg_->ki; doc["kd"] = cfg_->kd;
+ doc["grillOffsetF"] = cfg_->grillOffsetF;
+ doc["mqttHost"] = cfg_->mqttHost;
+ doc["mqttPort"] = cfg_->mqttPort;
+ doc["mqttUser"] = cfg_->mqttUser;
+ doc["mqttPass"] = cfg_->mqttPass.length() ? "********" : "";
+ doc["haDiscovery"] = cfg_->haDiscovery;
+ sendJson(req, 200, doc);
+ });
+
+ s.addHandler(new AsyncCallbackJsonWebHandler("/api/config",
+ [this](AsyncWebServerRequest* req, JsonVariant json) {
+ JsonObjectConst o = json.as();
+ if (o["hostname"].is()) cfg_->hostname = (const char*)o["hostname"];
+ if (o["wifiSsid"].is()) cfg_->wifiSsid = (const char*)o["wifiSsid"];
+ // Only overwrite secrets when a real (non-redacted) value is sent.
+ if (o["wifiPass"].is()) {
+ String v = (const char*)o["wifiPass"];
+ if (v != "********") cfg_->wifiPass = v;
+ }
+ if (o["kp"].is()) cfg_->kp = o["kp"];
+ if (o["ki"].is()) cfg_->ki = o["ki"];
+ if (o["kd"].is()) cfg_->kd = o["kd"];
+ if (o["grillOffsetF"].is()) cfg_->grillOffsetF = o["grillOffsetF"];
+ if (o["mqttHost"].is()) cfg_->mqttHost = (const char*)o["mqttHost"];
+ if (o["mqttPort"].is()) cfg_->mqttPort = o["mqttPort"];
+ if (o["mqttUser"].is()) cfg_->mqttUser = (const char*)o["mqttUser"];
+ if (o["mqttPass"].is()) {
+ String v = (const char*)o["mqttPass"];
+ if (v != "********") cfg_->mqttPass = v;
+ }
+ if (o["haDiscovery"].is()) cfg_->haDiscovery = o["haDiscovery"];
+
+ // Apply what we safely can live; the rest takes effect on reboot.
+ grill_->setPidGains(cfg_->kp ? cfg_->kp : pid_defaults::KP,
+ cfg_->ki ? cfg_->ki : pid_defaults::KI,
+ cfg_->kd ? cfg_->kd : pid_defaults::KD);
+ temp_->setGrillOffsetF(cfg_->grillOffsetF);
+ if (!store_->saveConfig(*cfg_)) return fail(req, 500, "save failed");
+ ok(req, "saved (network changes apply on reboot)");
+ }));
+
+ // --- static UI ---
+ s.serveStatic("/", LittleFS, "/www/").setDefaultFile("index.html");
+ s.onNotFound([](AsyncWebServerRequest* req) {
+ // SPA fallback so client-side routes still load the app shell.
+ if (LittleFS.exists("/www/index.html"))
+ req->send(LittleFS, "/www/index.html", "text/html");
+ else
+ req->send(404, "text/plain", "OpenEmber: web UI not uploaded (pio run -t uploadfs)");
+ });
+}
diff --git a/src/net/WebServer.h b/src/net/WebServer.h
new file mode 100644
index 0000000..9747774
--- /dev/null
+++ b/src/net/WebServer.h
@@ -0,0 +1,50 @@
+// WebServer.h β REST API + static web UI, served over HTTP.
+//
+// Endpoints (all JSON):
+// GET /api/status
+// POST /api/grill/setpoint {"tempF": 225}
+// POST /api/grill/supersmoke {"on": true}
+// POST /api/grill/start
+// POST /api/grill/shutdown
+// POST /api/grill/clear
+// GET /api/recipes
+// GET /api/recipes?id=
+// POST /api/recipes (create/replace)
+// POST /api/recipes/delete {"id": ""}
+// POST /api/cook/load {"id": ""}
+// POST /api/cook/begin
+// POST /api/cook/stop
+// POST /api/cook/resume
+// POST /api/cook/next
+// POST /api/cook/cancel
+// GET /api/config (secrets redacted)
+// POST /api/config
+//
+// The UI in data/www is served for every other path; "/" returns index.html.
+#pragma once
+#include
+#include "control/GrillController.h"
+#include "recipe/RecipeEngine.h"
+#include "hardware/TempSensor.h"
+#include "storage/Storage.h"
+#include "net/WifiManager.h"
+
+class AsyncWebServer;
+
+class WebUi {
+public:
+ WebUi();
+ void begin(GrillController* grill, RecipeEngine* engine, TempSensor* temp,
+ Storage* store, WifiManager* wifi, DeviceConfig* cfg);
+
+private:
+ AsyncWebServer* server_ = nullptr;
+ GrillController* grill_ = nullptr;
+ RecipeEngine* engine_ = nullptr;
+ TempSensor* temp_ = nullptr;
+ Storage* store_ = nullptr;
+ WifiManager* wifi_ = nullptr;
+ DeviceConfig* cfg_ = nullptr;
+
+ void routes();
+};
diff --git a/src/net/WifiManager.cpp b/src/net/WifiManager.cpp
new file mode 100644
index 0000000..a6cfff7
--- /dev/null
+++ b/src/net/WifiManager.cpp
@@ -0,0 +1,45 @@
+#include "net/WifiManager.h"
+#include
+#include "config.h"
+
+void WifiManager::begin(const DeviceConfig& cfg) {
+ ssid_ = cfg.wifiSsid;
+ pass_ = cfg.wifiPass;
+ hostname_ = cfg.hostname.length() ? cfg.hostname : net_defaults::HOSTNAME;
+
+ WiFi.setHostname(hostname_.c_str());
+ if (ssid_.length()) {
+ station_ = true;
+ WiFi.mode(WIFI_STA);
+ WiFi.begin(ssid_.c_str(), pass_.c_str());
+ lastAttemptMs_ = millis();
+ } else {
+ station_ = false;
+ WiFi.mode(WIFI_AP);
+ WiFi.softAP(net_defaults::AP_SSID, net_defaults::AP_PASS);
+ }
+}
+
+void WifiManager::loop() {
+ if (!station_) return;
+ if (WiFi.status() == WL_CONNECTED) return;
+ // Retry the join every 15 s without blocking the control loop.
+ if (millis() - lastAttemptMs_ > 15000) {
+ lastAttemptMs_ = millis();
+ WiFi.disconnect();
+ WiFi.begin(ssid_.c_str(), pass_.c_str());
+ }
+}
+
+bool WifiManager::connected() const {
+ return station_ ? WiFi.status() == WL_CONNECTED : true;
+}
+
+IPAddress WifiManager::ip() const {
+ return station_ ? WiFi.localIP() : WiFi.softAPIP();
+}
+
+String WifiManager::modeString() const {
+ if (!station_) return "AP";
+ return WiFi.status() == WL_CONNECTED ? "STA" : "STA(connecting)";
+}
diff --git a/src/net/WifiManager.h b/src/net/WifiManager.h
new file mode 100644
index 0000000..7a594bf
--- /dev/null
+++ b/src/net/WifiManager.h
@@ -0,0 +1,24 @@
+// WifiManager.h β connect to the configured Wi-Fi, or fall back to a setup AP.
+//
+// If station credentials are present we try to join that network. If they are
+// missing or the join fails, we bring up an open-configuration SoftAP
+// (OpenEmber-Setup) so the user can reach the web UI and enter credentials.
+#pragma once
+#include
+#include "storage/Storage.h"
+
+class WifiManager {
+public:
+ void begin(const DeviceConfig& cfg);
+ void loop(); // handles reconnection backoff
+
+ bool isStation() const { return station_; }
+ bool connected() const;
+ IPAddress ip() const;
+ String modeString() const;
+
+private:
+ bool station_ = false;
+ uint32_t lastAttemptMs_ = 0;
+ String ssid_, pass_, hostname_;
+};
diff --git a/src/recipe/Recipe.cpp b/src/recipe/Recipe.cpp
new file mode 100644
index 0000000..83011b9
--- /dev/null
+++ b/src/recipe/Recipe.cpp
@@ -0,0 +1,78 @@
+#include "recipe/Recipe.h"
+#include
+
+const char* advanceTypeName(AdvanceType t) {
+ switch (t) {
+ case AdvanceType::TIME: return "time";
+ case AdvanceType::PROBE: return "probe";
+ case AdvanceType::GRILL: return "grill";
+ case AdvanceType::MANUAL: return "manual";
+ }
+ return "manual";
+}
+
+AdvanceType advanceTypeFromName(const char* s) {
+ if (!s) return AdvanceType::MANUAL;
+ if (!strcmp(s, "time")) return AdvanceType::TIME;
+ if (!strcmp(s, "probe")) return AdvanceType::PROBE;
+ if (!strcmp(s, "grill")) return AdvanceType::GRILL;
+ return AdvanceType::MANUAL;
+}
+
+static void copyStr(char* dst, size_t cap, const char* src) {
+ if (!src) { dst[0] = '\0'; return; }
+ strncpy(dst, src, cap - 1);
+ dst[cap - 1] = '\0';
+}
+
+bool Recipe::fromJson(JsonObjectConst obj) {
+ *this = Recipe{}; // reset to defaults
+
+ const char* rid = obj["id"] | "";
+ if (rid[0] == '\0') return false;
+ copyStr(id, sizeof(id), rid);
+ copyStr(name, sizeof(name), obj["name"] | rid);
+ copyStr(author, sizeof(author), obj["author"] | "");
+ keepWarm = obj["keepWarm"] | true;
+
+ JsonArrayConst steps = obj["steps"];
+ if (steps.isNull() || steps.size() == 0) return false;
+ if (steps.size() > RECIPE_MAX_STEPS) return false;
+
+ uint8_t i = 0;
+ for (JsonObjectConst s : steps) {
+ RecipeStep& st = this->steps[i];
+ copyStr(st.name, sizeof(st.name), s["name"] | "Step");
+ st.setpointF = s["setpointF"] | 180.0f;
+ st.superSmoke = s["superSmoke"] | false;
+
+ JsonObjectConst adv = s["advance"];
+ st.advType = advanceTypeFromName(adv["type"] | "manual");
+ st.seconds = adv["seconds"] | 0UL;
+ st.probeIndex = adv["probe"] | 0;
+ st.targetF = adv["targetF"] | 0.0f;
+ i++;
+ }
+ stepCount = i;
+ return valid();
+}
+
+void Recipe::toJson(JsonObject obj) const {
+ obj["id"] = id;
+ obj["name"] = name;
+ obj["author"] = author;
+ obj["keepWarm"] = keepWarm;
+ JsonArray arr = obj["steps"].to();
+ for (uint8_t i = 0; i < stepCount; i++) {
+ const RecipeStep& st = steps[i];
+ JsonObject so = arr.add();
+ so["name"] = st.name;
+ so["setpointF"] = st.setpointF;
+ so["superSmoke"] = st.superSmoke;
+ JsonObject adv = so["advance"].to();
+ adv["type"] = advanceTypeName(st.advType);
+ if (st.advType == AdvanceType::TIME) adv["seconds"] = st.seconds;
+ if (st.advType == AdvanceType::PROBE) { adv["probe"] = st.probeIndex; adv["targetF"] = st.targetF; }
+ if (st.advType == AdvanceType::GRILL) adv["targetF"] = st.targetF;
+ }
+}
diff --git a/src/recipe/Recipe.h b/src/recipe/Recipe.h
new file mode 100644
index 0000000..b2f59a4
--- /dev/null
+++ b/src/recipe/Recipe.h
@@ -0,0 +1,50 @@
+// Recipe.h β the programmable multi-step cook model (the "Make Now" analogue).
+//
+// A recipe is an ordered list of steps. Each step sets a grill temperature (and
+// optionally Super Smoke) and defines the condition that advances the cook to
+// the next step: a timer, a meat-probe target, a grill-temperature target, or a
+// manual tap. When the last step's condition is met the grill either drops to
+// Keep Warm (165Β°F) or shuts down.
+//
+// Fixed-size storage (no heap) keeps this safe to use on the control core.
+#pragma once
+#include
+#include
+
+static constexpr uint8_t RECIPE_MAX_STEPS = 16;
+
+enum class AdvanceType : uint8_t {
+ TIME, // advance after `seconds` elapsed in this step
+ PROBE, // advance when meat probe `probeIndex` reaches `targetF`
+ GRILL, // advance when grill temperature reaches `targetF`
+ MANUAL // advance only when the user taps "Next"
+};
+
+struct RecipeStep {
+ char name[32] = {0};
+ float setpointF = 180.0f;
+ bool superSmoke = false;
+ AdvanceType advType = AdvanceType::MANUAL;
+ uint32_t seconds = 0; // TIME
+ uint8_t probeIndex = 0; // PROBE
+ float targetF = 0.0f; // PROBE / GRILL
+};
+
+struct Recipe {
+ char id[40] = {0};
+ char name[48] = {0};
+ char author[40] = {0};
+ bool keepWarm = true;
+ uint8_t stepCount = 0;
+ RecipeStep steps[RECIPE_MAX_STEPS];
+
+ bool valid() const { return stepCount > 0 && id[0] != '\0'; }
+
+ // Parse from a JSON document. Returns false (and leaves *this cleared) if the
+ // document is missing required fields or exceeds RECIPE_MAX_STEPS.
+ bool fromJson(JsonObjectConst obj);
+ void toJson(JsonObject obj) const;
+};
+
+const char* advanceTypeName(AdvanceType t);
+AdvanceType advanceTypeFromName(const char* s);
diff --git a/src/recipe/RecipeEngine.cpp b/src/recipe/RecipeEngine.cpp
new file mode 100644
index 0000000..caa9ebd
--- /dev/null
+++ b/src/recipe/RecipeEngine.cpp
@@ -0,0 +1,209 @@
+#include "recipe/RecipeEngine.h"
+#include
+#include
+
+const char* cookStateName(CookState s) {
+ switch (s) {
+ case CookState::IDLE: return "IDLE";
+ case CookState::RUNNING: return "RUNNING";
+ case CookState::PAUSED: return "PAUSED";
+ case CookState::KEEP_WARM: return "KEEP_WARM";
+ case CookState::COMPLETE: return "COMPLETE";
+ }
+ return "?";
+}
+
+void RecipeEngine::begin(GrillController* grill, TempSensor* temp, Storage* store) {
+ grill_ = grill;
+ temp_ = temp;
+ store_ = store;
+ state_ = CookState::IDLE;
+}
+
+bool RecipeEngine::load(const Recipe& r) {
+ if (!r.valid()) return false;
+ recipe_ = r;
+ loaded_ = true;
+ stepIdx_ = 0;
+ state_ = CookState::IDLE;
+ refreshStatus();
+ return true;
+}
+
+bool RecipeEngine::beginCook() {
+ if (!loaded_) return false;
+ grill_->start(); // ignite if not already lit
+ enterStep(0);
+ return true;
+}
+
+void RecipeEngine::stop() {
+ // Detach from the grill but leave the fire burning under manual control,
+ // exactly like tapping STOP on a Make Now cook.
+ if (state_ == CookState::RUNNING || state_ == CookState::KEEP_WARM) {
+ state_ = CookState::PAUSED;
+ persist();
+ refreshStatus();
+ }
+}
+
+bool RecipeEngine::resume() {
+ if (state_ != CookState::PAUSED || !loaded_) return false;
+ grill_->start();
+ enterStep(stepIdx_);
+ return true;
+}
+
+void RecipeEngine::nextStep() {
+ if (state_ != CookState::RUNNING) return;
+ if (stepIdx_ + 1 >= recipe_.stepCount) { finish(); return; }
+ enterStep(stepIdx_ + 1);
+}
+
+void RecipeEngine::cancel() {
+ state_ = CookState::IDLE;
+ loaded_ = false;
+ if (store_) store_->clearResume();
+ grill_->shutdown();
+ refreshStatus();
+}
+
+void RecipeEngine::enterStep(uint8_t idx) {
+ stepIdx_ = idx;
+ stepStartMs_ = millis();
+ state_ = CookState::RUNNING;
+ const RecipeStep& st = recipe_.steps[idx];
+ grill_->setSetpoint(st.setpointF);
+ grill_->setSuperSmoke(st.superSmoke);
+ persist();
+ refreshStatus();
+}
+
+bool RecipeEngine::stepConditionMet(const RecipeStep& st) {
+ switch (st.advType) {
+ case AdvanceType::TIME:
+ return (millis() - stepStartMs_) >= (uint32_t)st.seconds * 1000UL;
+ case AdvanceType::PROBE: {
+ float p = temp_->probeTempF(st.probeIndex);
+ return !isnan(p) && p >= st.targetF;
+ }
+ case AdvanceType::GRILL: {
+ float g = temp_->grillTempF();
+ return !isnan(g) && g >= st.targetF;
+ }
+ case AdvanceType::MANUAL:
+ default:
+ return false; // only nextStep() advances a manual step
+ }
+}
+
+void RecipeEngine::finish() {
+ if (recipe_.keepWarm) {
+ state_ = CookState::KEEP_WARM;
+ grill_->setSuperSmoke(false);
+ grill_->setSetpoint(KEEP_WARM_F);
+ } else {
+ state_ = CookState::COMPLETE;
+ grill_->shutdown();
+ }
+ if (store_) store_->clearResume();
+ refreshStatus();
+}
+
+void RecipeEngine::update() {
+ if (!grill_) return;
+
+ if (state_ == CookState::RUNNING) {
+ const RecipeStep& st = recipe_.steps[stepIdx_];
+ // Re-assert the step's setpoint every tick: while a cook is running the
+ // recipe owns the grill, so any stray manual change is overridden.
+ grill_->setSetpoint(st.setpointF);
+ grill_->setSuperSmoke(st.superSmoke);
+
+ // If the grill faulted out from under us, stop driving the cook.
+ if (grill_->status().state == GrillState::ERROR) {
+ state_ = CookState::PAUSED;
+ persist();
+ } else if (stepConditionMet(st)) {
+ if (stepIdx_ + 1 >= recipe_.stepCount) finish();
+ else enterStep(stepIdx_ + 1);
+ }
+ } else if (state_ == CookState::KEEP_WARM) {
+ grill_->setSetpoint(KEEP_WARM_F);
+ }
+ refreshStatus();
+}
+
+void RecipeEngine::persist() {
+ if (!store_) return;
+ ResumeState rs;
+ rs.active = (state_ == CookState::RUNNING || state_ == CookState::PAUSED);
+ strncpy(rs.recipeId, recipe_.id, sizeof(rs.recipeId) - 1);
+ rs.stepIndex = stepIdx_;
+ rs.paused = (state_ == CookState::PAUSED);
+ if (rs.active) store_->saveResume(rs);
+ else store_->clearResume();
+}
+
+bool RecipeEngine::restoreFromStorage() {
+ if (!store_) return false;
+ ResumeState rs;
+ if (!store_->loadResume(rs) || !rs.active) return false;
+ Recipe r;
+ if (!store_->loadRecipe(rs.recipeId, r)) { store_->clearResume(); return false; }
+ if (rs.stepIndex >= r.stepCount) { store_->clearResume(); return false; }
+ recipe_ = r;
+ loaded_ = true;
+ stepIdx_ = rs.stepIndex;
+ // Come back PAUSED so the user must confirm re-lighting a cold grill.
+ state_ = CookState::PAUSED;
+ refreshStatus();
+ return true;
+}
+
+void RecipeEngine::refreshStatus() {
+ status_.state = state_;
+ strncpy(status_.recipeName, recipe_.name, sizeof(status_.recipeName) - 1);
+ status_.stepCount = recipe_.stepCount;
+ status_.percentComplete = recipe_.stepCount
+ ? (uint8_t)((uint16_t)stepIdx_ * 100 / recipe_.stepCount) : 0;
+
+ if (!loaded_ || state_ == CookState::IDLE) {
+ status_.stepName[0] = '\0';
+ status_.stepIndex = 0;
+ return;
+ }
+
+ status_.stepIndex = stepIdx_;
+ const RecipeStep& st = recipe_.steps[stepIdx_];
+ strncpy(status_.stepName, st.name, sizeof(status_.stepName) - 1);
+ status_.stepSetpointF = st.setpointF;
+ status_.stepSuperSmoke = st.superSmoke;
+ status_.advanceType = advanceTypeName(st.advType);
+ status_.probeIndex = st.probeIndex;
+ status_.probeTargetF = st.targetF;
+
+ uint32_t elapsedS = (millis() - stepStartMs_) / 1000;
+ status_.stepElapsedS = elapsedS;
+ status_.stepRemainingS = 0;
+ status_.stepProgress = 0;
+
+ if (state_ != CookState::RUNNING) return;
+ switch (st.advType) {
+ case AdvanceType::TIME:
+ status_.stepRemainingS = (st.seconds > elapsedS) ? (st.seconds - elapsedS) : 0;
+ status_.stepProgress = st.seconds ? min(1.0f, (float)elapsedS / st.seconds) : 1.0f;
+ break;
+ case AdvanceType::PROBE: {
+ float p = temp_->probeTempF(st.probeIndex);
+ if (!isnan(p) && st.targetF > 0) status_.stepProgress = min(1.0f, p / st.targetF);
+ break;
+ }
+ case AdvanceType::GRILL: {
+ float g = temp_->grillTempF();
+ if (!isnan(g) && st.targetF > 0) status_.stepProgress = min(1.0f, g / st.targetF);
+ break;
+ }
+ default: break;
+ }
+}
diff --git a/src/recipe/RecipeEngine.h b/src/recipe/RecipeEngine.h
new file mode 100644
index 0000000..13e12cc
--- /dev/null
+++ b/src/recipe/RecipeEngine.h
@@ -0,0 +1,83 @@
+// RecipeEngine.h β sequences a Recipe's steps over the GrillController.
+//
+// This is the "Make Now" behaviour:
+// * loads a recipe and drives the grill through each step automatically,
+// * advances on the step's condition (timer / probe / grill temp / manual),
+// * re-asserts the step's setpoint each tick so manual changes are ignored
+// while a cook is running (matching the stock app's locked controls),
+// * drops to Keep Warm (165Β°F) when the recipe finishes, or shuts down,
+// * supports Stop (back to manual) and Resume from the saved step,
+// * persists progress so a power blip doesn't lose your place.
+#pragma once
+#include
+#include "recipe/Recipe.h"
+#include "control/GrillController.h"
+#include "hardware/TempSensor.h"
+#include "storage/Storage.h"
+
+enum class CookState : uint8_t {
+ IDLE, // no recipe running
+ RUNNING, // actively cooking a step
+ PAUSED, // stopped by the user; grill back under manual control
+ KEEP_WARM, // recipe finished, holding at 165Β°F
+ COMPLETE // recipe finished and grill shut down (keepWarm == false)
+};
+
+const char* cookStateName(CookState s);
+
+struct CookStatus {
+ CookState state = CookState::IDLE;
+ char recipeName[48] = {0};
+ uint8_t stepIndex = 0;
+ uint8_t stepCount = 0;
+ char stepName[32] = {0};
+ float stepSetpointF = 0;
+ bool stepSuperSmoke = false;
+ const char* advanceType = "manual";
+ // Progress toward advancing the current step (0..1), best-effort.
+ float stepProgress = 0;
+ uint32_t stepElapsedS = 0;
+ uint32_t stepRemainingS = 0; // for TIME steps
+ float probeTargetF = 0; // for PROBE steps
+ uint8_t probeIndex = 0;
+ uint8_t percentComplete = 0; // completed steps / total, like the app dots
+};
+
+class RecipeEngine {
+public:
+ void begin(GrillController* grill, TempSensor* temp, Storage* store);
+
+ bool load(const Recipe& r); // stage a recipe (does not start cooking)
+ bool beginCook(); // "MAKE NOW / BEGIN COOK"
+ void stop(); // "STOP" -> PAUSED, grill returns to manual
+ bool resume(); // "START" from the pinned widget
+ void nextStep(); // manual advance / skip current step
+ void cancel(); // abandon the cook entirely (shutdown)
+
+ // Restore an interrupted cook after a reboot (called once at boot).
+ bool restoreFromStorage();
+
+ void update(); // call every loop
+
+ const CookStatus& status() const { return status_; }
+ const Recipe& recipe() const { return recipe_; }
+ bool hasRecipe() const { return loaded_; }
+
+private:
+ void enterStep(uint8_t idx);
+ bool stepConditionMet(const RecipeStep& st);
+ void finish();
+ void persist();
+ void refreshStatus();
+
+ GrillController* grill_ = nullptr;
+ TempSensor* temp_ = nullptr;
+ Storage* store_ = nullptr;
+
+ Recipe recipe_;
+ bool loaded_ = false;
+ CookState state_ = CookState::IDLE;
+ uint8_t stepIdx_ = 0;
+ uint32_t stepStartMs_ = 0;
+ CookStatus status_;
+};
diff --git a/src/storage/Storage.cpp b/src/storage/Storage.cpp
new file mode 100644
index 0000000..62bde44
--- /dev/null
+++ b/src/storage/Storage.cpp
@@ -0,0 +1,152 @@
+#include "storage/Storage.h"
+#include
+
+static const char* CONFIG_PATH = "/config.json";
+static const char* RESUME_PATH = "/resume.json";
+static const char* RECIPE_DIR = "/recipes";
+
+bool Storage::begin() {
+ // format-on-fail so a virgin board comes up cleanly.
+ mounted_ = LittleFS.begin(true);
+ if (mounted_ && !LittleFS.exists(RECIPE_DIR)) {
+ LittleFS.mkdir(RECIPE_DIR);
+ }
+ return mounted_;
+}
+
+String Storage::recipePath(const char* id) {
+ return String(RECIPE_DIR) + "/" + id + ".json";
+}
+
+// --- config ---------------------------------------------------------------
+
+bool Storage::loadConfig(DeviceConfig& cfg) {
+ if (!mounted_ || !LittleFS.exists(CONFIG_PATH)) return false;
+ File f = LittleFS.open(CONFIG_PATH, "r");
+ if (!f) return false;
+ JsonDocument doc;
+ DeserializationError err = deserializeJson(doc, f);
+ f.close();
+ if (err) return false;
+
+ cfg.wifiSsid = doc["wifiSsid"] | "";
+ cfg.wifiPass = doc["wifiPass"] | "";
+ cfg.hostname = doc["hostname"] | "openember";
+ cfg.kp = doc["kp"] | 0.0f;
+ cfg.ki = doc["ki"] | 0.0f;
+ cfg.kd = doc["kd"] | 0.0f;
+ cfg.grillOffsetF = doc["grillOffsetF"] | 0.0f;
+ cfg.mqttHost = doc["mqttHost"] | "";
+ cfg.mqttPort = doc["mqttPort"] | 1883;
+ cfg.mqttUser = doc["mqttUser"] | "";
+ cfg.mqttPass = doc["mqttPass"] | "";
+ cfg.haDiscovery = doc["haDiscovery"] | true;
+ return true;
+}
+
+bool Storage::saveConfig(const DeviceConfig& cfg) {
+ if (!mounted_) return false;
+ JsonDocument doc;
+ doc["wifiSsid"] = cfg.wifiSsid;
+ doc["wifiPass"] = cfg.wifiPass;
+ doc["hostname"] = cfg.hostname;
+ doc["kp"] = cfg.kp;
+ doc["ki"] = cfg.ki;
+ doc["kd"] = cfg.kd;
+ doc["grillOffsetF"] = cfg.grillOffsetF;
+ doc["mqttHost"] = cfg.mqttHost;
+ doc["mqttPort"] = cfg.mqttPort;
+ doc["mqttUser"] = cfg.mqttUser;
+ doc["mqttPass"] = cfg.mqttPass;
+ doc["haDiscovery"] = cfg.haDiscovery;
+ File f = LittleFS.open(CONFIG_PATH, "w");
+ if (!f) return false;
+ serializeJson(doc, f);
+ f.close();
+ return true;
+}
+
+// --- recipes --------------------------------------------------------------
+
+bool Storage::saveRecipe(const Recipe& r) {
+ if (!mounted_ || !r.valid()) return false;
+ JsonDocument doc;
+ r.toJson(doc.to());
+ File f = LittleFS.open(recipePath(r.id), "w");
+ if (!f) return false;
+ serializeJson(doc, f);
+ f.close();
+ return true;
+}
+
+bool Storage::loadRecipe(const char* id, Recipe& out) {
+ if (!mounted_) return false;
+ String path = recipePath(id);
+ if (!LittleFS.exists(path)) return false;
+ File f = LittleFS.open(path, "r");
+ if (!f) return false;
+ JsonDocument doc;
+ DeserializationError err = deserializeJson(doc, f);
+ f.close();
+ if (err) return false;
+ return out.fromJson(doc.as());
+}
+
+bool Storage::deleteRecipe(const char* id) {
+ if (!mounted_) return false;
+ String path = recipePath(id);
+ if (!LittleFS.exists(path)) return false;
+ return LittleFS.remove(path);
+}
+
+void Storage::listRecipes(JsonArray arr) {
+ if (!mounted_) return;
+ File dir = LittleFS.open(RECIPE_DIR);
+ if (!dir || !dir.isDirectory()) return;
+ for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
+ if (f.isDirectory()) continue;
+ JsonDocument doc;
+ if (deserializeJson(doc, f) == DeserializationError::Ok) {
+ JsonObject o = arr.add();
+ o["id"] = doc["id"];
+ o["name"] = doc["name"];
+ o["stepCount"] = doc["steps"].size();
+ }
+ f.close();
+ }
+}
+
+// --- resume ---------------------------------------------------------------
+
+bool Storage::loadResume(ResumeState& st) {
+ if (!mounted_ || !LittleFS.exists(RESUME_PATH)) return false;
+ File f = LittleFS.open(RESUME_PATH, "r");
+ if (!f) return false;
+ JsonDocument doc;
+ DeserializationError err = deserializeJson(doc, f);
+ f.close();
+ if (err) return false;
+ st.active = doc["active"] | false;
+ strncpy(st.recipeId, doc["recipeId"] | "", sizeof(st.recipeId) - 1);
+ st.stepIndex = doc["stepIndex"] | 0;
+ st.paused = doc["paused"] | false;
+ return st.active;
+}
+
+bool Storage::saveResume(const ResumeState& st) {
+ if (!mounted_) return false;
+ JsonDocument doc;
+ doc["active"] = st.active;
+ doc["recipeId"] = st.recipeId;
+ doc["stepIndex"] = st.stepIndex;
+ doc["paused"] = st.paused;
+ File f = LittleFS.open(RESUME_PATH, "w");
+ if (!f) return false;
+ serializeJson(doc, f);
+ f.close();
+ return true;
+}
+
+void Storage::clearResume() {
+ if (mounted_ && LittleFS.exists(RESUME_PATH)) LittleFS.remove(RESUME_PATH);
+}
diff --git a/src/storage/Storage.h b/src/storage/Storage.h
new file mode 100644
index 0000000..d177204
--- /dev/null
+++ b/src/storage/Storage.h
@@ -0,0 +1,60 @@
+// Storage.h β LittleFS persistence for config, recipes, and resume state.
+//
+// Layout on the flash filesystem:
+// /config.json device + PID + Wi-Fi + MQTT settings
+// /recipes/.json one file per recipe
+// /resume.json the in-progress cook, so it survives a reboot
+// /www/ the web UI (index.html, app.js, style.css)
+#pragma once
+#include
+#include
+#include "recipe/Recipe.h"
+
+struct DeviceConfig {
+ String wifiSsid;
+ String wifiPass;
+ String hostname = "openember";
+ // PID gains (fall back to compile-time defaults when 0).
+ float kp = 0, ki = 0, kd = 0;
+ float grillOffsetF = 0;
+ // MQTT (optional). Empty host disables MQTT.
+ String mqttHost;
+ uint16_t mqttPort = 1883;
+ String mqttUser;
+ String mqttPass;
+ bool haDiscovery = true; // publish Home Assistant MQTT discovery
+};
+
+struct ResumeState {
+ bool active = false;
+ char recipeId[40] = {0};
+ uint8_t stepIndex = 0;
+ bool paused = false;
+};
+
+class Storage {
+public:
+ bool begin(); // mount LittleFS (formats on first run)
+
+ // --- config ---
+ bool loadConfig(DeviceConfig& cfg);
+ bool saveConfig(const DeviceConfig& cfg);
+
+ // --- recipes ---
+ bool saveRecipe(const Recipe& r); // writes /recipes/.json
+ bool loadRecipe(const char* id, Recipe& out);
+ bool deleteRecipe(const char* id);
+ // Append a JSON array of {id,name,stepCount} for every stored recipe.
+ void listRecipes(JsonArray arr);
+
+ // --- resume ---
+ bool loadResume(ResumeState& st);
+ bool saveResume(const ResumeState& st);
+ void clearResume();
+
+ bool mounted() const { return mounted_; }
+
+private:
+ bool mounted_ = false;
+ static String recipePath(const char* id);
+};
diff --git a/tools/validate_recipe.py b/tools/validate_recipe.py
new file mode 100644
index 0000000..58b8f4d
--- /dev/null
+++ b/tools/validate_recipe.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Validate OpenEmber recipe JSON files against recipes/schema.json.
+
+Usage:
+ python3 tools/validate_recipe.py recipes/*.json
+
+Falls back to a built-in structural check if `jsonschema` isn't installed, so it
+runs with a stock Python. Exit code is non-zero if any file fails.
+"""
+import json
+import sys
+import pathlib
+
+HERE = pathlib.Path(__file__).resolve().parent
+SCHEMA_PATH = HERE.parent / "recipes" / "schema.json"
+ADV_TYPES = {"time", "probe", "grill", "manual"}
+
+
+def basic_check(doc: dict) -> list[str]:
+ errs = []
+ if not isinstance(doc.get("id"), str) or not doc["id"]:
+ errs.append("missing/invalid 'id'")
+ steps = doc.get("steps")
+ if not isinstance(steps, list) or not steps:
+ errs.append("'steps' must be a non-empty array")
+ return errs
+ if len(steps) > 16:
+ errs.append(f"too many steps ({len(steps)} > 16)")
+ for i, st in enumerate(steps):
+ sp = st.get("setpointF")
+ if not isinstance(sp, (int, float)) or not (150 <= sp <= 500):
+ errs.append(f"step {i}: setpointF must be 150β500")
+ adv = st.get("advance", {})
+ t = adv.get("type")
+ if t not in ADV_TYPES:
+ errs.append(f"step {i}: advance.type must be one of {sorted(ADV_TYPES)}")
+ if t == "time" and not isinstance(adv.get("seconds"), int):
+ errs.append(f"step {i}: time step needs integer 'seconds'")
+ if t in ("probe", "grill") and not isinstance(adv.get("targetF"), (int, float)):
+ errs.append(f"step {i}: {t} step needs numeric 'targetF'")
+ if t == "probe" and adv.get("probe", 0) not in (0, 1):
+ errs.append(f"step {i}: probe index must be 0 or 1")
+ return errs
+
+
+def validate(path: pathlib.Path) -> list[str]:
+ try:
+ doc = json.loads(path.read_text())
+ except json.JSONDecodeError as e:
+ return [f"invalid JSON: {e}"]
+ try:
+ import jsonschema # type: ignore
+ schema = json.loads(SCHEMA_PATH.read_text())
+ v = jsonschema.Draft7Validator(schema)
+ return [f"{e.json_path}: {e.message}" for e in v.iter_errors(doc)]
+ except ImportError:
+ return basic_check(doc)
+
+
+def main(argv: list[str]) -> int:
+ files = [pathlib.Path(a) for a in argv] or sorted((HERE.parent / "recipes").glob("*.json"))
+ files = [f for f in files if f.name != "schema.json"]
+ failed = 0
+ for f in files:
+ errs = validate(f)
+ if errs:
+ failed += 1
+ print(f"β {f}")
+ for e in errs:
+ print(f" {e}")
+ else:
+ print(f"β {f}")
+ print(f"\n{len(files) - failed}/{len(files)} valid")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))