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