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
+116
View File
@@ -0,0 +1,116 @@
"""Emulator behaviour, especially the failure modes.
The fail-cold policy is a safety property, not a nicety: a stuck-hot channel
can convince the grill a cook finished early. These tests pin that down.
"""
from __future__ import annotations
import time
import pytest
from smokescreen.models import ProbeReading
from smokescreen.outputs.pt1000_emulator import (
ChannelCalibration,
PT1000Emulator,
)
from smokescreen.outputs.rheostat import MockRheostat
from smokescreen.pt1000 import temp_c_to_resistance
def make_emulator(**kwargs) -> tuple[PT1000Emulator, MockRheostat]:
channel = MockRheostat(max_code=1023)
emulator = PT1000Emulator(
jack=1,
channel=channel,
calibration=ChannelCalibration(r_offset_ohms=1000.0, step_ohms=1.0),
**kwargs,
)
return emulator, channel
def reading(tip_c: float, *, age_s: float = 0.0) -> ProbeReading:
return ProbeReading(
probe_id="test:1",
tip_c=tip_c,
source="test",
timestamp=time.monotonic() - age_s,
)
@pytest.mark.parametrize("temp_c", [0.0, 25.0, 60.0, 93.3, 150.0])
def test_applied_temperature_tracks_request(temp_c):
emulator, _ = make_emulator()
assert emulator.apply_temperature(temp_c) == pytest.approx(temp_c, abs=0.2)
def test_wiper_code_matches_the_pt1000_curve():
emulator, channel = make_emulator()
emulator.apply_temperature(100.0)
# r_offset 1000 + code*1 should land on 1385 ohm.
expected_code = round(temp_c_to_resistance(100.0) - 1000.0)
assert channel.code == expected_code
def test_temperature_above_hardware_range_clamps_rather_than_wraps():
emulator, channel = make_emulator()
applied = emulator.apply_temperature(300.0)
assert channel.code == channel.max_code
assert applied < 300.0 # clamped, and the caller can see it
def test_stale_reading_fails_cold():
emulator, channel = make_emulator(stale_after_s=30.0)
emulator.update(reading(90.0))
assert channel.code > 0
emulator.update(reading(90.0, age_s=120.0))
assert channel.code == 0
assert emulator.state.stale
assert "stale" in emulator.state.error
def test_missing_probe_fails_cold():
emulator, channel = make_emulator()
emulator.update(None)
assert channel.code == 0
assert emulator.state.stale
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), 5000.0])
def test_implausible_reading_fails_cold_instead_of_raising(bad):
emulator, channel = make_emulator()
emulator.update(reading(bad))
assert channel.code == 0
assert emulator.state.stale
def test_fail_cold_is_never_hot():
"""Whatever else happens, the fault state must read cold, not hot."""
emulator, channel = make_emulator()
emulator.apply_temperature(150.0)
emulator.fail_cold("test")
assert channel.code == 0
assert emulator.min_ohms < temp_c_to_resistance(0.0) + 1e-6
def test_close_drives_cold_before_releasing():
emulator, channel = make_emulator()
emulator.apply_temperature(120.0)
emulator.close()
assert channel.history[-1] == 0
def test_calibration_solved_from_two_measured_points():
cal = ChannelCalibration.from_two_points(
code_a=0, ohms_a=1012.4, code_b=1000, ohms_b=2015.6
)
assert cal.r_offset_ohms == pytest.approx(1012.4, abs=0.01)
assert cal.step_ohms == pytest.approx(1.0032, abs=0.0001)
assert cal.ohms_for_code(500) == pytest.approx(1514.0, abs=0.1)
def test_calibration_rejects_degenerate_points():
with pytest.raises(ValueError):
ChannelCalibration.from_two_points(10, 1000.0, 10, 2000.0)
+61
View File
@@ -0,0 +1,61 @@
"""MEATER decode tests.
These pin the community-derived decode so a refactor cannot silently change
it. They do NOT prove the decode is correct -- only bench comparison against
the MEATER app can do that. See docs/PROTOCOL_NOTES.md.
"""
from __future__ import annotations
import pytest
from smokescreen.sources.meater import decode_battery, decode_temperature
def payload(tip: int, ambient_raw: int, ambient_offset: int) -> bytes:
return (
tip.to_bytes(2, "little")
+ ambient_raw.to_bytes(2, "little")
+ ambient_offset.to_bytes(2, "little")
+ b"\x00\x00"
)
def test_tip_decodes_to_sixteenths_of_a_degree():
# 400 counts -> (400 + 8) / 16 = 25.5 C
tip_c, _ = decode_temperature(payload(400, 0, 0))
assert tip_c == pytest.approx(25.5)
def test_zero_counts_is_just_above_freezing():
tip_c, _ = decode_temperature(payload(0, 0, 0))
assert tip_c == pytest.approx(0.5)
def test_ambient_never_reads_below_tip():
"""The max(0, ...) term means ambient floors at the tip temperature."""
tip_c, ambient_c = decode_temperature(payload(1000, 0, 200))
assert ambient_c == pytest.approx(tip_c)
def test_ambient_rises_above_tip_when_raw_exceeds_offset():
tip_c, ambient_c = decode_temperature(payload(1000, 500, 48))
assert ambient_c > tip_c
def test_short_payload_is_rejected_not_guessed():
with pytest.raises(ValueError):
decode_temperature(b"\x01\x02")
def test_battery_scales_by_ten():
assert decode_battery((7).to_bytes(2, "little")) == 70
def test_battery_clamps_to_100():
assert decode_battery((25).to_bytes(2, "little")) == 100
def test_short_battery_payload_is_rejected():
with pytest.raises(ValueError):
decode_battery(b"\x01")
+93
View File
@@ -0,0 +1,93 @@
"""PT1000 curve tests against published IEC 60751 reference values.
These numbers are the whole basis for the grill believing our fake probe, so
they are checked against the standard table rather than against our own output.
"""
from __future__ import annotations
import pytest
from smokescreen.pt1000 import (
MAX_TEMP_C,
MIN_TEMP_C,
is_plausible,
required_span_ohms,
resistance_to_temp_c,
resistance_to_temp_c_clamped,
resolution_c_per_ohm,
temp_c_to_resistance,
)
# (celsius, ohms) from the IEC 60751 alpha=0.00385 PT1000 table.
REFERENCE_POINTS = [
(-20.0, 921.60),
(0.0, 1000.00),
(20.0, 1077.94),
(50.0, 1193.97),
(100.0, 1385.06),
(150.0, 1573.25),
(200.0, 1758.56),
(250.0, 1940.98),
]
@pytest.mark.parametrize("temp_c,expected_ohms", REFERENCE_POINTS)
def test_temp_to_resistance_matches_standard_table(temp_c, expected_ohms):
assert temp_c_to_resistance(temp_c) == pytest.approx(expected_ohms, abs=0.05)
@pytest.mark.parametrize("temp_c,ohms", REFERENCE_POINTS)
def test_resistance_to_temp_matches_standard_table(temp_c, ohms):
assert resistance_to_temp_c(ohms) == pytest.approx(temp_c, abs=0.02)
@pytest.mark.parametrize("temp_c", [-40, -10, 0, 0.5, 37, 93.3, 165, 250, 350])
def test_round_trip_is_lossless(temp_c):
ohms = temp_c_to_resistance(temp_c)
assert resistance_to_temp_c(ohms) == pytest.approx(temp_c, abs=0.001)
def test_curve_is_monotonic_across_the_food_range():
"""A non-monotonic patch would make the inverse ambiguous."""
ohms = [temp_c_to_resistance(t / 10) for t in range(-400, 3501)]
assert all(b > a for a, b in zip(ohms, ohms[1:]))
@pytest.mark.parametrize("bad_ohms", [50.0, 0.0, 842.0, 2300.0, 1e6, float("nan")])
def test_resistance_off_the_curve_is_rejected(bad_ohms):
"""Unguarded, the curve extrapolates 50 ohm to a plausible-looking -235 C."""
with pytest.raises(ValueError):
resistance_to_temp_c(bad_ohms)
@pytest.mark.parametrize("ohms,expected", [(50.0, MIN_TEMP_C), (1e6, MAX_TEMP_C)])
def test_clamped_variant_saturates_instead_of_raising(ohms, expected):
assert resistance_to_temp_c_clamped(ohms) == pytest.approx(expected, abs=0.01)
def test_clamped_variant_is_exact_inside_the_range():
assert resistance_to_temp_c_clamped(1385.06) == pytest.approx(100.0, abs=0.02)
def test_required_span_covers_a_real_cook():
lo, hi = required_span_ohms(0.0, 200.0)
assert lo == pytest.approx(1000.0, abs=0.1)
assert hi == pytest.approx(1758.6, abs=0.5)
# This is the number that sizes the hardware: a 1k rheostat is enough.
assert hi - lo < 1000.0
def test_one_ohm_is_finer_than_the_grill_displays():
# Grill shows whole degrees F; 1 ohm must be comfortably under 1 F (0.56 C).
assert resolution_c_per_ohm(100.0) < 0.4
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), -273.0, 900.0])
def test_implausible_values_rejected(bad):
assert not is_plausible(bad)
@pytest.mark.parametrize("good", [-20.0, 0.0, 93.3, 200.0])
def test_plausible_values_accepted(good):
assert is_plausible(good)
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from smokescreen.models import ProbeReading
from smokescreen.router import ProbeRouter
def reading(probe_id: str, tip_c: float = 60.0) -> ProbeReading:
return ProbeReading(probe_id=probe_id, tip_c=tip_c, source="test")
def test_pinned_probe_drives_its_jack():
router = ProbeRouter(jacks=[1, 2], pinned={2: "meater:aa"})
assert router.observe(reading("meater:aa")) == 2
def test_auto_assignment_fills_free_jacks_in_order():
router = ProbeRouter(jacks=[1, 2])
assert router.observe(reading("probe:a")) == 1
assert router.observe(reading("probe:b")) == 2
def test_auto_assignment_skips_pinned_jacks():
router = ProbeRouter(jacks=[1, 2], pinned={1: "meater:aa"})
assert router.observe(reading("probe:b")) == 2
def test_binding_is_sticky_across_a_dropout():
"""A probe that reconnects must not land on a different jack mid-cook."""
router = ProbeRouter(jacks=[1, 2])
assert router.observe(reading("probe:a")) == 1
assert router.observe(reading("probe:b")) == 2
assert router.observe(reading("probe:a", 70.0)) == 1
def test_extra_probes_have_nowhere_to_go():
router = ProbeRouter(jacks=[1, 2])
router.observe(reading("probe:a"))
router.observe(reading("probe:b"))
assert router.observe(reading("probe:c")) is None
assert [r.probe_id for r in router.unassigned()] == ["probe:c"]
def test_reading_for_returns_latest_sample():
router = ProbeRouter(jacks=[1])
router.observe(reading("probe:a", 50.0))
router.observe(reading("probe:a", 75.0))
assert router.reading_for(1).tip_c == 75.0
def test_reading_for_unbound_jack_is_none():
assert ProbeRouter(jacks=[1, 2]).reading_for(2) is None
def test_pin_to_unknown_jack_is_rejected():
with pytest.raises(ValueError, match="unknown jacks"):
ProbeRouter(jacks=[1, 2], pinned={3: "probe:a"})
def test_probe_pinned_twice_is_rejected():
with pytest.raises(ValueError, match="more than one jack"):
ProbeRouter(jacks=[1, 2], pinned={1: "probe:a", 2: "probe:a"})
+145
View File
@@ -0,0 +1,145 @@
"""ThermoWorks RFX cloud source tests.
The load-bearing behaviour here is staleness. ThermoWorks Cloud returns a
probe's last known value with a perfectly healthy HTTP 200 long after the probe
has gone away, so "the request succeeded" says nothing about whether the
temperature is current. These tests pin the backdating that makes the
fail-cold policy actually work for a cloud source.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from smokescreen.sources.thermoworks import (
_normalise_temp,
channel_to_reading,
)
NOW = datetime(2026, 8, 16, 12, 0, 0, tzinfo=timezone.utc)
def device(**kwargs):
return SimpleNamespace(
**{
"serial": "RFX123",
"label": "Brisket",
"device_name": "RFX MEAT",
"battery": 88,
"signal_strength": -62,
**kwargs,
}
)
def channel(age_s: float = 0.0, **kwargs):
return SimpleNamespace(
**{
"value": 165.0,
"units": "F",
"number": "1",
"label": "Internal",
"last_telemetry_saved": NOW - timedelta(seconds=age_s),
"last_seen": NOW - timedelta(seconds=age_s),
**kwargs,
}
)
def convert(dev, chan, **kwargs):
return channel_to_reading(
dev, chan, now_utc=NOW, monotonic_now=1000.0, **kwargs
)
@pytest.mark.parametrize(
"value,units,expected",
[
(212.0, "F", 100.0),
(32.0, "F", 0.0),
(100.0, "C", 100.0),
(100.0, "°C", 100.0),
(165.0, "f", 73.888),
],
)
def test_units_are_converted_to_celsius(value, units, expected):
assert _normalise_temp(value, units) == pytest.approx(expected, abs=0.01)
@pytest.mark.parametrize(
"units", [None, "", "kelvin", "rankine", "?", "furlongs", "cubits", "F/C"]
)
def test_unknown_units_are_refused_not_guessed(units):
"""Assuming F when it was C would feed 225C to a grill told 225F.
'furlongs' and 'cubits' are regression cases: a prefix match on F/C
silently converted them rather than rejecting them.
"""
assert _normalise_temp(100.0, units) is None
def test_fahrenheit_channel_converts():
reading = convert(device(), channel())
assert reading.tip_c == pytest.approx(73.89, abs=0.01)
assert reading.probe_id == "thermoworks:RFX123:1"
assert reading.display_name == "Internal"
assert reading.battery_pct == 88
def test_reading_is_backdated_by_cloud_age():
"""A 90s-old cloud value must arrive already 90s old, not brand new."""
reading = convert(device(), channel(age_s=90.0))
assert reading.timestamp == pytest.approx(1000.0 - 90.0)
assert reading.age_s(now=1000.0) == pytest.approx(90.0)
def test_fresh_reading_is_not_backdated():
reading = convert(device(), channel(age_s=0.0))
assert reading.age_s(now=1000.0) == pytest.approx(0.0)
def test_cloud_data_past_the_age_limit_is_dropped():
assert convert(device(), channel(age_s=3600.0), max_cloud_age_s=300.0) is None
def test_age_limit_boundary_is_inclusive():
assert convert(device(), channel(age_s=299.0), max_cloud_age_s=300.0) is not None
def test_channel_with_no_value_is_dropped():
assert convert(device(), channel(value=None)) is None
def test_channel_with_unknown_units_is_dropped():
assert convert(device(), channel(units="furlongs")) is None
def test_naive_timestamps_are_treated_as_utc():
naive = (NOW - timedelta(seconds=60)).replace(tzinfo=None)
reading = convert(device(), channel(last_telemetry_saved=naive, last_seen=naive))
assert reading.age_s(now=1000.0) == pytest.approx(60.0)
def test_falls_back_to_last_seen_when_telemetry_timestamp_missing():
chan = channel(age_s=45.0, last_telemetry_saved=None)
assert convert(device(), chan).age_s(now=1000.0) == pytest.approx(45.0)
def test_missing_timestamps_are_treated_as_current():
chan = channel(last_telemetry_saved=None, last_seen=None)
assert convert(device(), chan).age_s(now=1000.0) == pytest.approx(0.0)
def test_multiple_channels_get_distinct_probe_ids():
ids = {
convert(device(), channel(number=str(n))).probe_id for n in (1, 2)
}
assert ids == {"thermoworks:RFX123:1", "thermoworks:RFX123:2"}
def test_channel_label_falls_back_to_device_label():
reading = convert(device(), channel(label=None))
assert reading.display_name == "Brisket"