Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.**
125 changes: 125 additions & 0 deletions data/www/app.js
Original file line number Diff line number Diff line change
@@ -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 => `<option value="${r.id}">${r.name} (${r.stepCount} steps)</option>`).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 =>
`<div class="probe ${p.connected ? '' : 'disc'}"><span>Probe ${p.index + 1}</span>` +
`<span class="t">${p.connected ? Math.round(p.tempF) + '°F' : 'not connected'}</span></div>`).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) =>
`<i class="${i < c.stepIndex ? 'done' : i === c.stepIndex ? 'cur' : ''}"></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);
Loading
Loading