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.
This commit is contained in:
@@ -166,6 +166,14 @@ def flatten(links):
|
|||||||
if temps_items:
|
if temps_items:
|
||||||
cur_c = _int(temps_items[0].get('x.com.samsung.da.current'))
|
cur_c = _int(temps_items[0].get('x.com.samsung.da.current'))
|
||||||
des_c = _int(temps_items[0].get('x.com.samsung.da.desired'))
|
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
|
# Door
|
||||||
doors_items = g('/doors/vs/0', 'x.com.samsung.da.items') or []
|
doors_items = g('/doors/vs/0', 'x.com.samsung.da.items') or []
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user