From 2cf7c860a1af27187d5db19e72d3d3ed73788bd9 Mon Sep 17 00:00:00 2001 From: Marcus Rasmussen Date: Tue, 7 Jul 2026 05:58:48 -0500 Subject: [PATCH] Initial OpenEmber firmware --- .github/workflows/ci.yml | 45 ++++++ .gitignore | 17 ++ LICENSE | 31 ++++ README.md | 121 ++++++++++++++ data/www/app.js | 125 +++++++++++++++ data/www/index.html | 94 +++++++++++ data/www/style.css | 76 +++++++++ docs/API.md | 94 +++++++++++ docs/ARCHITECTURE.md | 78 ++++++++++ docs/FEATURE_PARITY.md | 43 +++++ docs/FLASHING.md | 149 ++++++++++++++++++ docs/HARDWARE.md | 225 +++++++++++++++++++++++++++ docs/RECIPE_FORMAT.md | 70 +++++++++ docs/SAFETY.md | 57 +++++++ include/config.h | 72 +++++++++ include/pins.h | 60 +++++++ partitions.csv | 7 + partitions_16mb.csv | 9 ++ platformio.ini | 46 ++++++ recipes/baby_back_ribs.json | 24 +++ recipes/reverse_sear_steak.json | 29 ++++ recipes/schema.json | 38 +++++ recipes/smoked_brisket.json | 29 ++++ src/control/GrillController.cpp | 268 ++++++++++++++++++++++++++++++++ src/control/GrillController.h | 95 +++++++++++ src/control/Pid.cpp | 43 +++++ src/control/Pid.h | 26 ++++ src/hardware/Outputs.cpp | 68 ++++++++ src/hardware/Outputs.h | 49 ++++++ src/hardware/TempSensor.cpp | 79 ++++++++++ src/hardware/TempSensor.h | 52 +++++++ src/main.cpp | 89 +++++++++++ src/net/MqttClient.cpp | 160 +++++++++++++++++++ src/net/MqttClient.h | 48 ++++++ src/net/StatusJson.cpp | 68 ++++++++ src/net/StatusJson.h | 17 ++ src/net/WebServer.cpp | 191 +++++++++++++++++++++++ src/net/WebServer.h | 50 ++++++ src/net/WifiManager.cpp | 45 ++++++ src/net/WifiManager.h | 24 +++ src/recipe/Recipe.cpp | 78 ++++++++++ src/recipe/Recipe.h | 50 ++++++ src/recipe/RecipeEngine.cpp | 209 +++++++++++++++++++++++++ src/recipe/RecipeEngine.h | 83 ++++++++++ src/storage/Storage.cpp | 152 ++++++++++++++++++ src/storage/Storage.h | 60 +++++++ tools/validate_recipe.py | 78 ++++++++++ 47 files changed, 3621 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 data/www/app.js create mode 100644 data/www/index.html create mode 100644 data/www/style.css create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/FEATURE_PARITY.md create mode 100644 docs/FLASHING.md create mode 100644 docs/HARDWARE.md create mode 100644 docs/RECIPE_FORMAT.md create mode 100644 docs/SAFETY.md create mode 100644 include/config.h create mode 100644 include/pins.h create mode 100644 partitions.csv create mode 100644 partitions_16mb.csv create mode 100644 platformio.ini create mode 100644 recipes/baby_back_ribs.json create mode 100644 recipes/reverse_sear_steak.json create mode 100644 recipes/schema.json create mode 100644 recipes/smoked_brisket.json create mode 100644 src/control/GrillController.cpp create mode 100644 src/control/GrillController.h create mode 100644 src/control/Pid.cpp create mode 100644 src/control/Pid.h create mode 100644 src/hardware/Outputs.cpp create mode 100644 src/hardware/Outputs.h create mode 100644 src/hardware/TempSensor.cpp create mode 100644 src/hardware/TempSensor.h create mode 100644 src/main.cpp create mode 100644 src/net/MqttClient.cpp create mode 100644 src/net/MqttClient.h create mode 100644 src/net/StatusJson.cpp create mode 100644 src/net/StatusJson.h create mode 100644 src/net/WebServer.cpp create mode 100644 src/net/WebServer.h create mode 100644 src/net/WifiManager.cpp create mode 100644 src/net/WifiManager.h create mode 100644 src/recipe/Recipe.cpp create mode 100644 src/recipe/Recipe.h create mode 100644 src/recipe/RecipeEngine.cpp create mode 100644 src/recipe/RecipeEngine.h create mode 100644 src/storage/Storage.cpp create mode 100644 src/storage/Storage.h create mode 100644 tools/validate_recipe.py 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 => ``).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 + + + +
+

πŸ”₯ OpenEmber

+ … +
+ +
+ +
+
+
--Β°F
+
set --Β°F Β· OFF
+
+
+ auger + igniter + fan + smoke +
+ + +
+ +
+ + + +
+ +
+
+ + +
+

Meat Probes

+
+
+ + +
+

Make Now

+
+ + +
+ +
+ +
+ Recipe editor +

Paste or edit recipe JSON, then Save. See docs/RECIPE_FORMAT.md.

+ +
+ + +
+
+
+ + + + + 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:]))