From 96d06369bcf20e874660cbea47f0570e5d60477d Mon Sep 17 00:00:00 2001 From: kkqq9320 Date: Tue, 4 Aug 2026 14:36:40 +0900 Subject: [PATCH] review: floor the sensing interval at one minute Dropping native_min to 0 fixed the read range and quietly opened a write: native_min governs what the user can enter, not just what renders, so 0 became enterable and would have gone out as periodicSensingInterval "0". Nothing establishes what that does to this board -- both fixtures report 600, the app's smallest choice is 10 min, and 60 s is the lowest value confirmed accepted. The two precedents leaned on differ in exactly the way that matters: oven.cook_time and operational.delay_start_hours sit at a zero floor under a value where 0 is a real setting ("no timer", "no delay"). One minute is also the resolution this board reports results at. lastSensingTime lands on an exact minute on every sample from the AVT-WW-TP1 and A-VTWW-TP2 boards -- both fixtures, plus eleven consecutive live readings -- where the TP1X/AC/hood boards report arbitrary seconds. A sub-minute interval is unobservable here whether or not the board honours it. So native_min goes to 1 rather than 0, and the read rounds up instead of to nearest so a sub-minute reading renders as 1 rather than falling below the entity's own floor. The write still refuses anything under a minute -- a None return, the silent no-op range_hood._lamp_level_write uses for a level the device didn't advertise -- since native_min only guards the UI path, not a service call. --- .../registry/capabilities/air_purifier.py | 32 +++++++++++++++---- tests/test_air_purifier_airlevelcheck.py | 20 +++++++++--- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 8297c0d..887b6be 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -575,13 +575,15 @@ SOUND_VOLUME = Capability( def _interval_minutes(seconds): """Device stores the interval in seconds; the entity is in minutes. - `is None` rather than a falsy check so a reported 0 stays 0 instead of - reading as unknown. Sub-30s values round to 0, which is why native_min is - 0 rather than 1 -- matching oven.cook_time and operational's delay hours, - both of which convert a device time value and floor at zero. + `is None` rather than a falsy check so a reported 0 is distinguishable + from a missing one. Anything else nonzero rounds up rather than to + nearest, so a sub-minute value can't render as 0 and fall below the + entity's own floor. """ secs = int_or_none(seconds) - return round(secs / 60) if secs is not None else None + if secs is None: + return None + return -(-secs // 60) if secs > 0 else 0 def _interval_write(payload, rep, href=None): @@ -592,8 +594,24 @@ def _interval_write(payload, rep, href=None): # does advertise constraints where it has them -- and it accepts values the # app never offers. Writing 60 s, six times finer than the app's smallest # choice, drove an observed ~60 s sensing cycle on hardware. + # + # One minute is the floor because that's the resolution this board reports + # results at: lastSensingTime lands on an exact minute on every sample from + # the AVT-WW-TP1 and A-VTWW-TP2 boards (both fixtures, and eleven + # consecutive live readings), where the TP1X/AC/hood boards report arbitrary + # seconds. A sub-minute interval is therefore unobservable here whether or + # not the board honours it. Zero is refused for a separate reason: unlike + # oven.cook_time or operational's delay hours, where 0 is a real setting + # ("no timer", "no delay"), nothing establishes what a 0 interval does to + # this board -- so native_min stops the UI offering it, and this guard + # covers the service-call path. Silent no-op via a None return, the same + # shape range_hood._lamp_level_write uses for a level the device didn't + # advertise. + minutes = round(float(payload)) + if minutes < 1: + return None return ["airlevelcheck", "vs", "0"], { - "x.com.samsung.da.periodicSensingInterval": str(round(float(payload) * 60)) + "x.com.samsung.da.periodicSensingInterval": str(minutes * 60) } @@ -694,7 +712,7 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.periodicSensingInterval", icon="mdi:timer-cog", entity_category="config", - native_min=0, + native_min=1, native_max=60, step=1, unit="min", diff --git a/tests/test_air_purifier_airlevelcheck.py b/tests/test_air_purifier_airlevelcheck.py index 72b50f4..8410025 100644 --- a/tests/test_air_purifier_airlevelcheck.py +++ b/tests/test_air_purifier_airlevelcheck.py @@ -145,13 +145,25 @@ def test_interval_is_minutes_in_the_ui_and_seconds_on_the_wire(): def test_interval_keeps_zero_distinct_from_unknown(): - """`if secs` would fold a reported 0 into None; sub-30s values round to 0, - which is why native_min is 0 rather than 1.""" + """`if secs` would fold a reported 0 into None. Anything else nonzero + rounds up, so a sub-minute reading can't render as 0 and fall below the + entity's own floor.""" desc = _desc("sensing_interval") assert desc.value_fn("0") == 0 - assert desc.value_fn("20") == 0 + assert desc.value_fn("20") == 1 + assert desc.value_fn("61") == 2 assert desc.value_fn(None) is None - assert desc.native_min == 0 + + +def test_interval_floor_is_one_minute(): + """lastSensingTime lands on an exact minute on this board family, so a + sub-minute interval is unobservable; and 0 has no established meaning + here, unlike the zero floors on oven.cook_time / delay_start_hours.""" + desc = _desc("sensing_interval") + assert desc.native_min == 1 + assert desc.write_fn(0, {}) is None + assert desc.write_fn(0.4, {}) is None + assert desc.write_fn(1, {})[1] == {"x.com.samsung.da.periodicSensingInterval": "60"} # --- skip window -------------------------------------------------------------