From a45ee4c00435e0de66b715f821c9387e6b925fa1 Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Tue, 18 Aug 2026 20:25:19 +0100 Subject: [PATCH] fix(mqtt): withhold the oven setpoint when no cycle is set With no cycle set the oven reports x.com.samsung.da.desired = 0, and flatten() published that straight through as target_temp_c. Home Assistant rejects it against the Number entity's declared 30-270 range on every publish, which produced 66,899 log errors over three weeks: Invalid value for number.samsung_oven_setpoint: 0 (range 30.0 - 270.0) 0 is not a 0 degree target, it is the absence of a setpoint, so treat anything outside the settable band as absent. null lands as unknown on both the Number and the Setpoint sensor, the way completion_minutes already reads when the oven is idle. _setpoint applied these bounds on the write side already; only the read path was missing them. Adds the first tests for the sample descriptors. One of them pins a non-obvious asymmetry: the write path snaps to the 5 degree step grid before bounds-checking, so 29 commits as 30 and 271 as 270, and only 0 is refused outright. The invariant that has to hold is the weaker one, that every value the write path commits is one flatten() will publish back, or a write appears to succeed and then reads as unknown. --- mqtt_demo/samples/oven.py | 8 ++++ tests/test_oven_flatten.py | 86 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 tests/test_oven_flatten.py diff --git a/mqtt_demo/samples/oven.py b/mqtt_demo/samples/oven.py index 83b4068..c365b17 100644 --- a/mqtt_demo/samples/oven.py +++ b/mqtt_demo/samples/oven.py @@ -166,6 +166,14 @@ def flatten(links): if temps_items: cur_c = _int(temps_items[0].get('x.com.samsung.da.current')) des_c = _int(temps_items[0].get('x.com.samsung.da.desired')) + # With no cycle set the oven reports desired=0. That means "no + # setpoint", not a 0 °C target, and HA rejects it against the Number + # entity's 30-270 range on every publish. Anything outside the + # settable band is absent, not a value: null lands as unknown on both + # the Number and the Setpoint sensor, the way completion_minutes + # already reads when idle. _setpoint applies the same bounds on write. + if des_c is not None and not (SETPOINT_MIN_C <= des_c <= SETPOINT_MAX_C): + des_c = None # Door doors_items = g('/doors/vs/0', 'x.com.samsung.da.items') or [] diff --git a/tests/test_oven_flatten.py b/tests/test_oven_flatten.py new file mode 100644 index 0000000..7ed1a58 --- /dev/null +++ b/tests/test_oven_flatten.py @@ -0,0 +1,86 @@ +"""Oven descriptor flatten() contracts for the HA Number entity's range. + +The oven reports ``x.com.samsung.da.desired = 0`` whenever no cycle is +set. That is "no setpoint", not a 0 °C target, and publishing it as one +makes Home Assistant reject every state message against the Number +entity's declared 30-270 range. +""" + +from __future__ import annotations + +import pytest + +from mqtt_demo.samples import oven + + +def _links(desired, current=180): + """A /temperatures/vs/0 link tree carrying one desired/current pair.""" + return { + '/temperatures/vs/0': { + 'x.com.samsung.da.items': [{ + 'x.com.samsung.da.current': str(current), + 'x.com.samsung.da.desired': str(desired), + }], + }, + } + + +@pytest.mark.parametrize('desired', [ + oven.SETPOINT_MIN_C, + oven.SETPOINT_MIN_C + oven.SETPOINT_STEP_C, + 180, + oven.SETPOINT_MAX_C, +]) +def test_settable_setpoints_are_published_unchanged(desired): + assert oven.flatten(_links(desired))['target_temp_c'] == desired + + +@pytest.mark.parametrize('desired', [ + 0, # the idle oven; see module docstring + oven.SETPOINT_MIN_C - 1, + oven.SETPOINT_MAX_C + 1, +]) +def test_unsettable_setpoints_are_published_as_absent(desired): + assert oven.flatten(_links(desired))['target_temp_c'] is None + + +def test_out_of_range_setpoint_does_not_suppress_current_temperature(): + """The guard applies to the setpoint alone. A cooling oven still + reports its cavity temperature after the cycle ends.""" + sensors = oven.flatten(_links(0, current=210)) + + assert sensors['target_temp_c'] is None + assert sensors['current_temp_c'] == 210 + + +def test_missing_temperature_resource_leaves_both_absent(): + sensors = oven.flatten({}) + + assert sensors['target_temp_c'] is None + assert sensors['current_temp_c'] is None + + +def test_every_committed_write_is_a_value_flatten_will_publish(): + """The write path snaps to the step grid *before* bounds-checking, so + it accepts more than flatten() publishes: 29 commits as 30, and 271 as + 270. That is fine for a slider, but it means the two range checks are + not symmetric. What has to hold is the weaker invariant: any setpoint + the oven is actually told to adopt is one flatten() will show back, + otherwise a write appears to succeed and then reads as unknown.""" + handler = oven.command_handlers()[oven.CMD_SETPOINT] + + for requested in range(-20, oven.SETPOINT_MAX_C + 40): + write = handler(str(requested), _links(180)) + if write is None: + continue + _path, body = write + committed = int(body['x.com.samsung.da.items'][0][ + 'x.com.samsung.da.desired']) + assert oven.flatten(_links(committed))['target_temp_c'] == committed + + +def test_zero_is_rejected_on_the_write_path_too(): + """0 is the one value that neither snaps into range nor publishes.""" + handler = oven.command_handlers()[oven.CMD_SETPOINT] + + assert handler('0', _links(180)) is None