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
+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
build/
dist/
# Local, contains your calibration constants and probe ids
config.yaml
+112
View File
@@ -0,0 +1,112 @@
# smokescreen
Feed third-party BBQ probes (MEATER, ThermoWorks, Combustion) into a pellet
grill's **wired probe jacks**, so they show up as native probes on the grill
display and in the vendor app — with working target-temp alarms and Keep Warm.
Developed and tested against a **Traeger Ironwood (2022 touchscreen WiFire)**,
but the technique is just PT1000 emulation, so it should carry to any grill
using the same 3.5 mm PT1000 jacks — Pit Boss, Camp Chef, GMG and friends.
*The name is the mechanism: the grill is looking at a probe that isn't there.*
## How it works
```
your probes ──BLE──> Raspberry Pi ──I²C──> AD5272 rheostat ──3.5mm──> Traeger jacks 1 & 2
```
The Ironwood's wired jacks take **PT1000 RTDs** — 1000 Ω at 0 °C, ~3.85 Ω/°C, a
published standard curve. So instead of breaking Traeger's wireless pairing, we
just *be* a PT1000: read your probe over BLE, compute the resistance a real
PT1000 would have at that temperature, and present it to the jack.
No protocol to reverse, nothing to re-break after a firmware update, and the
grill's own logic works because as far as it knows, nothing unusual happened.
Why not MITM the wireless probes — and why one nRF52840 dongle can't anyway — is
in [docs/PROTOCOL_NOTES.md](docs/PROTOCOL_NOTES.md#the-wireless-traeger-probes--the-road-not-taken).
## Start here
**Before writing code or buying parts**, do
[Step 0 in docs/HARDWARE.md](docs/HARDWARE.md#step-0--prove-the-concept-for-0-before-buying-anything):
put a plain 1.2 kΩ resistor in a 3.5 mm plug, plug it into jack 1, and check the
grill reads ~127 °F. That one experiment validates this entire design in ten
minutes with a multimeter and a junk-drawer resistor.
## Quick start
```bash
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
```
Run the whole pipeline with no hardware at all — simulated probes, no I²C writes:
```bash
cp config.example.yaml config.yaml && .venv/bin/smokescreen run --dry-run
```
```
jack1=20.3C/1079ohm [Simulated probe 1] jack2=17.2C/1067ohm [Simulated probe 2]
```
The PT1000 reference table, for bench work with a multimeter:
```bash
.venv/bin/smokescreen table
```
On the Pi, with hardware wired and calibrated:
```bash
.venv/bin/smokescreen run
```
## Adding a probe vendor
Subclass `ProbeSource`, emit `ProbeReading` objects, register it in
`sources/__init__.py`. Nothing else changes — the router and the PT1000
emulator never learn what hardware produced a number.
| Source | Status |
|---|---|
| `simulator` | Works. Synthetic cook curve with a stall, for development |
| `thermoworks` | RFX via ThermoWorks Cloud. Needs `pip install '.[thermoworks]'` |
| `meater` | Implemented from community reverse engineering — **verify on the bench** |
| `combustion` | Stub. Open published spec, easiest to finish |
With more probes than jacks — four RFX probes into two jacks — list what's
visible and pin the two you want:
```bash
.venv/bin/smokescreen discover
```
Unimplemented sources raise rather than returning plausible-looking numbers.
The grill acts on these values, so a driver that silently invents data would be
worse than one that refuses to start.
## Safety
Stale, missing, or implausible readings drive the channel **cold**, never hot. A
stuck-hot channel could convince the grill a cook finished early; a stuck-cold
one can only look obviously wrong. Details and the reasoning are in
[docs/HARDWARE.md](docs/HARDWARE.md#failure-behaviour).
This 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.
## Layout
```
src/smokescreen/
pt1000.py IEC 60751 curve math — the part that must be right
models.py ProbeReading, ChannelState
router.py which probe drives which jack
bridge.py orchestration loop
sources/ probe backends (add vendors here)
outputs/ rheostat driver + PT1000 emulator
docs/HARDWARE.md wiring, BOM, calibration, Step 0
docs/PROTOCOL_NOTES.md what we know per vendor, and how much to trust it
```
+61
View File
@@ -0,0 +1,61 @@
# Copy to config.yaml and edit.
#
# Start with the simulator source and --dry-run to exercise the pipeline with
# no hardware at all, then swap in real sources once probes are talking.
#
# Find your probe ids with: smokescreen discover
update_interval_s: 2.0 # how often the jacks are re-driven
i2c_bus: 1 # /dev/i2c-1 on a Raspberry Pi
log_level: INFO
sources:
# Synthetic cook curve. Safe to leave in while developing; remove for real cooks.
- type: simulator
probe_count: 2
speed: 60 # 60x realtime, so a brisket runs in minutes
# ThermoWorks RFX, via ThermoWorks Cloud (there is no local gateway API).
# Needs: pip install '.[thermoworks]'
# Credentials come from THERMOWORKS_EMAIL / THERMOWORKS_PASSWORD in the
# environment. Prefer that over putting them here — config.yaml is gitignored,
# but it is still one `git add -f` away from being committed.
# - type: thermoworks
# poll_interval_s: 60 # 60s is what works in practice; faster just burns
# # requests against an unofficial API
# max_cloud_age_s: 300 # drop cloud values older than this, since the API
# # serves a dead probe's last value with a 200
# MEATER / MEATER 2. Remember the probe accepts only ONE BLE connection --
# the Pi cannot read it while your phone or the Block is connected.
# Needs: pip install '.[ble]'
# - type: meater
# display_name: "Brisket point"
# address: null # null = scan and take the first probe found
# Not implemented yet -- see the module docstring for what it needs.
# - type: combustion
jacks:
1:
# Pin a specific probe here once you know its id. With more probes than
# jacks (4 RFX probes, 2 jacks) pinning is how you choose which two matter.
# Omit to auto-assign the first probe seen.
# probe_id: "thermoworks:RFX123456:1"
i2c_address: 0x2f
# Must comfortably exceed your slowest source's poll interval, or the jack
# flaps cold between polls. Rule of thumb: 3x. For 60s cloud polling use
# 180s+; for BLE sources 30s is fine.
stale_after_s: 180
# Replace with output from: smokescreen calibrate ...
calibration:
r_offset_ohms: 1000.0
step_ohms: 1.0
2:
# probe_id: "thermoworks:RFX654321:1"
i2c_address: 0x2c # second rheostat MUST be strapped to a different address
stale_after_s: 180
calibration:
r_offset_ohms: 1000.0
step_ohms: 1.0
+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.
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "smokescreen"
version = "0.1.0"
description = "Feed third-party BBQ probes into a pellet grill's wired PT1000 probe jacks"
requires-python = ">=3.11"
dependencies = [
"pyyaml>=6.0",
]
[project.optional-dependencies]
# BLE probe sources (MEATER, Combustion). Not needed for --dry-run development.
ble = ["bleak>=0.22"]
# ThermoWorks RFX via ThermoWorks Cloud. Kept optional partly because it is
# GPLv3, which would otherwise attach to this whole project.
thermoworks = ["thermoworks-cloud>=0.1.16", "aiohttp>=3.9"]
# I2C rheostat control. Raspberry Pi only.
hw = ["smbus2>=0.4"]
dev = ["pytest>=8.0", "pytest-asyncio>=0.24"]
[project.scripts]
smokescreen = "smokescreen.__main__:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+3
View File
@@ -0,0 +1,3 @@
"""Feed third-party BBQ probes into a Traeger Ironwood's wired probe jacks."""
__version__ = "0.1.0"
+165
View File
@@ -0,0 +1,165 @@
"""Command line entry point."""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from .bridge import Bridge, discover_probes
from .config import Config
from .outputs.pt1000_emulator import ChannelCalibration
from .pt1000 import (
c_to_f,
f_to_c,
resistance_to_temp_c,
resolution_c_per_ohm,
temp_c_to_resistance,
)
def cmd_run(args: argparse.Namespace) -> int:
config = Config.load(args.config)
logging.getLogger().setLevel(args.log_level or config.log_level)
bridge = Bridge(config, dry_run=args.dry_run)
try:
asyncio.run(bridge.run())
except KeyboardInterrupt:
print("\nstopped", file=sys.stderr)
return 0
def cmd_discover(args: argparse.Namespace) -> int:
"""List every probe the configured sources can see, with its id."""
config = Config.load(args.config)
seen = asyncio.run(discover_probes(config, duration_s=args.seconds))
if not seen:
print("No probes found. Check that probes are awake and in range.")
return 1
print(f"Found {len(seen)} probe(s):\n")
for probe_id, reading in sorted(seen.items()):
temp = f"{reading.tip_c:.1f}°C / {c_to_f(reading.tip_c):.0f}°F"
battery = f" battery {reading.battery_pct}%" if reading.battery_pct else ""
print(f" {probe_id}")
print(f" {reading.name}{temp}{battery}")
jacks = len(config.jacks)
if len(seen) > jacks:
print(
f"\n{len(seen)} probes but only {jacks} jack(s). Pin the ones you want "
"by adding probe_id under each jack in config.yaml:"
)
for jack, probe_id in zip(
(j.jack for j in config.jacks), sorted(seen), strict=False
):
print(f' {jack}:\n probe_id: "{probe_id}"')
return 0
def cmd_table(args: argparse.Namespace) -> int:
"""Print the PT1000 reference table for bench work with a multimeter."""
print(f"{'°F':>7} {'°C':>7} {'ohms':>9}")
print("-" * 27)
temp_f = args.start_f
while temp_f <= args.stop_f + 1e-9:
temp_c = f_to_c(temp_f)
print(
f"{temp_f:7.0f} {temp_c:7.1f} {temp_c_to_resistance(temp_c):9.1f}"
)
temp_f += args.step_f
print()
print(f"resolution near 100°C: {resolution_c_per_ohm(100.0):.3f} °C per ohm")
return 0
def cmd_calibrate(args: argparse.Namespace) -> int:
"""Solve a channel calibration from two measured (code, ohms) points.
Bench procedure: command the wiper to a low code and a high code, measure
the resistance across the jack plug with a multimeter each time, then feed
both points in here. See docs/HARDWARE.md.
"""
calibration = ChannelCalibration.from_two_points(
args.code_a, args.ohms_a, args.code_b, args.ohms_b
)
lo = calibration.ohms_for_code(0)
hi = calibration.ohms_for_code(args.max_code)
print("Paste into config.yaml under this jack:\n")
print(" calibration:")
print(f" r_offset_ohms: {calibration.r_offset_ohms:.3f}")
print(f" step_ohms: {calibration.step_ohms:.4f}")
print()
print(f" resistance range : {lo:.1f} - {hi:.1f} ohm")
try:
lo_c, hi_c = resistance_to_temp_c(lo), resistance_to_temp_c(hi)
print(
f" temperature range: {lo_c:.1f} - {hi_c:.1f} °C "
f"({c_to_f(lo_c):.0f} - {c_to_f(hi_c):.0f} °F)"
)
except ValueError:
print(" temperature range: OUT OF PT1000 RANGE — check your fixed resistor")
print(f" step size : {calibration.step_ohms:.3f} ohm "
f"({calibration.step_ohms * resolution_c_per_ohm(100.0):.3f} °C)")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="smokescreen",
description="Feed third-party BBQ probes into a Traeger's wired probe jacks.",
)
parser.add_argument("--log-level", default=None, help="DEBUG, INFO, WARNING, ...")
subparsers = parser.add_subparsers(dest="command", required=True)
run_parser = subparsers.add_parser("run", help="run the bridge")
run_parser.add_argument("-c", "--config", default="config.yaml")
run_parser.add_argument(
"--dry-run",
action="store_true",
help="do everything except write to the I2C rheostats",
)
run_parser.set_defaults(func=cmd_run)
discover_parser = subparsers.add_parser(
"discover", help="list visible probes and their ids, for pinning to jacks"
)
discover_parser.add_argument("-c", "--config", default="config.yaml")
discover_parser.add_argument(
"--seconds", type=float, default=20.0, help="how long to listen"
)
discover_parser.set_defaults(func=cmd_discover)
table_parser = subparsers.add_parser(
"table", help="print the PT1000 temperature/resistance table"
)
table_parser.add_argument("--start-f", type=float, default=32.0)
table_parser.add_argument("--stop-f", type=float, default=400.0)
table_parser.add_argument("--step-f", type=float, default=20.0)
table_parser.set_defaults(func=cmd_table)
cal_parser = subparsers.add_parser(
"calibrate", help="solve a channel calibration from two measured points"
)
cal_parser.add_argument("--code-a", type=int, required=True)
cal_parser.add_argument("--ohms-a", type=float, required=True)
cal_parser.add_argument("--code-b", type=int, required=True)
cal_parser.add_argument("--ohms-b", type=float, required=True)
cal_parser.add_argument("--max-code", type=int, default=1023)
cal_parser.set_defaults(func=cmd_calibrate)
args = parser.parse_args(argv)
logging.basicConfig(
level=args.log_level or "INFO",
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
+184
View File
@@ -0,0 +1,184 @@
"""Main orchestration loop.
Sources run as independent supervised tasks pushing readings into the router.
A separate ticker drives the jacks on a fixed cadence, decoupled from probe
arrival rate: BLE notifications are bursty and per-probe, but the grill wants a
steady, well-behaved resistance rather than one that jitters on every packet.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from .config import Config
from .models import ProbeReading
from .outputs.pt1000_emulator import PT1000Emulator
from .outputs.rheostat import AD5272Rheostat, MockRheostat, ResistanceChannel
from .router import ProbeRouter
from .sources import BackoffSupervisor, build_source
log = logging.getLogger(__name__)
def build_channel(
config: Config, i2c_address: int, *, dry_run: bool
) -> ResistanceChannel:
if dry_run:
return MockRheostat()
return AD5272Rheostat(i2c_address=i2c_address, bus_number=config.i2c_bus)
async def discover_probes(
config: Config, duration_s: float = 20.0
) -> dict[str, ProbeReading]:
"""Run every configured source briefly and report the probes it sees.
Exists because you cannot pin a probe to a jack without knowing its id, and
ids are BLE addresses and cloud serials that nobody has memorised. More
probes than jacks is the normal case, not an edge case -- four RFX probes
into two jacks means picking two, and picking requires seeing the list.
"""
seen: dict[str, ProbeReading] = {}
sources = [build_source(e.type, **e.options) for e in config.sources]
def collect(reading: ProbeReading) -> None:
seen[reading.probe_id] = reading
tasks = [
asyncio.create_task(BackoffSupervisor(source, collect).run())
for source in sources
]
try:
await asyncio.wait_for(asyncio.gather(*tasks), timeout=duration_s)
except (asyncio.TimeoutError, asyncio.CancelledError):
pass
finally:
for task in tasks:
task.cancel()
for task in tasks:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
for source in sources:
with contextlib.suppress(Exception):
await source.close()
return seen
class Bridge:
def __init__(self, config: Config, *, dry_run: bool = False) -> None:
self.config = config
self.dry_run = dry_run
self.emulators: dict[int, PT1000Emulator] = {}
for jack_config in config.jacks:
channel = build_channel(
config, jack_config.i2c_address, dry_run=dry_run
)
self.emulators[jack_config.jack] = PT1000Emulator(
jack=jack_config.jack,
channel=channel,
calibration=jack_config.calibration,
stale_after_s=jack_config.stale_after_s,
)
self.router = ProbeRouter(
jacks=[j.jack for j in config.jacks],
pinned={j.jack: j.probe_id for j in config.jacks if j.probe_id},
)
self.sources = [
build_source(entry.type, **entry.options) for entry in config.sources
]
self._warn_on_tight_stale_windows()
def _warn_on_tight_stale_windows(self) -> None:
"""Catch the classic misconfiguration: fail-cold faster than we poll.
A cloud source polling every 60s against a 30s stale window would flap
the jacks cold on every cycle. Cheap to detect here, maddening to debug
standing next to a hot grill.
"""
slowest = max((s.poll_interval_s for s in self.sources), default=0.0)
for emulator in self.emulators.values():
if emulator.stale_after_s < slowest * 2.0:
log.warning(
"jack %d stale_after_s=%.0f is tight for a source polling "
"every %.0fs; expect spurious fail-cold. Use at least %.0f.",
emulator.jack,
emulator.stale_after_s,
slowest,
slowest * 3.0,
)
def _on_reading(self, reading: ProbeReading) -> None:
jack = self.router.observe(reading)
if jack is None:
log.debug("reading from unbound probe %s", reading.name)
async def _tick_forever(self) -> None:
while True:
for jack, emulator in self.emulators.items():
reading = self.router.reading_for(jack)
try:
emulator.update(reading)
except Exception:
log.exception("jack %d update failed", jack)
emulator.fail_cold("update raised")
self._log_status()
await asyncio.sleep(self.config.update_interval_s)
def _log_status(self) -> None:
if not log.isEnabledFor(logging.INFO):
return
parts = []
for jack, emulator in sorted(self.emulators.items()):
state = emulator.state
if state.stale or state.target_temp_c is None:
parts.append(f"jack{jack}=FAULT({state.error})")
else:
name = state.reading.name if state.reading else "?"
parts.append(
f"jack{jack}={state.target_temp_c:.1f}C"
f"/{state.applied_ohms:.0f}ohm [{name}]"
)
log.info(" ".join(parts))
async def run(self) -> None:
log.info(
"starting bridge: %d source(s), jacks %s%s",
len(self.sources),
sorted(self.emulators),
" (DRY RUN, no I2C writes)" if self.dry_run else "",
)
tasks = [
asyncio.create_task(
BackoffSupervisor(source, self._on_reading).run(),
name=f"source:{source.name}",
)
for source in self.sources
]
tasks.append(asyncio.create_task(self._tick_forever(), name="ticker"))
try:
await asyncio.gather(*tasks)
except asyncio.CancelledError:
raise
finally:
for task in tasks:
task.cancel()
for task in tasks:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
await self.aclose()
async def aclose(self) -> None:
for source in self.sources:
with contextlib.suppress(Exception):
await source.close()
for emulator in self.emulators.values():
with contextlib.suppress(Exception):
emulator.close()
log.info("bridge stopped; all jacks driven to fail-cold")
+103
View File
@@ -0,0 +1,103 @@
"""Configuration loading and validation."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from .outputs.pt1000_emulator import ChannelCalibration
# The Ironwood exposes two wired probe jacks, labelled 1 and 2 on the display.
VALID_JACKS = (1, 2)
@dataclass(slots=True)
class SourceConfig:
type: str
options: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class JackConfig:
jack: int
probe_id: str | None = None
"""Exact probe id to bind. ``None`` means auto-assign on first sight."""
calibration: ChannelCalibration = field(default_factory=ChannelCalibration)
i2c_address: int = 0x2F
stale_after_s: float = 30.0
@dataclass(slots=True)
class Config:
sources: list[SourceConfig] = field(default_factory=list)
jacks: list[JackConfig] = field(default_factory=list)
update_interval_s: float = 2.0
i2c_bus: int = 1
log_level: str = "INFO"
@classmethod
def load(cls, path: str | Path) -> "Config":
path = Path(path)
if not path.exists():
raise FileNotFoundError(
f"no config at {path}; copy config.example.yaml to get started"
)
with path.open() as handle:
raw = yaml.safe_load(handle) or {}
return cls.from_dict(raw)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Config":
sources = []
for entry in raw.get("sources") or []:
options = dict(entry)
type_name = options.pop("type", None)
if not type_name:
raise ValueError(f"source entry missing 'type': {entry!r}")
sources.append(SourceConfig(type=type_name, options=options))
jacks = []
for key, entry in (raw.get("jacks") or {}).items():
jack = int(key)
if jack not in VALID_JACKS:
raise ValueError(
f"jack {jack} is not valid; the Ironwood has jacks "
f"{' and '.join(map(str, VALID_JACKS))}"
)
entry = entry or {}
cal_raw = entry.get("calibration") or {}
jacks.append(
JackConfig(
jack=jack,
probe_id=entry.get("probe_id"),
calibration=ChannelCalibration(
r_offset_ohms=float(cal_raw.get("r_offset_ohms", 1000.0)),
step_ohms=float(cal_raw.get("step_ohms", 1.0)),
),
i2c_address=int(entry.get("i2c_address", 0x2F)),
stale_after_s=float(entry.get("stale_after_s", 30.0)),
)
)
if not sources:
raise ValueError("config defines no sources; nothing would be read")
if not jacks:
raise ValueError("config defines no jacks; nothing would be driven")
addresses = [j.i2c_address for j in jacks]
if len(set(addresses)) != len(addresses):
raise ValueError(
"each jack needs its own rheostat at a distinct I2C address; "
f"got {[hex(a) for a in addresses]}"
)
return cls(
sources=sources,
jacks=sorted(jacks, key=lambda j: j.jack),
update_interval_s=float(raw.get("update_interval_s", 2.0)),
i2c_bus=int(raw.get("i2c_bus", 1)),
log_level=str(raw.get("log_level", "INFO")),
)
+55
View File
@@ -0,0 +1,55 @@
"""Core data types passed between probe sources and grill outputs."""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True, slots=True)
class ProbeReading:
"""One sample from one probe channel.
Every source normalises to this shape, so the router and the PT1000
emulator never need to know what hardware produced the number.
Temperatures are always Celsius -- convert at the display edge, not here.
"""
probe_id: str
"""Stable unique id, e.g. a BLE MAC or serial. Used for config mapping."""
tip_c: float
"""Internal / food temperature."""
source: str
"""Driver name that produced this, e.g. "meater"."""
ambient_c: float | None = None
"""Ambient / surface temperature, if the probe reports one."""
battery_pct: int | None = None
rssi: int | None = None
display_name: str | None = None
timestamp: float = field(default_factory=time.monotonic)
extra: dict[str, Any] = field(default_factory=dict)
@property
def name(self) -> str:
return self.display_name or self.probe_id
def age_s(self, now: float | None = None) -> float:
return (time.monotonic() if now is None else now) - self.timestamp
@dataclass(slots=True)
class ChannelState:
"""What we are currently driving onto one grill probe jack."""
jack: int
target_temp_c: float | None = None
applied_ohms: float | None = None
reading: ProbeReading | None = None
stale: bool = True
error: str | None = None
+12
View File
@@ -0,0 +1,12 @@
"""Grill-facing outputs: things that present data to the Traeger."""
from .pt1000_emulator import ChannelCalibration, PT1000Emulator
from .rheostat import AD5272Rheostat, MockRheostat, ResistanceChannel
__all__ = [
"AD5272Rheostat",
"ChannelCalibration",
"MockRheostat",
"PT1000Emulator",
"ResistanceChannel",
]
+156
View File
@@ -0,0 +1,156 @@
"""Turn a temperature into the resistance a Traeger probe jack expects.
The resistance network per channel is::
jack tip ---[ R_fixed (precision) ]---[ AD5272 wiper ]--- jack sleeve
so total resistance is ``r_offset + code * step_ohms``, where ``r_offset``
folds in the fixed resistor plus the rheostat's own wiper resistance. Both
constants come from calibration rather than datasheet nominals, because that
is the only way to cancel part tolerance.
FAIL-COLD POLICY: when a probe goes stale we drive the channel to its MINIMUM
resistance, i.e. an implausibly cold reading. This is deliberate. A stuck-hot
channel could convince the grill the food hit its target and trigger Keep Warm
or shutdown mid-cook; a stuck-cold channel can only ever look obviously wrong.
Failures should be visible, never mistaken for success.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from ..models import ChannelState, ProbeReading
from ..pt1000 import (
is_plausible,
resistance_to_temp_c_clamped,
temp_c_to_resistance,
)
from .rheostat import ResistanceChannel
log = logging.getLogger(__name__)
@dataclass(slots=True)
class ChannelCalibration:
"""Measured constants for one channel's resistance network.
Defaults describe the reference build: a 1k 0.1% fixed resistor in series
with an AD5272BRMZ-1. They are a starting point, not a substitute for
running ``smokescreen calibrate``.
"""
r_offset_ohms: float = 1000.0
step_ohms: float = 1.0
def code_for_ohms(self, ohms: float, max_code: int) -> int:
raw = round((ohms - self.r_offset_ohms) / self.step_ohms)
return max(0, min(max_code, int(raw)))
def ohms_for_code(self, code: int) -> float:
return self.r_offset_ohms + code * self.step_ohms
@classmethod
def from_two_points(
cls, code_a: int, ohms_a: float, code_b: int, ohms_b: float
) -> "ChannelCalibration":
"""Solve the line through two measured (wiper code, ohms) points."""
if code_a == code_b:
raise ValueError("calibration points must use different wiper codes")
step = (ohms_b - ohms_a) / (code_b - code_a)
return cls(r_offset_ohms=ohms_a - code_a * step, step_ohms=step)
class PT1000Emulator:
"""Drives one grill probe jack to mimic a PT1000 at a chosen temperature."""
def __init__(
self,
jack: int,
channel: ResistanceChannel,
calibration: ChannelCalibration | None = None,
*,
stale_after_s: float = 30.0,
) -> None:
self.jack = jack
self.channel = channel
self.calibration = calibration or ChannelCalibration()
self.stale_after_s = stale_after_s
self.state = ChannelState(jack=jack)
@property
def min_ohms(self) -> float:
return self.calibration.ohms_for_code(0)
@property
def max_ohms(self) -> float:
return self.calibration.ohms_for_code(self.channel.max_code)
def temperature_range_c(self) -> tuple[float, float]:
"""Actual temperature span this channel's hardware can express."""
return (
resistance_to_temp_c_clamped(self.min_ohms),
resistance_to_temp_c_clamped(self.max_ohms),
)
def apply_temperature(self, temp_c: float) -> float:
"""Drive the jack to ``temp_c``. Returns the temperature truly applied.
The return value differs from the request when the temperature falls
outside the hardware's resistance window or lands between wiper steps.
Callers should log the difference rather than assume it is zero.
"""
if not is_plausible(temp_c):
raise ValueError(f"implausible temperature {temp_c!r} for jack {self.jack}")
wanted_ohms = temp_c_to_resistance(temp_c)
code = self.calibration.code_for_ohms(wanted_ohms, self.channel.max_code)
self.channel.set_code(code)
applied_ohms = self.calibration.ohms_for_code(code)
self.state.applied_ohms = applied_ohms
self.state.target_temp_c = temp_c
self.state.error = None
return resistance_to_temp_c_clamped(applied_ohms)
def update(self, reading: ProbeReading | None, now: float | None = None) -> None:
"""Feed the latest reading for this jack, applying the stale policy."""
if reading is None:
self.fail_cold("no probe assigned")
return
age = reading.age_s(now)
if age > self.stale_after_s:
self.fail_cold(f"reading {age:.0f}s stale (limit {self.stale_after_s:.0f}s)")
return
if not is_plausible(reading.tip_c):
self.fail_cold(f"implausible reading {reading.tip_c!r}")
return
applied = self.apply_temperature(reading.tip_c)
self.state.reading = reading
self.state.stale = False
if abs(applied - reading.tip_c) > 0.5:
log.warning(
"jack %d clamped %.1fC -> %.1fC (hardware range %.1f..%.1fC)",
self.jack,
reading.tip_c,
applied,
*self.temperature_range_c(),
)
def fail_cold(self, reason: str) -> None:
"""Drive to minimum resistance so the fault reads as obviously wrong."""
if not self.state.stale or self.state.error != reason:
log.warning("jack %d failing cold: %s", self.jack, reason)
self.channel.set_code(0)
self.state.applied_ohms = self.min_ohms
self.state.target_temp_c = None
self.state.stale = True
self.state.error = reason
def close(self) -> None:
self.fail_cold("shutting down")
self.channel.close()
+120
View File
@@ -0,0 +1,120 @@
"""Programmable resistance backends.
The AD5272 is a 1024-position I2C digital rheostat. The 1k ohm variant
(AD5272BRMZ-1) gives ~1 ohm per step, which is about 0.26 C -- finer than the
grill displays -- and its +/-1% end-to-end tolerance is far better than the
+/-20% typical of ordinary digipots. Put a precision fixed resistor in series
to shift the window onto the PT1000 range. See docs/HARDWARE.md.
DATASHEET CHECK BEFORE FIRST USE: the register map and I2C address below are
written from the AD5272 datasheet, but confirm the address strapping on your
actual board -- vendors differ on whether ADDR is tied or floating.
"""
from __future__ import annotations
import abc
import logging
from typing import Any
log = logging.getLogger(__name__)
# AD5272 command codes, occupying bits [13:10] of the 16-bit control word.
CMD_NOP = 0x0
CMD_WRITE_RDAC = 0x1
CMD_READ_RDAC = 0x2
CMD_RESET = 0x4
CMD_WRITE_CONTROL = 0x7
CMD_READ_CONTROL = 0x8
CMD_SHUTDOWN = 0x9
# Control register bit 1 enables RDAC writes. It powers up CLEARED, so the
# wiper silently ignores writes until we set it -- the classic AD5272 gotcha.
CTRL_RDAC_WRITE_ENABLE = 0x02
AD5272_MAX_CODE = 1023
class ResistanceChannel(abc.ABC):
"""Something that can present a commanded resistance to the grill."""
@abc.abstractmethod
def set_code(self, code: int) -> None:
"""Drive the raw wiper code."""
@property
@abc.abstractmethod
def max_code(self) -> int: ...
def close(self) -> None: ...
class MockRheostat(ResistanceChannel):
"""Records commanded codes. Used by --dry-run and the tests."""
def __init__(self, max_code: int = AD5272_MAX_CODE) -> None:
self._max_code = max_code
self.code: int | None = None
self.history: list[int] = []
@property
def max_code(self) -> int:
return self._max_code
def set_code(self, code: int) -> None:
self.code = code
self.history.append(code)
class AD5272Rheostat(ResistanceChannel):
"""AD5272 digital rheostat over I2C."""
def __init__(
self,
i2c_address: int = 0x2F,
bus_number: int = 1,
bus: Any | None = None,
) -> None:
self.i2c_address = i2c_address
self._external_bus = bus is not None
if bus is None:
try:
from smbus2 import SMBus
except ImportError as exc: # pragma: no cover - import guard
raise RuntimeError(
"AD5272 needs smbus2: pip install '.[hw]'"
) from exc
bus = SMBus(bus_number)
self._bus = bus
self._unlock()
@property
def max_code(self) -> int:
return AD5272_MAX_CODE
def _write(self, command: int, data: int) -> None:
"""Send one 16-bit word: 4 command bits then 10 data bits."""
high = ((command & 0x0F) << 2) | ((data >> 8) & 0x03)
low = data & 0xFF
self._bus.write_byte_data(self.i2c_address, high, low)
def _unlock(self) -> None:
self._write(CMD_WRITE_CONTROL, CTRL_RDAC_WRITE_ENABLE)
log.debug("AD5272 @ 0x%02x: RDAC writes enabled", self.i2c_address)
def set_code(self, code: int) -> None:
if not 0 <= code <= AD5272_MAX_CODE:
raise ValueError(f"code {code} out of range 0..{AD5272_MAX_CODE}")
self._write(CMD_WRITE_RDAC, code)
def read_code(self) -> int:
self._write(CMD_READ_RDAC, 0)
high, low = self._bus.read_i2c_block_data(self.i2c_address, 0, 2)
return ((high & 0x03) << 8) | low
def close(self) -> None:
if not self._external_bus:
try:
self._bus.close()
except Exception:
log.debug("error closing I2C bus", exc_info=True)
+146
View File
@@ -0,0 +1,146 @@
"""PT1000 RTD math and resistance-network planning.
The Traeger wired probe jacks expect a PT1000 RTD: 1000 ohm at 0 C, roughly
3.85 ohm per degree C. To make the grill read an arbitrary temperature we have
to present the resistance that a real PT1000 would have at that temperature.
Conversions follow IEC 60751 (Callendar-Van Dusen, alpha = 0.00385), which is
the same curve the cheap PT1000 replacement probes on Amazon advertise.
Verify against a real probe before trusting it -- see docs/HARDWARE.md.
"""
from __future__ import annotations
import math
# IEC 60751 Callendar-Van Dusen coefficients for alpha = 0.00385 platinum.
CVD_A = 3.9083e-3
CVD_B = -5.775e-7
CVD_C = -4.183e-12 # only applies below 0 C
R0 = 1000.0 # PT1000 nominal resistance at 0 C, in ohms
# Sanity envelope. A food probe below -40 C or above 350 C is a fault, not a
# reading, and we should never drive the grill with it.
MIN_TEMP_C = -40.0
MAX_TEMP_C = 350.0
# Resistance window corresponding to that envelope, roughly 843-2297 ohm.
# Populated at import; see the bottom of this module.
MIN_OHMS: float
MAX_OHMS: float
def temp_c_to_resistance(temp_c: float, r0: float = R0) -> float:
"""Resistance in ohms that a PT1000 would show at ``temp_c``.
>>> round(temp_c_to_resistance(0), 2)
1000.0
>>> round(temp_c_to_resistance(100), 2)
1385.06
"""
if temp_c >= 0.0:
return r0 * (1.0 + CVD_A * temp_c + CVD_B * temp_c**2)
return r0 * (
1.0
+ CVD_A * temp_c
+ CVD_B * temp_c**2
+ CVD_C * (temp_c - 100.0) * temp_c**3
)
def resistance_to_temp_c(ohms: float, r0: float = R0) -> float:
"""Inverse of :func:`temp_c_to_resistance`.
Above 0 C this is an exact quadratic solve. Below 0 C the C term makes the
polynomial quartic, so we fall back to Newton refinement seeded by the
quadratic root -- accurate to well under 0.01 C across our range.
Raises ValueError for resistances outside the food-probe envelope. The
curve happily extrapolates far past anything physical -- 50 ohm "solves"
to about -235 C -- so an unguarded call turns a broken reading or a
miswired jack into a plausible-looking number instead of an error.
"""
if not math.isfinite(ohms) or not (MIN_OHMS <= ohms <= MAX_OHMS):
raise ValueError(
f"resistance {ohms} ohm is outside the PT1000 food-probe range "
f"{MIN_OHMS:.1f}-{MAX_OHMS:.1f} ohm ({MIN_TEMP_C}..{MAX_TEMP_C} C)"
)
# Quadratic root of r0 * (1 + A*t + B*t^2) = ohms
disc = CVD_A**2 - 4.0 * CVD_B * (1.0 - ohms / r0)
if disc < 0.0:
raise ValueError(f"resistance {ohms} ohm is not on the PT1000 curve")
temp = (-CVD_A + math.sqrt(disc)) / (2.0 * CVD_B)
if temp >= 0.0:
return temp
for _ in range(8):
err = temp_c_to_resistance(temp, r0) - ohms
# d/dt of the sub-zero polynomial
deriv = r0 * (
CVD_A
+ 2.0 * CVD_B * temp
+ CVD_C * (4.0 * temp**3 - 300.0 * temp**2)
)
if abs(deriv) < 1e-12:
break
step = err / deriv
temp -= step
if abs(step) < 1e-9:
break
return temp
def c_to_f(temp_c: float) -> float:
return temp_c * 9.0 / 5.0 + 32.0
def f_to_c(temp_f: float) -> float:
return (temp_f - 32.0) * 5.0 / 9.0
def is_plausible(temp_c: float) -> bool:
"""Reject NaN, infinities, and readings outside the food-probe envelope."""
return math.isfinite(temp_c) and MIN_TEMP_C <= temp_c <= MAX_TEMP_C
def required_span_ohms(
min_temp_c: float = 0.0, max_temp_c: float = 200.0
) -> tuple[float, float]:
"""Resistance window the emulator hardware has to cover.
Defaults to 0-200 C (32-392 F), which comfortably spans every cook you'd
put a meat probe in. Returns ``(min_ohms, max_ohms)``.
"""
return temp_c_to_resistance(min_temp_c), temp_c_to_resistance(max_temp_c)
def resolution_c_per_ohm(temp_c: float = 100.0) -> float:
"""How many degrees C one ohm of error costs you, near ``temp_c``.
Useful for sizing the rheostat: at ~0.26 C/ohm, a 1 ohm step is about
0.5 F, which is finer than the grill displays.
"""
delta = 0.5
r_hi = temp_c_to_resistance(temp_c + delta)
r_lo = temp_c_to_resistance(temp_c - delta)
return (2.0 * delta) / (r_hi - r_lo)
def resistance_to_temp_c_clamped(ohms: float) -> float:
"""Like :func:`resistance_to_temp_c` but saturates instead of raising.
For readback and diagnostics -- "what did we actually apply", "what range
can this board reach" -- where a bad calibration should show up as a
clamped number in a log line rather than an exception in the drive loop.
Never use it to interpret a reading; use the raising version for that.
"""
if not math.isfinite(ohms):
return MIN_TEMP_C
return resistance_to_temp_c(min(MAX_OHMS, max(MIN_OHMS, ohms)))
MIN_OHMS = temp_c_to_resistance(MIN_TEMP_C)
MAX_OHMS = temp_c_to_resistance(MAX_TEMP_C)
+82
View File
@@ -0,0 +1,82 @@
"""Decides which probe drives which grill jack.
Two binding modes, chosen per jack in config:
pinned -- ``probe_id`` is set, so only that probe ever drives that jack.
Use this once you know your probe ids and care which is which.
auto -- ``probe_id`` is null; the first unclaimed probe to appear takes
the jack and keeps it for the rest of the run. Convenient for
"just show me something" and for probes whose ids you have not
written down yet.
Bindings are sticky for the process lifetime. A probe that drops off and
reconnects reclaims its jack rather than shuffling to a different one, so a
mid-cook dropout never silently swaps which piece of meat you are watching.
"""
from __future__ import annotations
import logging
from .models import ProbeReading
log = logging.getLogger(__name__)
class ProbeRouter:
def __init__(self, jacks: list[int], pinned: dict[int, str] | None = None) -> None:
self.jacks = list(jacks)
self.pinned = dict(pinned or {})
unknown = set(self.pinned) - set(self.jacks)
if unknown:
raise ValueError(f"pinned bindings reference unknown jacks: {sorted(unknown)}")
duplicates = [
probe_id
for probe_id in set(self.pinned.values())
if list(self.pinned.values()).count(probe_id) > 1
]
if duplicates:
raise ValueError(f"probe(s) pinned to more than one jack: {duplicates}")
# jack -> probe_id, including auto-assignments once they happen.
self.bindings: dict[int, str] = dict(self.pinned)
self.latest: dict[str, ProbeReading] = {}
def observe(self, reading: ProbeReading) -> int | None:
"""Record a reading and return the jack it drives, if any."""
self.latest[reading.probe_id] = reading
for jack, probe_id in self.bindings.items():
if probe_id == reading.probe_id:
return jack
return self._auto_assign(reading)
def _auto_assign(self, reading: ProbeReading) -> int | None:
for jack in self.jacks:
if jack in self.pinned or jack in self.bindings:
continue
self.bindings[jack] = reading.probe_id
log.info("auto-assigned %s to jack %d", reading.name, jack)
return jack
log.debug(
"no free jack for %s; %d jack(s) already bound",
reading.name,
len(self.bindings),
)
return None
def reading_for(self, jack: int) -> ProbeReading | None:
probe_id = self.bindings.get(jack)
if probe_id is None:
return None
return self.latest.get(probe_id)
def unassigned(self) -> list[ProbeReading]:
"""Probes we can see but have nowhere to put -- worth surfacing."""
bound = set(self.bindings.values())
return [r for pid, r in self.latest.items() if pid not in bound]
+44
View File
@@ -0,0 +1,44 @@
"""Probe source registry.
Adding a probe vendor is: write a :class:`ProbeSource` subclass, import it
here, add it to ``SOURCE_TYPES``. Config then reaches it by ``type:`` name.
"""
from __future__ import annotations
from typing import Any
from .base import BackoffSupervisor, ProbeSource, ReadingSink, UnavailableSource
from .combustion import CombustionSource
from .meater import MeaterSource
from .simulator import SimulatorSource
from .thermoworks import ThermoWorksSource
SOURCE_TYPES: dict[str, type[ProbeSource]] = {
SimulatorSource.name: SimulatorSource,
MeaterSource.name: MeaterSource,
ThermoWorksSource.name: ThermoWorksSource,
CombustionSource.name: CombustionSource,
}
def build_source(type_name: str, **options: Any) -> ProbeSource:
"""Instantiate a source by config ``type:`` name."""
try:
cls = SOURCE_TYPES[type_name]
except KeyError:
known = ", ".join(sorted(SOURCE_TYPES))
raise ValueError(
f"unknown probe source type {type_name!r}; known types: {known}"
) from None
return cls(**options)
__all__ = [
"SOURCE_TYPES",
"BackoffSupervisor",
"ProbeSource",
"ReadingSink",
"UnavailableSource",
"build_source",
]
+105
View File
@@ -0,0 +1,105 @@
"""Probe source plugin interface.
A source discovers probes and emits :class:`ProbeReading` objects. It knows
nothing about the grill. Adding support for new hardware means writing one
subclass and registering it -- nothing else in the codebase changes.
Sources are long-lived asyncio tasks. ``run()`` should loop forever and is
expected to handle its own reconnection; the supervisor restarts it with
backoff if it raises.
"""
from __future__ import annotations
import abc
import asyncio
import logging
from typing import Any, Callable
from ..models import ProbeReading
log = logging.getLogger(__name__)
ReadingSink = Callable[[ProbeReading], None]
class ProbeSource(abc.ABC):
"""Base class for all probe backends."""
#: Short name used in config.yaml (``type: meater``) and in the registry.
name: str = "base"
#: Set False for sources that cannot work without real hardware present,
#: so `--dry-run` can skip them with a clear message instead of failing.
works_without_hardware: bool = False
def __init__(self, **options: Any) -> None:
self.options = options
self.poll_interval_s: float = float(options.get("poll_interval_s", 5.0))
@abc.abstractmethod
async def run(self, emit: ReadingSink) -> None:
"""Read probes forever, calling ``emit`` for every fresh sample."""
async def close(self) -> None:
"""Release hardware handles. Safe to call when never started."""
def __repr__(self) -> str:
return f"<{type(self).__name__} {self.name}>"
class UnavailableSource(ProbeSource):
"""Placeholder for a driver that is not implemented yet.
We deliberately do NOT fake readings here -- a source that silently invents
plausible temperatures would be worse than one that refuses to start, since
the whole point is driving a real fire with these numbers.
"""
def __init__(self, name: str, reason: str, **options: Any) -> None:
super().__init__(**options)
self.name = name
self.reason = reason
async def run(self, emit: ReadingSink) -> None:
log.error("source %r is not implemented: %s", self.name, self.reason)
raise NotImplementedError(f"{self.name}: {self.reason}")
class BackoffSupervisor:
"""Restarts a source when it dies, with capped exponential backoff."""
def __init__(
self,
source: ProbeSource,
emit: ReadingSink,
*,
initial_s: float = 1.0,
max_s: float = 60.0,
) -> None:
self.source = source
self.emit = emit
self.initial_s = initial_s
self.max_s = max_s
async def run(self) -> None:
delay = self.initial_s
while True:
try:
await self.source.run(self.emit)
# A clean return means "done"; nothing left to supervise.
return
except asyncio.CancelledError:
await self.source.close()
raise
except NotImplementedError:
# Never going to succeed on retry. Stop quietly.
return
except Exception:
log.exception(
"source %s crashed; retrying in %.0fs", self.source.name, delay
)
await asyncio.sleep(delay)
delay = min(delay * 2.0, self.max_s)
else:
delay = self.initial_s
+48
View File
@@ -0,0 +1,48 @@
"""Combustion Inc predictive thermometer source (BLE). NOT YET IMPLEMENTED.
Combustion is the most tractable of the three vendors: they publish an open
protocol spec and first-party SDKs, and probes broadcast temperatures in BLE
advertisements, so you can read them WITHOUT connecting. That sidesteps the
one-connection-at-a-time problem that MEATER has -- the Pi can listen while
your phone stays connected.
RESEARCH NOTES for whoever implements this (do not trust from memory, check
the spec first -- https://github.com/combustion-inc/combustion-documentation):
- Service UUID is 00000100-CAAB-3792-3D44-97AE51C1407A.
- Each probe has 8 temperature sensors (T1 at the tip through T8 at the
handle), packed as 8 x 13-bit little-endian values = 13 bytes total.
- Raw counts convert as celsius = raw * 0.05 - 20.
- Advertisements also carry probe serial, mode, and battery/virtual-sensor
state in a status byte.
- For the bridge we want the "virtual core" sensor, not raw T1, since T1 is
the physical tip and core is Combustion's corrected food temperature.
This module deliberately raises rather than guessing at the packing. A parser
that looks right but is off by a bit boundary would drive the grill with
garbage, and the grill acts on these numbers.
"""
from __future__ import annotations
from typing import Any
from .base import ProbeSource, ReadingSink
SERVICE_UUID = "00000100-caab-3792-3d44-97ae51c1407a"
_REASON = (
"Combustion decode is unimplemented. Implement the 8x13-bit advertisement "
"unpack against combustion-inc/combustion-documentation, verify against the "
"Combustion app, then remove this guard."
)
class CombustionSource(ProbeSource):
name = "combustion"
def __init__(self, **options: Any) -> None:
super().__init__(**options)
async def run(self, emit: ReadingSink) -> None:
raise NotImplementedError(_REASON)
+154
View File
@@ -0,0 +1,154 @@
"""MEATER / MEATER+ / MEATER 2 probe source (BLE).
PROVENANCE: the UUIDs and the temperature decode below are community reverse
engineering (nathanfaber/meaterble and the Home Assistant MEATER BLE work), not
vendor documentation. The tip decode is simple and well corroborated. The
ambient decode is an empirical fit that the original authors flagged as hard to
reproduce across tip/ambient combinations -- treat ambient as advisory and
verify against the MEATER app before you rely on it. See docs/PROTOCOL_NOTES.md.
OPERATIONAL LIMIT: a MEATER probe accepts exactly one BLE connection at a time.
If the probe is connected to its own Block or the phone app, this source cannot
read it, and vice versa. Plan on the Pi owning the probe during a cook.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from ..models import ProbeReading
from .base import ProbeSource, ReadingSink
log = logging.getLogger(__name__)
SERVICE_UUID = "a75cc7fc-c956-488f-ac2a-2dbc08b63a04"
TEMPERATURE_CHAR_UUID = "7edda774-045e-4bbf-909b-45d1991a2876"
BATTERY_CHAR_UUID = "2adb4877-68d8-4884-bd3c-d83853bf27b8"
def _u16le(data: bytes, offset: int) -> int:
return int.from_bytes(data[offset : offset + 2], "little")
def decode_temperature(data: bytes) -> tuple[float, float]:
"""Decode the MEATER temperature characteristic to ``(tip_c, ambient_c)``.
Payload is little-endian uint16 fields; we use the first three:
``[0:2]`` tip, ``[2:4]`` ambient raw, ``[4:6]`` ambient offset.
Raw counts are sixteenths of a degree C with a half-step bias, hence the
``(raw + 8) / 16``. The ambient term is the empirical community fit.
"""
if len(data) < 6:
raise ValueError(f"expected >=6 bytes, got {len(data)}")
tip_raw = _u16le(data, 0)
ambient_raw_field = _u16le(data, 2)
ambient_offset = _u16le(data, 4)
tip_c = (tip_raw + 8) / 16.0
ambient_counts = tip_raw + max(
0, ((ambient_raw_field - min(48, ambient_offset)) * 16 * 589) // 1487
)
ambient_c = (ambient_counts + 8) / 16.0
return tip_c, ambient_c
def decode_battery(data: bytes) -> int:
"""Battery characteristic is a uint16 in units of 10%, clamped to 0-100."""
if len(data) < 2:
raise ValueError(f"expected >=2 bytes, got {len(data)}")
return max(0, min(100, _u16le(data, 0) * 10))
class MeaterSource(ProbeSource):
"""Connects to every MEATER probe in range and streams notifications."""
name = "meater"
def __init__(self, **options: Any) -> None:
super().__init__(**options)
self.address: str | None = options.get("address")
self.scan_timeout_s: float = float(options.get("scan_timeout_s", 10.0))
self._clients: dict[str, Any] = {}
async def run(self, emit: ReadingSink) -> None:
try:
from bleak import BleakClient, BleakScanner
except ImportError as exc: # pragma: no cover - import guard
raise RuntimeError(
"meater source needs bleak: pip install '.[ble]'"
) from exc
while True:
address = self.address
if address is None:
log.info("scanning %.0fs for MEATER probes", self.scan_timeout_s)
device = await BleakScanner.find_device_by_filter(
lambda _d, adv: SERVICE_UUID.lower()
in [u.lower() for u in adv.service_uuids],
timeout=self.scan_timeout_s,
)
if device is None:
log.warning("no MEATER probe found; rescanning")
await asyncio.sleep(self.poll_interval_s)
continue
address = device.address
await self._stream_from(BleakClient, address, emit)
async def _stream_from(
self, client_cls: Any, address: str, emit: ReadingSink
) -> None:
"""Hold one probe connection open, emitting on every notification."""
disconnected = asyncio.Event()
def on_disconnect(_client: Any) -> None:
log.warning("MEATER %s disconnected", address)
disconnected.set()
async with client_cls(address, disconnected_callback=on_disconnect) as client:
self._clients[address] = client
log.info("connected to MEATER %s", address)
battery: int | None = None
try:
battery = decode_battery(
await client.read_gatt_char(BATTERY_CHAR_UUID)
)
except Exception:
log.debug("battery read failed for %s", address, exc_info=True)
def on_temperature(_handle: int, data: bytearray) -> None:
try:
tip_c, ambient_c = decode_temperature(bytes(data))
except ValueError:
log.warning("undecodable MEATER payload: %s", bytes(data).hex())
return
emit(
ProbeReading(
probe_id=f"meater:{address}",
tip_c=tip_c,
ambient_c=ambient_c,
battery_pct=battery,
source=self.name,
display_name=self.options.get("display_name"),
)
)
await client.start_notify(TEMPERATURE_CHAR_UUID, on_temperature)
await disconnected.wait()
self._clients.pop(address, None)
async def close(self) -> None:
for address, client in list(self._clients.items()):
try:
await client.disconnect()
except Exception:
log.debug("error disconnecting %s", address, exc_info=True)
self._clients.clear()
+60
View File
@@ -0,0 +1,60 @@
"""Synthetic probe source for developing without a grill or probes attached.
Models a roast warming toward a target with a plausible stall, so you can
exercise the full pipeline -- routing, PT1000 conversion, rheostat stepping --
on a laptop. Only ever used when explicitly configured; nothing auto-falls-back
to simulated data.
"""
from __future__ import annotations
import asyncio
import math
from typing import Any
from ..models import ProbeReading
from .base import ProbeSource, ReadingSink
class SimulatorSource(ProbeSource):
"""Emits a smooth, physically plausible cook curve."""
name = "simulator"
works_without_hardware = True
def __init__(self, **options: Any) -> None:
super().__init__(**options)
self.probe_count: int = int(options.get("probe_count", 2))
self.start_c: float = float(options.get("start_c", 4.0))
self.ambient_c: float = float(options.get("ambient_c", 121.0))
self.speed: float = float(options.get("speed", 60.0))
self.poll_interval_s = float(options.get("poll_interval_s", 1.0))
self._elapsed = 0.0
def _tip_at(self, elapsed_s: float, index: int) -> float:
"""Newton cooling toward ambient, with a stall around 65-70 C."""
# Stagger probes so they do not move in lockstep.
tau = 2400.0 * (1.0 + 0.25 * index)
approach = 1.0 - math.exp(-elapsed_s / tau)
temp = self.start_c + (self.ambient_c - self.start_c) * approach
# Evaporative stall: flatten the curve as it crosses the mid 60s.
stall_center, stall_width = 67.0, 6.0
damping = math.exp(-(((temp - stall_center) / stall_width) ** 2))
return temp - damping * 8.0
async def run(self, emit: ReadingSink) -> None:
while True:
self._elapsed += self.poll_interval_s * self.speed
for index in range(self.probe_count):
emit(
ProbeReading(
probe_id=f"sim:{index}",
tip_c=self._tip_at(self._elapsed, index),
ambient_c=self.ambient_c,
battery_pct=100,
source=self.name,
display_name=f"Simulated probe {index + 1}",
)
)
await asyncio.sleep(self.poll_interval_s)
+244
View File
@@ -0,0 +1,244 @@
"""ThermoWorks RFX probe source, via ThermoWorks Cloud.
RFX probes transmit sub-1 GHz RF to an RFX Gateway, which forwards over WiFi to
ThermoWorks Cloud. There is no local API on the gateway today, so the only
working path is polling the cloud. We use `thermoworks-cloud`, an unofficial
library built from the observed behaviour of the ThermoWorks web client
(https://github.com/a2hill/python-thermoworks-cloud).
Consequences worth understanding before you cook on this -- see
docs/PROTOCOL_NOTES.md for the full writeup:
* Your internet connection is in the loop. If it drops, readings go stale and
the affected jacks fail cold.
* Round trip is probe -> gateway -> cloud -> here. Practical polling is ~60s,
versus a couple of seconds for BLE. Fine for low-and-slow, poor for searing.
* THE CLOUD SERVES STALE DATA WITH A FRESH 200. A probe that died an hour ago
still returns its last value. We therefore date every reading by the
cloud's own `last_telemetry_saved`/`last_seen` timestamp, NOT by when the
HTTP call returned. Without that, a dead probe would look alive forever and
the fail-cold policy would never trip.
LICENSING: thermoworks-cloud is GPLv3, so it is an optional extra
(`pip install '.[thermoworks]'`) rather than a core dependency. Installing it
has implications if you ever redistribute this project.
Other ThermoWorks families are NOT handled here. Node is BLE and would want a
driver shaped like the MEATER one; Signals is a different cloud/WiFi device.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from datetime import datetime, timezone
from typing import Any
from ..models import ProbeReading
from ..pt1000 import f_to_c
from .base import ProbeSource, ReadingSink
log = logging.getLogger(__name__)
# The cloud API has no documented channel listing, so we probe channel ids
# until one 404s -- the same approach the Home Assistant integration uses.
MAX_CHANNEL_PROBE = 9
# Anything older than this in the cloud's own timestamps is treated as the
# probe having gone away, regardless of how healthy the HTTP call looked.
DEFAULT_MAX_CLOUD_AGE_S = 300.0
# Matched exactly, never by prefix: "furlongs" starts with F, and a prefix
# match would quietly convert it as Fahrenheit.
_FAHRENHEIT_UNITS = frozenset({"F", "FAHRENHEIT"})
_CELSIUS_UNITS = frozenset({"C", "CELSIUS", "CENTIGRADE"})
def _normalise_temp(value: float, units: str | None) -> float | None:
"""Convert a cloud reading to Celsius. Returns None if units are unknown.
Guessing at units here would be a great way to feed 225 C to a grill that
was told 225 F, so an unrecognised unit is an error, not a default.
"""
if units is None:
return None
unit = units.strip().upper().lstrip("°").strip()
if unit in _FAHRENHEIT_UNITS:
return f_to_c(value)
if unit in _CELSIUS_UNITS:
return value
return None
def channel_to_reading(
device: Any,
channel: Any,
*,
now_utc: datetime | None = None,
monotonic_now: float | None = None,
max_cloud_age_s: float = DEFAULT_MAX_CLOUD_AGE_S,
) -> ProbeReading | None:
"""Convert a cloud device+channel pair into a ProbeReading.
Returns None when the channel has no usable reading, unknown units, or
cloud-side data old enough that we should not pretend it is live.
The returned reading's ``timestamp`` is backdated by the cloud-reported age,
so downstream staleness checks measure the age of the *measurement* rather
than the age of our HTTP request.
"""
value = getattr(channel, "value", None)
if value is None:
return None
temp_c = _normalise_temp(float(value), getattr(channel, "units", None))
if temp_c is None:
log.warning(
"channel %s on %s reported unknown units %r; skipping",
getattr(channel, "number", "?"),
getattr(device, "serial", "?"),
getattr(channel, "units", None),
)
return None
now_utc = now_utc or datetime.now(timezone.utc)
monotonic_now = time.monotonic() if monotonic_now is None else monotonic_now
reported_at = getattr(channel, "last_telemetry_saved", None) or getattr(
channel, "last_seen", None
)
age_s = 0.0
if isinstance(reported_at, datetime):
if reported_at.tzinfo is None:
reported_at = reported_at.replace(tzinfo=timezone.utc)
age_s = max(0.0, (now_utc - reported_at).total_seconds())
if age_s > max_cloud_age_s:
log.warning(
"dropping %s channel %s: cloud data is %.0fs old",
getattr(device, "serial", "?"),
getattr(channel, "number", "?"),
age_s,
)
return None
serial = getattr(device, "serial", None) or getattr(device, "device_id", "?")
number = getattr(channel, "number", None) or "1"
label = (
getattr(channel, "label", None)
or getattr(device, "label", None)
or getattr(device, "device_name", None)
)
return ProbeReading(
probe_id=f"thermoworks:{serial}:{number}",
tip_c=temp_c,
source="thermoworks",
battery_pct=getattr(device, "battery", None),
rssi=getattr(device, "signal_strength", None),
display_name=label,
# Backdate so staleness reflects the measurement, not the fetch.
timestamp=monotonic_now - age_s,
extra={"cloud_age_s": age_s, "channel": number},
)
class ThermoWorksSource(ProbeSource):
"""Polls ThermoWorks Cloud for RFX (and other cloud-connected) probes."""
name = "thermoworks"
def __init__(self, **options: Any) -> None:
super().__init__(**options)
self.poll_interval_s = float(options.get("poll_interval_s", 60.0))
self.max_cloud_age_s = float(
options.get("max_cloud_age_s", DEFAULT_MAX_CLOUD_AGE_S)
)
self.email: str | None = options.get("email") or os.environ.get(
"THERMOWORKS_EMAIL"
)
self.password: str | None = options.get("password") or os.environ.get(
"THERMOWORKS_PASSWORD"
)
# Channel discovery costs one request per attempt, so we learn each
# device's channel list once and reuse it rather than re-probing 1..9
# against an unofficial API every single cycle.
self._channels_by_serial: dict[str, list[str]] = {}
def _check_credentials(self) -> None:
if not self.email or not self.password:
raise RuntimeError(
"thermoworks source needs credentials: set THERMOWORKS_EMAIL and "
"THERMOWORKS_PASSWORD in the environment, or email/password in "
"config.yaml (environment is preferred -- config.yaml is easy to "
"commit by accident)"
)
async def _discover_channels(self, client: Any, serial: str) -> list[str]:
"""Find which channel ids exist on a device, caching the result."""
if serial in self._channels_by_serial:
return self._channels_by_serial[serial]
from thermoworks_cloud import ResourceNotFoundError
channels: list[str] = []
for number in range(1, MAX_CHANNEL_PROBE + 1):
try:
await client.get_device_channel(
device_serial=serial, channel=str(number)
)
except ResourceNotFoundError:
break
channels.append(str(number))
self._channels_by_serial[serial] = channels
log.info("device %s exposes channel(s) %s", serial, channels or "none")
return channels
async def _poll_once(self, client: Any, account_id: str, emit: ReadingSink) -> None:
devices = await client.get_devices(account_id)
for device in devices:
serial = getattr(device, "serial", None)
if not serial:
continue
for number in await self._discover_channels(client, serial):
channel = await client.get_device_channel(
device_serial=serial, channel=number
)
reading = channel_to_reading(
device, channel, max_cloud_age_s=self.max_cloud_age_s
)
if reading is not None:
emit(reading)
async def run(self, emit: ReadingSink) -> None:
self._check_credentials()
try:
from aiohttp import ClientSession
from thermoworks_cloud import AuthFactory, ThermoworksCloud
except ImportError as exc: # pragma: no cover - import guard
raise RuntimeError(
"thermoworks source needs the cloud client: "
"pip install '.[thermoworks]'"
) from exc
async with ClientSession() as session:
auth = await AuthFactory(session).build_auth(
email=self.email, password=self.password
)
client = ThermoworksCloud(auth)
user = await client.get_user()
account_id = getattr(user, "account_id", None)
if not account_id:
raise RuntimeError("ThermoWorks account has no account_id")
log.info(
"authenticated to ThermoWorks Cloud, polling every %.0fs",
self.poll_interval_s,
)
while True:
await self._poll_once(client, account_id, emit)
await asyncio.sleep(self.poll_interval_s)
+116
View File
@@ -0,0 +1,116 @@
"""Emulator behaviour, especially the failure modes.
The fail-cold policy is a safety property, not a nicety: a stuck-hot channel
can convince the grill a cook finished early. These tests pin that down.
"""
from __future__ import annotations
import time
import pytest
from smokescreen.models import ProbeReading
from smokescreen.outputs.pt1000_emulator import (
ChannelCalibration,
PT1000Emulator,
)
from smokescreen.outputs.rheostat import MockRheostat
from smokescreen.pt1000 import temp_c_to_resistance
def make_emulator(**kwargs) -> tuple[PT1000Emulator, MockRheostat]:
channel = MockRheostat(max_code=1023)
emulator = PT1000Emulator(
jack=1,
channel=channel,
calibration=ChannelCalibration(r_offset_ohms=1000.0, step_ohms=1.0),
**kwargs,
)
return emulator, channel
def reading(tip_c: float, *, age_s: float = 0.0) -> ProbeReading:
return ProbeReading(
probe_id="test:1",
tip_c=tip_c,
source="test",
timestamp=time.monotonic() - age_s,
)
@pytest.mark.parametrize("temp_c", [0.0, 25.0, 60.0, 93.3, 150.0])
def test_applied_temperature_tracks_request(temp_c):
emulator, _ = make_emulator()
assert emulator.apply_temperature(temp_c) == pytest.approx(temp_c, abs=0.2)
def test_wiper_code_matches_the_pt1000_curve():
emulator, channel = make_emulator()
emulator.apply_temperature(100.0)
# r_offset 1000 + code*1 should land on 1385 ohm.
expected_code = round(temp_c_to_resistance(100.0) - 1000.0)
assert channel.code == expected_code
def test_temperature_above_hardware_range_clamps_rather_than_wraps():
emulator, channel = make_emulator()
applied = emulator.apply_temperature(300.0)
assert channel.code == channel.max_code
assert applied < 300.0 # clamped, and the caller can see it
def test_stale_reading_fails_cold():
emulator, channel = make_emulator(stale_after_s=30.0)
emulator.update(reading(90.0))
assert channel.code > 0
emulator.update(reading(90.0, age_s=120.0))
assert channel.code == 0
assert emulator.state.stale
assert "stale" in emulator.state.error
def test_missing_probe_fails_cold():
emulator, channel = make_emulator()
emulator.update(None)
assert channel.code == 0
assert emulator.state.stale
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), 5000.0])
def test_implausible_reading_fails_cold_instead_of_raising(bad):
emulator, channel = make_emulator()
emulator.update(reading(bad))
assert channel.code == 0
assert emulator.state.stale
def test_fail_cold_is_never_hot():
"""Whatever else happens, the fault state must read cold, not hot."""
emulator, channel = make_emulator()
emulator.apply_temperature(150.0)
emulator.fail_cold("test")
assert channel.code == 0
assert emulator.min_ohms < temp_c_to_resistance(0.0) + 1e-6
def test_close_drives_cold_before_releasing():
emulator, channel = make_emulator()
emulator.apply_temperature(120.0)
emulator.close()
assert channel.history[-1] == 0
def test_calibration_solved_from_two_measured_points():
cal = ChannelCalibration.from_two_points(
code_a=0, ohms_a=1012.4, code_b=1000, ohms_b=2015.6
)
assert cal.r_offset_ohms == pytest.approx(1012.4, abs=0.01)
assert cal.step_ohms == pytest.approx(1.0032, abs=0.0001)
assert cal.ohms_for_code(500) == pytest.approx(1514.0, abs=0.1)
def test_calibration_rejects_degenerate_points():
with pytest.raises(ValueError):
ChannelCalibration.from_two_points(10, 1000.0, 10, 2000.0)
+61
View File
@@ -0,0 +1,61 @@
"""MEATER decode tests.
These pin the community-derived decode so a refactor cannot silently change
it. They do NOT prove the decode is correct -- only bench comparison against
the MEATER app can do that. See docs/PROTOCOL_NOTES.md.
"""
from __future__ import annotations
import pytest
from smokescreen.sources.meater import decode_battery, decode_temperature
def payload(tip: int, ambient_raw: int, ambient_offset: int) -> bytes:
return (
tip.to_bytes(2, "little")
+ ambient_raw.to_bytes(2, "little")
+ ambient_offset.to_bytes(2, "little")
+ b"\x00\x00"
)
def test_tip_decodes_to_sixteenths_of_a_degree():
# 400 counts -> (400 + 8) / 16 = 25.5 C
tip_c, _ = decode_temperature(payload(400, 0, 0))
assert tip_c == pytest.approx(25.5)
def test_zero_counts_is_just_above_freezing():
tip_c, _ = decode_temperature(payload(0, 0, 0))
assert tip_c == pytest.approx(0.5)
def test_ambient_never_reads_below_tip():
"""The max(0, ...) term means ambient floors at the tip temperature."""
tip_c, ambient_c = decode_temperature(payload(1000, 0, 200))
assert ambient_c == pytest.approx(tip_c)
def test_ambient_rises_above_tip_when_raw_exceeds_offset():
tip_c, ambient_c = decode_temperature(payload(1000, 500, 48))
assert ambient_c > tip_c
def test_short_payload_is_rejected_not_guessed():
with pytest.raises(ValueError):
decode_temperature(b"\x01\x02")
def test_battery_scales_by_ten():
assert decode_battery((7).to_bytes(2, "little")) == 70
def test_battery_clamps_to_100():
assert decode_battery((25).to_bytes(2, "little")) == 100
def test_short_battery_payload_is_rejected():
with pytest.raises(ValueError):
decode_battery(b"\x01")
+93
View File
@@ -0,0 +1,93 @@
"""PT1000 curve tests against published IEC 60751 reference values.
These numbers are the whole basis for the grill believing our fake probe, so
they are checked against the standard table rather than against our own output.
"""
from __future__ import annotations
import pytest
from smokescreen.pt1000 import (
MAX_TEMP_C,
MIN_TEMP_C,
is_plausible,
required_span_ohms,
resistance_to_temp_c,
resistance_to_temp_c_clamped,
resolution_c_per_ohm,
temp_c_to_resistance,
)
# (celsius, ohms) from the IEC 60751 alpha=0.00385 PT1000 table.
REFERENCE_POINTS = [
(-20.0, 921.60),
(0.0, 1000.00),
(20.0, 1077.94),
(50.0, 1193.97),
(100.0, 1385.06),
(150.0, 1573.25),
(200.0, 1758.56),
(250.0, 1940.98),
]
@pytest.mark.parametrize("temp_c,expected_ohms", REFERENCE_POINTS)
def test_temp_to_resistance_matches_standard_table(temp_c, expected_ohms):
assert temp_c_to_resistance(temp_c) == pytest.approx(expected_ohms, abs=0.05)
@pytest.mark.parametrize("temp_c,ohms", REFERENCE_POINTS)
def test_resistance_to_temp_matches_standard_table(temp_c, ohms):
assert resistance_to_temp_c(ohms) == pytest.approx(temp_c, abs=0.02)
@pytest.mark.parametrize("temp_c", [-40, -10, 0, 0.5, 37, 93.3, 165, 250, 350])
def test_round_trip_is_lossless(temp_c):
ohms = temp_c_to_resistance(temp_c)
assert resistance_to_temp_c(ohms) == pytest.approx(temp_c, abs=0.001)
def test_curve_is_monotonic_across_the_food_range():
"""A non-monotonic patch would make the inverse ambiguous."""
ohms = [temp_c_to_resistance(t / 10) for t in range(-400, 3501)]
assert all(b > a for a, b in zip(ohms, ohms[1:]))
@pytest.mark.parametrize("bad_ohms", [50.0, 0.0, 842.0, 2300.0, 1e6, float("nan")])
def test_resistance_off_the_curve_is_rejected(bad_ohms):
"""Unguarded, the curve extrapolates 50 ohm to a plausible-looking -235 C."""
with pytest.raises(ValueError):
resistance_to_temp_c(bad_ohms)
@pytest.mark.parametrize("ohms,expected", [(50.0, MIN_TEMP_C), (1e6, MAX_TEMP_C)])
def test_clamped_variant_saturates_instead_of_raising(ohms, expected):
assert resistance_to_temp_c_clamped(ohms) == pytest.approx(expected, abs=0.01)
def test_clamped_variant_is_exact_inside_the_range():
assert resistance_to_temp_c_clamped(1385.06) == pytest.approx(100.0, abs=0.02)
def test_required_span_covers_a_real_cook():
lo, hi = required_span_ohms(0.0, 200.0)
assert lo == pytest.approx(1000.0, abs=0.1)
assert hi == pytest.approx(1758.6, abs=0.5)
# This is the number that sizes the hardware: a 1k rheostat is enough.
assert hi - lo < 1000.0
def test_one_ohm_is_finer_than_the_grill_displays():
# Grill shows whole degrees F; 1 ohm must be comfortably under 1 F (0.56 C).
assert resolution_c_per_ohm(100.0) < 0.4
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), -273.0, 900.0])
def test_implausible_values_rejected(bad):
assert not is_plausible(bad)
@pytest.mark.parametrize("good", [-20.0, 0.0, 93.3, 200.0])
def test_plausible_values_accepted(good):
assert is_plausible(good)
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from smokescreen.models import ProbeReading
from smokescreen.router import ProbeRouter
def reading(probe_id: str, tip_c: float = 60.0) -> ProbeReading:
return ProbeReading(probe_id=probe_id, tip_c=tip_c, source="test")
def test_pinned_probe_drives_its_jack():
router = ProbeRouter(jacks=[1, 2], pinned={2: "meater:aa"})
assert router.observe(reading("meater:aa")) == 2
def test_auto_assignment_fills_free_jacks_in_order():
router = ProbeRouter(jacks=[1, 2])
assert router.observe(reading("probe:a")) == 1
assert router.observe(reading("probe:b")) == 2
def test_auto_assignment_skips_pinned_jacks():
router = ProbeRouter(jacks=[1, 2], pinned={1: "meater:aa"})
assert router.observe(reading("probe:b")) == 2
def test_binding_is_sticky_across_a_dropout():
"""A probe that reconnects must not land on a different jack mid-cook."""
router = ProbeRouter(jacks=[1, 2])
assert router.observe(reading("probe:a")) == 1
assert router.observe(reading("probe:b")) == 2
assert router.observe(reading("probe:a", 70.0)) == 1
def test_extra_probes_have_nowhere_to_go():
router = ProbeRouter(jacks=[1, 2])
router.observe(reading("probe:a"))
router.observe(reading("probe:b"))
assert router.observe(reading("probe:c")) is None
assert [r.probe_id for r in router.unassigned()] == ["probe:c"]
def test_reading_for_returns_latest_sample():
router = ProbeRouter(jacks=[1])
router.observe(reading("probe:a", 50.0))
router.observe(reading("probe:a", 75.0))
assert router.reading_for(1).tip_c == 75.0
def test_reading_for_unbound_jack_is_none():
assert ProbeRouter(jacks=[1, 2]).reading_for(2) is None
def test_pin_to_unknown_jack_is_rejected():
with pytest.raises(ValueError, match="unknown jacks"):
ProbeRouter(jacks=[1, 2], pinned={3: "probe:a"})
def test_probe_pinned_twice_is_rejected():
with pytest.raises(ValueError, match="more than one jack"):
ProbeRouter(jacks=[1, 2], pinned={1: "probe:a", 2: "probe:a"})
+145
View File
@@ -0,0 +1,145 @@
"""ThermoWorks RFX cloud source tests.
The load-bearing behaviour here is staleness. ThermoWorks Cloud returns a
probe's last known value with a perfectly healthy HTTP 200 long after the probe
has gone away, so "the request succeeded" says nothing about whether the
temperature is current. These tests pin the backdating that makes the
fail-cold policy actually work for a cloud source.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from smokescreen.sources.thermoworks import (
_normalise_temp,
channel_to_reading,
)
NOW = datetime(2026, 8, 16, 12, 0, 0, tzinfo=timezone.utc)
def device(**kwargs):
return SimpleNamespace(
**{
"serial": "RFX123",
"label": "Brisket",
"device_name": "RFX MEAT",
"battery": 88,
"signal_strength": -62,
**kwargs,
}
)
def channel(age_s: float = 0.0, **kwargs):
return SimpleNamespace(
**{
"value": 165.0,
"units": "F",
"number": "1",
"label": "Internal",
"last_telemetry_saved": NOW - timedelta(seconds=age_s),
"last_seen": NOW - timedelta(seconds=age_s),
**kwargs,
}
)
def convert(dev, chan, **kwargs):
return channel_to_reading(
dev, chan, now_utc=NOW, monotonic_now=1000.0, **kwargs
)
@pytest.mark.parametrize(
"value,units,expected",
[
(212.0, "F", 100.0),
(32.0, "F", 0.0),
(100.0, "C", 100.0),
(100.0, "°C", 100.0),
(165.0, "f", 73.888),
],
)
def test_units_are_converted_to_celsius(value, units, expected):
assert _normalise_temp(value, units) == pytest.approx(expected, abs=0.01)
@pytest.mark.parametrize(
"units", [None, "", "kelvin", "rankine", "?", "furlongs", "cubits", "F/C"]
)
def test_unknown_units_are_refused_not_guessed(units):
"""Assuming F when it was C would feed 225C to a grill told 225F.
'furlongs' and 'cubits' are regression cases: a prefix match on F/C
silently converted them rather than rejecting them.
"""
assert _normalise_temp(100.0, units) is None
def test_fahrenheit_channel_converts():
reading = convert(device(), channel())
assert reading.tip_c == pytest.approx(73.89, abs=0.01)
assert reading.probe_id == "thermoworks:RFX123:1"
assert reading.display_name == "Internal"
assert reading.battery_pct == 88
def test_reading_is_backdated_by_cloud_age():
"""A 90s-old cloud value must arrive already 90s old, not brand new."""
reading = convert(device(), channel(age_s=90.0))
assert reading.timestamp == pytest.approx(1000.0 - 90.0)
assert reading.age_s(now=1000.0) == pytest.approx(90.0)
def test_fresh_reading_is_not_backdated():
reading = convert(device(), channel(age_s=0.0))
assert reading.age_s(now=1000.0) == pytest.approx(0.0)
def test_cloud_data_past_the_age_limit_is_dropped():
assert convert(device(), channel(age_s=3600.0), max_cloud_age_s=300.0) is None
def test_age_limit_boundary_is_inclusive():
assert convert(device(), channel(age_s=299.0), max_cloud_age_s=300.0) is not None
def test_channel_with_no_value_is_dropped():
assert convert(device(), channel(value=None)) is None
def test_channel_with_unknown_units_is_dropped():
assert convert(device(), channel(units="furlongs")) is None
def test_naive_timestamps_are_treated_as_utc():
naive = (NOW - timedelta(seconds=60)).replace(tzinfo=None)
reading = convert(device(), channel(last_telemetry_saved=naive, last_seen=naive))
assert reading.age_s(now=1000.0) == pytest.approx(60.0)
def test_falls_back_to_last_seen_when_telemetry_timestamp_missing():
chan = channel(age_s=45.0, last_telemetry_saved=None)
assert convert(device(), chan).age_s(now=1000.0) == pytest.approx(45.0)
def test_missing_timestamps_are_treated_as_current():
chan = channel(last_telemetry_saved=None, last_seen=None)
assert convert(device(), chan).age_s(now=1000.0) == pytest.approx(0.0)
def test_multiple_channels_get_distinct_probe_ids():
ids = {
convert(device(), channel(number=str(n))).probe_id for n in (1, 2)
}
assert ids == {"thermoworks:RFX123:1", "thermoworks:RFX123:2"}
def test_channel_label_falls_back_to_device_label():
reading = convert(device(), channel(label=None))
assert reading.display_name == "Brisket"