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:
@@ -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 0–100.
|
||||
|
||||
**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.
|
||||
Reference in New Issue
Block a user