Rename finish_time debounce -> hysteresis to match actual behavior

The gate holds finish_time at its last reported value until a new one
differs by at least a configured number of minutes, regardless of how
long that difference has been building up -- a magnitude-based deadband,
not a time-based debounce (which would wait for the value to stay put for
N minutes before accepting it). debounce invited the wrong mental model
for anyone reading the option name or the code later, so rename it
throughout before the option name ships: CONF_FINISH_TIME_DEBOUNCE_MINUTES
-> CONF_FINISH_TIME_HYSTERESIS_MINUTES, SensorDesc.debounce -> hysteresis,
LocalThingsSensor._debounce/_debounced_value -> _apply_hysteresis/
_hysteresis_value, and the options-flow data key/translations to match.
This commit is contained in:
Jelle Lauwers
2026-07-31 20:27:48 +02:00
parent d90562017f
commit 9ab3c5827c
8 changed files with 41 additions and 39 deletions
+6 -6
View File
@@ -34,14 +34,14 @@ from .const import (
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
CONF_FINISH_TIME_DEBOUNCE_MINUTES, DEFAULT_FINISH_TIME_DEBOUNCE_MINUTES,
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
_DEBOUNCE_MINUTES = NumberSelector(NumberSelectorConfig(
_HYSTERESIS_MINUTES = NumberSelector(NumberSelectorConfig(
min=0, max=30, step=1, mode=NumberSelectorMode.BOX,
))
@@ -470,12 +470,12 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
),
): bool,
vol.Required(
CONF_FINISH_TIME_DEBOUNCE_MINUTES,
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
default=self.config_entry.options.get(
CONF_FINISH_TIME_DEBOUNCE_MINUTES,
DEFAULT_FINISH_TIME_DEBOUNCE_MINUTES,
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _DEBOUNCE_MINUTES,
): _HYSTERESIS_MINUTES,
}),
)
+10 -10
View File
@@ -22,16 +22,16 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# Options-flow key: minimum change (in minutes) required before a debounced
# timestamp sensor (currently just finish_time) is allowed to report a new
# value. Devices commonly revise their own remaining-time estimate by a
# minute or two throughout a cycle, and finish_time = now() + remaining
# drifts by the poll interval between those revisions -- both push a fresh
# state (and a recorder/logbook entry) far more often than the estimate is
# meaningfully different. 0 disables debouncing (every computed change is
# reported, today's behavior).
CONF_FINISH_TIME_DEBOUNCE_MINUTES = "finish_time_debounce_minutes"
DEFAULT_FINISH_TIME_DEBOUNCE_MINUTES = 3
# Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
# to report a new value. Devices commonly revise their own remaining-time
# estimate by a minute or two throughout a cycle, and finish_time = now() +
# remaining drifts by the poll interval between those revisions -- both push
# a fresh state (and a recorder/logbook entry) far more often than the
# estimate is meaningfully different. 0 disables the gate (every computed
# change is reported, today's behavior).
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
@@ -163,7 +163,7 @@ OPERATIONAL_STATE = Capability(
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
debounce=True, rep_fn=_finish_time),
hysteresis=True, rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
@@ -58,10 +58,10 @@ class SensorDesc(SamsungEntityDescription):
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_DEBOUNCE_MINUTES threshold (see sensor.py). Only for
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
# device-side revisions -- not a general-purpose flag every sensor should set.
debounce: bool = False
hysteresis: bool = False
@dataclass(frozen=True, kw_only=True)
+14 -12
View File
@@ -14,7 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import CONF_FINISH_TIME_DEBOUNCE_MINUTES, DEFAULT_FINISH_TIME_DEBOUNCE_MINUTES, DOMAIN
from .const import CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
@@ -44,7 +44,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
self._debounced_value = None
self._hysteresis_value = None
@property
def native_unit_of_measurement(self):
@@ -56,13 +56,15 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
@property
def native_value(self):
raw = (self.coordinator.data or {}).get(self._state_key)
if not self._bound.desc.debounce:
if not self._bound.desc.hysteresis:
return raw
return self._debounce(raw)
return self._apply_hysteresis(raw)
def _debounce(self, raw):
"""Suppress a new value until it differs from the last one this
entity actually reported by at least the configured threshold.
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
that difference has been building up (this is a deadband, not a
time-based debounce).
Values like finish_time are `now() + remaining`, recomputed from
scratch every poll -- both wall-clock drift between the device's own
@@ -74,16 +76,16 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
on both sides gets held back.
"""
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_DEBOUNCE_MINUTES, DEFAULT_FINISH_TIME_DEBOUNCE_MINUTES
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
if (
threshold_min
and raw is not None
and self._debounced_value is not None
and abs(raw - self._debounced_value) < timedelta(minutes=threshold_min)
and self._hysteresis_value is not None
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
):
return self._debounced_value
self._debounced_value = raw
return self._hysteresis_value
self._hysteresis_value = raw
return raw
@@ -1147,7 +1147,7 @@
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_debounce_minutes": "Estimated finish -- minimum change (minutes)"
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
}
},
"debug_write": {
@@ -1147,7 +1147,7 @@
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_debounce_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
}
},
"debug_write": {
@@ -1,9 +1,9 @@
"""Unit tests for LocalThingsSensor's debounce gate (finish_time churn)."""
"""Unit tests for LocalThingsSensor's hysteresis gate (finish_time churn)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from custom_components.localthings.const import CONF_FINISH_TIME_DEBOUNCE_MINUTES
from custom_components.localthings.const import CONF_FINISH_TIME_HYSTERESIS_MINUTES
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.sensor import LocalThingsSensor
@@ -22,7 +22,7 @@ class _FakeCoordinator:
def __init__(self, threshold_minutes):
self.device_serial = 'TEST-SERIAL'
self.config_entry = _FakeConfigEntry({
CONF_FINISH_TIME_DEBOUNCE_MINUTES: threshold_minutes,
CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes,
})
self.data: dict = {}
@@ -59,7 +59,7 @@ def test_change_past_threshold_is_reported():
assert sensor.native_value == new
def test_zero_threshold_disables_debounce():
def test_zero_threshold_disables_hysteresis():
sensor, coordinator = _sensor(threshold_minutes=0)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
@@ -83,8 +83,8 @@ def test_cycle_end_passes_through_immediately():
assert sensor.native_value is None
def test_non_debounced_sensor_is_unaffected():
"""A SensorDesc without debounce=True reads straight through, unchanged."""
def test_non_hysteresis_sensor_is_unaffected():
"""A SensorDesc without hysteresis=True reads straight through, unchanged."""
machine_state_desc = next(
e for e in OPERATIONAL_STATE.entities if e.key == 'machine_state'
)