Initial commit: PT1000 probe emulation bridge
Feeds third-party BBQ probes into a pellet grill's wired probe jacks by presenting the resistance a real PT1000 RTD would show at that temperature. The grill sees an ordinary wired probe, so its display, app, target-temp alarms and Keep Warm all work with no protocol reversing involved. Pluggable probe sources: ThermoWorks RFX (via ThermoWorks Cloud), MEATER (community-derived BLE decode), a synthetic simulator for hardware-free development, and a Combustion stub. Two safety invariants are load-bearing: - Stale, missing or implausible readings drive the channel cold, never hot. A stuck-hot channel could convince the grill a cook finished early. - Unimplemented sources raise rather than returning plausible numbers, since the grill acts on these values. Cloud sources are dated by the cloud's own timestamp rather than by fetch time, because ThermoWorks serves a dead probe's last value with a fresh 200. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Feed third-party BBQ probes into a Traeger Ironwood's wired probe jacks."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user