Files
smokescreen/tests/test_thermoworks.py
T
steveandClaude Opus 5 ba0c3b58b6 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]>
2026-08-16 22:46:14 -04:00

146 lines
4.4 KiB
Python

"""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"