From fe9a1128eaf6b94c31a3e0cc93a94864c03556c7 Mon Sep 17 00:00:00 2001 From: Jelle Lauwers Date: Fri, 31 Jul 2026 19:07:24 +0200 Subject: [PATCH 1/4] Round finish_time to the minute to stop recorder spam _finish_time added datetime.now(timezone.utc) (fresh seconds/ microseconds every call) to the device's remaining-time duration, so the returned timestamp differed at the sub-minute level on nearly every poll even when remainingTime itself hadn't changed. The recorder logged a new history/logbook entry each time, while the UI rounds the display down to the minute, making repeated polls look like duplicate identical entries. Round the result down to the minute so the entity only changes state when the estimate actually shifts. --- .../registry/capabilities/operational.py | 9 +++++- tests/test_operational_capability.py | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 6970140..193efb9 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -90,7 +90,14 @@ def _finish_time(rep): total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime')) if not total_s: return None - return datetime.now(timezone.utc) + timedelta(seconds=total_s) + # Round to whole minutes -- remainingTime itself only has minute + # resolution, but datetime.now() always carries fresh seconds/ + # microseconds, so an unrounded result changes on nearly every poll + # even when the device-reported remaining time hasn't. That floods + # the recorder history/logbook with values that look identical once + # the UI rounds them down for display. + finish = datetime.now(timezone.utc) + timedelta(seconds=total_s) + return finish.replace(second=0, microsecond=0) def _completion_minutes(rep): diff --git a/tests/test_operational_capability.py b/tests/test_operational_capability.py index 606efb6..5f50535 100644 --- a/tests/test_operational_capability.py +++ b/tests/test_operational_capability.py @@ -62,6 +62,34 @@ class TestCompletionMinutes: assert desc.rep_fn(rep) is None +class TestFinishTime: + """issue: remainingTime only has minute resolution, but datetime.now() + always carries fresh seconds/microseconds -- an unrounded finish_time + changed on nearly every poll even when remainingTime hadn't, flooding + the recorder history/logbook with values that looked identical once + the UI rounded them down to the minute for display.""" + + def test_seconds_and_microseconds_are_zeroed(self): + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time') + rep = { + 'x.com.samsung.da.state': 'Run', + 'x.com.samsung.da.remainingTime': '00:29:00', + } + result = desc.rep_fn(rep) + assert result.second == 0 + assert result.microsecond == 0 + + def test_stable_across_polls_within_same_minute(self): + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time') + rep = { + 'x.com.samsung.da.state': 'Run', + 'x.com.samsung.da.remainingTime': '00:29:00', + } + first = desc.rep_fn(rep) + second = desc.rep_fn(rep) + assert first == second + + class TestDelayFieldFallback: def test_reads_delay_end_time_when_delay_start_time_absent(self): from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE From d90562017f1cc2fc7f7beb30da4d8ea6c74bde95 Mon Sep 17 00:00:00 2001 From: Jelle Lauwers Date: Fri, 31 Jul 2026 19:25:45 +0200 Subject: [PATCH 2/4] Add configurable finish_time debounce to cut recorder churn further Minute-rounding stopped identical values from re-logging, but finish_time still updates on nearly every poll because now() + remaining is a continuously-drifting value between the device's own remaining-time revisions, and washers/dryers/dishwashers commonly revise that estimate by a minute or two mid-cycle anyway -- both are real, small changes that individually don't matter but each cost a recorder/logbook entry. Add a per-device Options Flow setting (finish_time_debounce_minutes, default 3) and a SensorDesc(debounce=True) opt-in. LocalThingsSensor now caches the last value it actually reported and only adopts a new one once it differs by at least the configured threshold, a cycle starts (no prior cache), or a cycle ends (new value is None) -- 0 disables it entirely, restoring today's behavior. Also pass config_entry explicitly into DataUpdateCoordinator's super().__init__() -- self.config_entry previously relied on an undocumented ContextVar fallback that upstream has flagged as removed in HA 2026.8, which the new debounce lookup needed to not be built on top of. --- custom_components/localthings/config_flow.py | 14 +++ custom_components/localthings/const.py | 11 ++ custom_components/localthings/coordinator.py | 1 + .../registry/capabilities/operational.py | 2 +- .../localthings/registry/entities.py | 5 + custom_components/localthings/sensor.py | 36 ++++++- .../localthings/translations/en.json | 9 +- .../localthings/translations/nl.json | 9 +- tests/test_sensor_debounce.py | 100 ++++++++++++++++++ 9 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 tests/test_sensor_debounce.py diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index c65fcec..3f11823 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -16,6 +16,9 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, ObjectSelector, SelectSelector, SelectSelectorConfig, @@ -31,12 +34,16 @@ 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, 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( + min=0, max=30, step=1, mode=NumberSelectorMode.BOX, +)) _LOGGER = logging.getLogger(__name__) @@ -462,6 +469,13 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): CONF_BYPASS_REMOTE_CONTROL, False ), ): bool, + vol.Required( + CONF_FINISH_TIME_DEBOUNCE_MINUTES, + default=self.config_entry.options.get( + CONF_FINISH_TIME_DEBOUNCE_MINUTES, + DEFAULT_FINISH_TIME_DEBOUNCE_MINUTES, + ), + ): _DEBOUNCE_MINUTES, }), ) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 10c04df..52162f0 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -22,6 +22,17 @@ 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 + # 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 # been seen as low as 49153, so we sweep the whole range for a live UDP port diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 2e35948..b9cc756 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -157,6 +157,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, self._log, + config_entry=entry, name=f"{DOMAIN}_{entry.data[CONF_HOST]}", update_interval=timedelta(seconds=SUMMARY_INTERVAL_S), ) diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 193efb9..9a24b6e 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -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', - rep_fn=_finish_time), + debounce=True, rep_fn=_finish_time), SensorDesc(key='completion_minutes', icon='mdi:clock-outline', unit='min', diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 903a322..4e1cb65 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -57,6 +57,11 @@ class SensorDesc(SamsungEntityDescription): unit: Optional[str] = None 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 + # 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 @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/localthings/sensor.py b/custom_components/localthings/sensor.py index bb1ec74..b63d590 100644 --- a/custom_components/localthings/sensor.py +++ b/custom_components/localthings/sensor.py @@ -1,6 +1,8 @@ """Sensor platform for Local Things.""" from __future__ import annotations +from datetime import timedelta + from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -12,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 DOMAIN +from .const import CONF_FINISH_TIME_DEBOUNCE_MINUTES, DEFAULT_FINISH_TIME_DEBOUNCE_MINUTES, DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included @@ -42,6 +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 @property def native_unit_of_measurement(self): @@ -52,7 +55,36 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity): @property def native_value(self): - return (self.coordinator.data or {}).get(self._state_key) + raw = (self.coordinator.data or {}).get(self._state_key) + if not self._bound.desc.debounce: + return raw + return self._debounce(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. + + Values like finish_time are `now() + remaining`, recomputed from + scratch every poll -- both wall-clock drift between the device's own + remaining-time updates and the device revising its own estimate mid- + cycle produce a stream of small, real changes that are individually + meaningless but each trigger a recorder/logbook entry. A cycle + ending (raw is None) or starting (cache empty) always passes through + immediately -- only in-between jitter while a value already exists + on both sides gets held back. + """ + threshold_min = self.coordinator.config_entry.options.get( + CONF_FINISH_TIME_DEBOUNCE_MINUTES, DEFAULT_FINISH_TIME_DEBOUNCE_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) + ): + return self._debounced_value + self._debounced_value = raw + return raw class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity): diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 5d718da..3aed0c7 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1138,15 +1138,16 @@ "init": { "title": "LocalThings options", "menu_options": { - "settings": "Remote control write settings", + "settings": "Device settings", "debug_write": "Debug: write to a resource" } }, "settings": { - "title": "Remote control write settings", - "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.", + "title": "Device settings", + "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" + "bypass_remote_control_lock": "Allow writes even when remote control is reported off", + "finish_time_debounce_minutes": "Estimated finish -- minimum change (minutes)" } }, "debug_write": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index f6d59f9..19caeaa 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1138,15 +1138,16 @@ "init": { "title": "LocalThings-opties", "menu_options": { - "settings": "Schrijfinstellingen voor afstandsbediening", + "settings": "Apparaatinstellingen", "debug_write": "Foutopsporing: naar een resource schrijven" } }, "settings": { - "title": "Schrijfinstellingen voor afstandsbediening", - "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.", + "title": "Apparaatinstellingen", + "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" + "bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld", + "finish_time_debounce_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)" } }, "debug_write": { diff --git a/tests/test_sensor_debounce.py b/tests/test_sensor_debounce.py new file mode 100644 index 0000000..ee31f59 --- /dev/null +++ b/tests/test_sensor_debounce.py @@ -0,0 +1,100 @@ +"""Unit tests for LocalThingsSensor's debounce 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.registry.capabilities.operational import OPERATIONAL_STATE +from custom_components.localthings.registry.discovery import BoundEntity +from custom_components.localthings.sensor import LocalThingsSensor + +_FINISH_TIME_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time') + + +class _FakeConfigEntry: + def __init__(self, options): + self.options = options + + +class _FakeCoordinator: + """Just enough surface for LocalThingsEntity/LocalThingsSensor.""" + + def __init__(self, threshold_minutes): + self.device_serial = 'TEST-SERIAL' + self.config_entry = _FakeConfigEntry({ + CONF_FINISH_TIME_DEBOUNCE_MINUTES: threshold_minutes, + }) + self.data: dict = {} + + +def _sensor(threshold_minutes=3): + coordinator = _FakeCoordinator(threshold_minutes) + bound = BoundEntity( + href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=_FINISH_TIME_DESC, + ) + sensor = LocalThingsSensor(coordinator, bound) + return sensor, coordinator + + +def test_small_change_is_suppressed(): + sensor, coordinator = _sensor(threshold_minutes=3) + base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + + coordinator.data = {'finish_time': base} + assert sensor.native_value == base + + coordinator.data = {'finish_time': base + timedelta(minutes=1)} + assert sensor.native_value == base, "a 1-minute wobble should be held back" + + +def test_change_past_threshold_is_reported(): + sensor, coordinator = _sensor(threshold_minutes=3) + base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + + coordinator.data = {'finish_time': base} + assert sensor.native_value == base + + new = base + timedelta(minutes=5) + coordinator.data = {'finish_time': new} + assert sensor.native_value == new + + +def test_zero_threshold_disables_debounce(): + sensor, coordinator = _sensor(threshold_minutes=0) + base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + + coordinator.data = {'finish_time': base} + assert sensor.native_value == base + + new = base + timedelta(seconds=1) + coordinator.data = {'finish_time': new} + assert sensor.native_value == new + + +def test_cycle_end_passes_through_immediately(): + """A cycle ending (finish_time -> None) must never be held back.""" + sensor, coordinator = _sensor(threshold_minutes=3) + base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + + coordinator.data = {'finish_time': base} + assert sensor.native_value == base + + coordinator.data = {'finish_time': None} + assert sensor.native_value is None + + +def test_non_debounced_sensor_is_unaffected(): + """A SensorDesc without debounce=True reads straight through, unchanged.""" + machine_state_desc = next( + e for e in OPERATIONAL_STATE.entities if e.key == 'machine_state' + ) + coordinator = _FakeCoordinator(threshold_minutes=3) + bound = BoundEntity( + href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=machine_state_desc, + ) + sensor = LocalThingsSensor(coordinator, bound) + + coordinator.data = {'machine_state': 'active'} + assert sensor.native_value == 'active' + coordinator.data = {'machine_state': 'idle'} + assert sensor.native_value == 'idle' From 9ab3c5827c19e47834add02383bc66cf80787135 Mon Sep 17 00:00:00 2001 From: Jelle Lauwers Date: Fri, 31 Jul 2026 20:27:48 +0200 Subject: [PATCH 3/4] 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. --- custom_components/localthings/config_flow.py | 12 ++++----- custom_components/localthings/const.py | 20 +++++++------- .../registry/capabilities/operational.py | 2 +- .../localthings/registry/entities.py | 4 +-- custom_components/localthings/sensor.py | 26 ++++++++++--------- .../localthings/translations/en.json | 2 +- .../localthings/translations/nl.json | 2 +- ..._debounce.py => test_sensor_hysteresis.py} | 12 ++++----- 8 files changed, 41 insertions(+), 39 deletions(-) rename tests/{test_sensor_debounce.py => test_sensor_hysteresis.py} (90%) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 3f11823..7049f9b 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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, }), ) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 52162f0..6c0eca7 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -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 diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 9a24b6e..dcd2039 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -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', diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 4e1cb65..aed65d1 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -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) diff --git a/custom_components/localthings/sensor.py b/custom_components/localthings/sensor.py index b63d590..00bad2c 100644 --- a/custom_components/localthings/sensor.py +++ b/custom_components/localthings/sensor.py @@ -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 diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 3aed0c7..73e2df1 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 19caeaa..79e69cc 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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": { diff --git a/tests/test_sensor_debounce.py b/tests/test_sensor_hysteresis.py similarity index 90% rename from tests/test_sensor_debounce.py rename to tests/test_sensor_hysteresis.py index ee31f59..bd90bf2 100644 --- a/tests/test_sensor_debounce.py +++ b/tests/test_sensor_hysteresis.py @@ -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' ) From 9f15d06e843ccb6b6c08eb83c26fd07f27f7bbc1 Mon Sep 17 00:00:00 2001 From: Jelle Lauwers Date: Fri, 31 Jul 2026 20:54:29 +0200 Subject: [PATCH 4/4] Document the two new per-device options in the README Bypass-remote-control already existed but was undocumented; finish_time hysteresis is new. Both live under the same Configure > Device settings menu, so cover them together as Part 4 rather than leaving a reader to discover them by opening the options flow. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 7aad706..a68029c 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,15 @@ Entities appear under one HA device per appliance, named `Samsung Appliance ( Devices & Services, under **Device settings**: + +- **Allow writes even when remote control is reported 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. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure. +- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change. + +--- + ## Development ### Docker Compose dev environment