Initial commit: PT1000 probe emulation bridge

Feeds third-party BBQ probes into a pellet grill's wired probe jacks by
presenting the resistance a real PT1000 RTD would show at that temperature.
The grill sees an ordinary wired probe, so its display, app, target-temp
alarms and Keep Warm all work with no protocol reversing involved.

Pluggable probe sources: ThermoWorks RFX (via ThermoWorks Cloud), MEATER
(community-derived BLE decode), a synthetic simulator for hardware-free
development, and a Combustion stub.

Two safety invariants are load-bearing:
- Stale, missing or implausible readings drive the channel cold, never hot.
  A stuck-hot channel could convince the grill a cook finished early.
- Unimplemented sources raise rather than returning plausible numbers, since
  the grill acts on these values.

Cloud sources are dated by the cloud's own timestamp rather than by fetch
time, because ThermoWorks serves a dead probe's last value with a fresh 200.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-16 22:46:14 -04:00
co-authored by Claude Opus 5
commit ba0c3b58b6
27 changed files with 2727 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
# Hardware
Target grill: **Traeger Ironwood (2022, touchscreen WiFire)** — the non-XL,
non-850 model. It has two wired probe jacks on the controller, labelled 1 and 2,
which take **PT1000 RTD** probes (1000 Ω at 0 °C, ~3.85 Ω/°C).
We present a resistance to those jacks. The grill has no idea it isn't a probe,
so the display, the app, target-temp alarms and Keep Warm all work natively.
---
## Step 0 — Prove the concept for $0 before buying anything
**Do this first.** It validates every assumption in this repo in about ten
minutes, using a multimeter and one resistor.
### 0a. Ohm out a real Traeger probe
With the probe at room temperature, measure all three pairs on the 3.5 mm plug:
| Pair | Expected | Meaning |
|---|---|---|
| One pair reads **~1086 Ω** at 72 °F | PT1000 element | This is the sensor |
| Another pair reads **~0 Ω** | bonded lead | 3-wire RTD, lead-compensated |
| Third pair reads **~1086 Ω** | element + bonded lead | Consistent with 3-wire |
If instead you see only two meaningful contacts and no near-zero pair, it's
wired as a simple 2-wire RTD and the emulator gets simpler.
Confirm it really is PT1000 by measuring at two known temperatures:
| Bath | Temp | PT1000 should read |
|---|---|---|
| Ice water | 0 °C / 32 °F | **1000 Ω** |
| Boiling water (sea level) | 100 °C / 212 °F | **1385 Ω** |
Run `smokescreen table` for the full curve.
> If these numbers don't match, **stop** — your grill isn't using the PT1000
> curve this repo assumes, and `pt1000.py` needs new coefficients. Everything
> else in the design still holds.
### 0b. Fake a temperature with a plain resistor
Put a fixed resistor across the element contacts of a 3.5 mm TRS plug (mirror
whatever the real probe does with the third contact) and plug it into jack 1:
| Resistor | Grill should display |
|---|---|
| 1.0 kΩ | ~32 °F |
| 1.2 kΩ | ~127 °F |
| 1.5 kΩ | ~264 °F |
If the grill shows roughly these, **the entire approach is proven** — the rest
is just making that resistor programmable. This experiment is worth more than
any amount of protocol reversing.
While you're here, note what the grill does with an **open** jack and a
**shorted** jack. Those are its fault thresholds, and knowing them tells you
whether the fail-cold policy actually trips a visible probe error.
---
## Bill of materials
| Qty | Part | Notes |
|---|---|---|
| 1 | Raspberry Pi (any with I²C) | Zero 2 W is plenty |
| 2 | **AD5272BRMZ-1** digital rheostat | 1 kΩ, 1024-step, I²C, ±1% end-to-end |
| 2 | 1 kΩ 0.1% 25 ppm resistor | Sets the base of the window |
| 2 | 3.5 mm TRS male plug | Or sacrifice a cheap replacement probe cable |
| 1 | ADuM1250 + isolated DC-DC | Optional, strongly recommended — see Isolation |
| — | GeeekPi nRF52840 dongle | For reading your BLE probes |
### Why the AD5272 specifically
The usual digipot fails here on two counts: 256 steps is ~4 Ω/step (≈1 °C, too
coarse to hide), and typical end-to-end tolerance is ±20%, which is a ~50 °C
error before you calibrate anything.
The AD5272-1 gives **1024 steps across 1 kΩ ≈ 1 Ω/step ≈ 0.26 °C**, finer than
the grill displays, with ±1% tolerance that calibration trims to nothing.
Sizing check, from `smokescreen table`:
```
0 °C (32 °F) → 1000 Ω
200 °C (392 °F) → 1759 Ω
```
A 759 Ω span fits inside the 1 kΩ rheostat with headroom to spare.
---
## Wiring, per channel
```
┌─────────── 3.5mm TRS plug → grill jack ───────────┐
│ │
tip ───────┴──[ R_fixed 1kΩ 0.1% ]──[ AD5272 A ] │
│ │
(W) wiper ───────────────┴─── sleeve
ring ─────────────────────────────────────┘ (mirror the real probe:
bond to whichever contact
measured ~0 Ω in step 0a)
AD5272: SDA/SCL → Pi I²C, VDD → 3.3 V, GND → Pi GND
ADDR strapped differently per channel (0x2F and 0x2C)
```
Use the AD5272 as a **rheostat** (two-terminal): tie terminal B to the wiper W,
or leave B open per the datasheet's rheostat-mode figure. Do not wire it as a
three-terminal divider.
Both channels need **distinct I²C addresses** — the config loader rejects
duplicates, because two rheostats answering to one address is a maddening bug
to chase in the dark next to a hot grill.
---
## Isolation
The grill controller is mains-powered and its probe jacks share a ground
reference with its ADC. Tying the Pi's ground straight to it creates a ground
loop that will, at best, skew your readings and, at worst, inject noise into the
controller.
The clean fix is an **ADuM1250 I²C isolator plus an isolated DC-DC** so the
rheostat side floats with the grill and the Pi side floats on its own.
At minimum, power the Pi from the same outlet as the grill, and check whether
your step-0b resistor readings drift once the Pi's ground is connected.
---
## Calibration
Per channel, once. This cancels resistor tolerance, wiper resistance, and
contact resistance in one line fit.
1. Wire up the channel, but plug it into your **multimeter**, not the grill.
2. Command a low wiper code and measure across the plug:
```bash
.venv/bin/python -c "from smokescreen.outputs.rheostat import AD5272Rheostat; AD5272Rheostat(0x2f).set_code(0)"
```
3. Repeat at a high code (e.g. 1000).
4. Solve the fit:
```bash
smokescreen calibrate --code-a 0 --ohms-a 1012.4 --code-b 1000 --ohms-b 2015.6
```
5. Paste the emitted `calibration:` block into `config.yaml`.
Sanity-check the reported temperature range covers 32400 °F. If it starts
above ~40 °F, your fixed resistor is too large.
---
## Failure behaviour
When a probe goes stale, disconnects, or reports something implausible, the
emulator drives the channel to its **minimum** resistance — an obviously-cold
reading.
This direction is deliberate. A stuck-**hot** channel could convince the grill
the food hit its target and trigger Keep Warm or a shutdown in the middle of a
cook. A stuck-**cold** channel can only ever look wrong. Failures should be
visible, never mistaken for success.
The same fail-cold state is applied on clean shutdown.
**This does not make the system safe to leave unattended.** It is a hobby
project driving a fire with reverse-engineered numbers. Keep a real probe in
the cook until you have several successful runs behind you.
+179
View File
@@ -0,0 +1,179 @@
# Protocol notes
What we know about each probe vendor, and how much to trust it. Everything here
is community reverse engineering unless marked otherwise — none of it is vendor
documentation except where linked.
---
## MEATER / MEATER+ / MEATER 2 — implemented, unverified
Source: [`sources/meater.py`](../src/smokescreen/sources/meater.py)
Derived from [nathanfaber/meaterble](https://github.com/nathanfaber/meaterble)
and the Home Assistant MEATER BLE work.
| Item | Value |
|---|---|
| Service UUID | `a75cc7fc-c956-488f-ac2a-2dbc08b63a04` |
| Temperature char | `7edda774-045e-4bbf-909b-45d1991a2876` |
| Battery char | `2adb4877-68d8-4884-bd3c-d83853bf27b8` |
Temperature payload is little-endian `uint16` fields:
```
[0:2] tip raw → tip_c = (raw + 8) / 16
[2:4] ambient raw
[4:6] ambient offset
→ ambient_counts = tip + max(0, ((ra - min(48, oa)) * 16 * 589) // 1487)
→ ambient_c = (ambient_counts + 8) / 16
```
Battery is `uint16 * 10`, clamped to 0100.
**Confidence:** tip decode is simple and well corroborated across projects —
trust it after one bench check. The **ambient decode is an empirical fit** that
its original authors flagged as hard to reproduce across tip/ambient
combinations. Treat ambient as advisory. We drive the grill from `tip_c` only.
**Operational limit:** a MEATER probe accepts **exactly one** BLE connection.
While the Pi holds it, your phone and the Block cannot see it, and vice versa.
Plan on the Pi owning the probe for the duration of a cook.
**Before trusting it:** put the probe in ice water and boiling water, compare
against the MEATER app.
---
## ThermoWorks RFX — implemented, cloud-only
Source: [`sources/thermoworks.py`](../src/smokescreen/sources/thermoworks.py)
RFX probes transmit **sub-1 GHz RF** to an **RFX Gateway**, which forwards over
WiFi to ThermoWorks Cloud. The probes never speak BLE, so the nRF52840 dongle
cannot see them at all.
Implemented against [`thermoworks-cloud`](https://github.com/a2hill/python-thermoworks-cloud)
(a2hill), an unofficial library built from the observed behaviour of the
ThermoWorks web client. Explicitly tested with RFX by its author, and confirmed
working by Home Assistant users through a 24-hour brisket cook.
**Licensing:** it is GPLv3, so it is an optional extra
(`pip install '.[thermoworks]'`) rather than a core dependency. That keeps the
GPL off this project unless you opt in.
### API shape
```
get_user() -> user.account_id
get_devices(account_id) -> [Device(serial, label, battery, ...)]
get_device_channel(serial, "1") -> DeviceChannel(value, units, last_telemetry_saved, ...)
```
There is **no channel listing endpoint**. Channels are discovered by requesting
ids 1..9 until one 404s — the same approach the Home Assistant integration
takes. We cache the result per device rather than re-probing every cycle, since
each attempt is a request against an unofficial API.
### The thing that will bite you
**ThermoWorks Cloud returns a dead probe's last known value with a perfectly
healthy HTTP 200.** A probe that fell off an hour ago still reports a
temperature, and nothing in the response status hints that anything is wrong.
So a successful fetch tells you nothing about whether the number is current. We
date every reading by the cloud's own `last_telemetry_saved` / `last_seen`
timestamp and backdate `ProbeReading.timestamp` accordingly, so the normal
staleness path measures the age of the *measurement* rather than the age of our
HTTP request. Readings older than `max_cloud_age_s` (default 300 s) are dropped
outright.
Without that backdating, the fail-cold policy would never trip for a cloud
source, and a jack would sit there confidently displaying an hour-old
temperature.
### Practical limits
| | |
|---|---|
| Polling | 60 s works; the HA integration ships a useless 1800 s default |
| Latency | probe → gateway → cloud → here. Fine for low-and-slow, poor for searing |
| Dependency | your internet is in the loop mid-cook; if it drops, jacks fail cold |
| Free tier | up to 10 cloud devices; 11+ needs a paid plan |
### Direct RF — not available
Receiving the sub-1 GHz signal directly with an RTL-SDR would remove both the
cloud and the gateway. It was
[requested in rtl_433 (#3041)](https://github.com/merbanan/rtl_433/issues/3041)
in August 2024 and **closed with no decoder written**. No prior art to build on.
One Home Assistant user noticed the RFX Gateway enumerates as an **Espressif
device — it is an ESP32**, which hints at a possible local API. Nobody has
explored it. That is the highest-value lead if the cloud dependency becomes
annoying.
### Other ThermoWorks families
Not handled by this driver. **Node** is BLE and would want something shaped like
the MEATER driver. **Signals** is a different WiFi/cloud device — the same
`thermoworks-cloud` library covers it, but channel semantics differ.
---
## Combustion Inc — not implemented, but the easiest of the three
Source: [`sources/combustion.py`](../src/smokescreen/sources/combustion.py)
The only vendor here with an **open published protocol** and first-party SDKs:
<https://github.com/combustion-inc/combustion-documentation>
Best-fit for this project for one specific reason: probes broadcast temperatures
in **BLE advertisements**, so you can read them without connecting. That
sidesteps MEATER's one-connection-at-a-time problem entirely — the Pi can listen
while your phone stays connected to the same probe.
Notes to verify against the spec before implementing:
- Service UUID `00000100-CAAB-3792-3D44-97AE51C1407A`
- 8 temperature sensors (T1 tip → T8 handle), packed as 8 × 13-bit LE = 13 bytes
- `celsius = raw * 0.05 - 20`
- Use the **virtual core** sensor, not raw T1 — core is Combustion's corrected
food temperature, T1 is just the physical tip
Deliberately left raising `NotImplementedError` rather than shipping a parser
written from memory. A 13-bit unpack that's off by one bit boundary produces
confident garbage, and the grill acts on these numbers.
---
## The wireless Traeger probes — the road not taken
Documented so it isn't re-litigated later.
The 2022 Ironwood also supports "Traeger Wireless Meat Probes", which are
**rebranded MEATER hardware** — Traeger acquired Apption Labs (MEATER) in 2021.
But Traeger deliberately changed the pairing: plain MEATER probes will not pair
to an Ironwood, and Traeger probes will not pair to the MEATER app. That is an
intentional auth/whitelist boundary, not an accident.
Reasons this project drives the wired jacks instead:
1. **It's a real auth boundary.** The cross-pairing block is deliberate, so
expect challenge-response, not just a different service UUID.
2. **Firmware updates.** A wired PT1000 is a physical standard; a BLE pairing
handshake is a moving target Traeger can change at will.
3. **One dongle can't MITM.** A true man-in-the-middle needs two radios — one
central to the real probe, one peripheral to the grill. A single nRF52840 is
one radio. You'd need two dongles, or the Pi's onboard BLE as the second side.
4. **MITM was never the goal anyway.** Sitting between a real Traeger probe and
the grill only relays temperatures you already have. To inject *your* probes
you want **emulation**, and emulation is exactly what the wired jack gives
you for free.
If you ever do revisit it, the nRF52840 dongle's real job there is
[nRF Sniffer for BLE](https://www.nordicsemi.com/Products/Development-tools/nRF-Sniffer-for-Bluetooth-LE)
— capture a genuine probe pairing to Wireshark and start from the handshake.
In the meantime the dongle earns its place as the BLE central for your
third-party probes, with a better antenna than the Pi's onboard radio.