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:
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
)
|
||||
Reference in New Issue
Block a user