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.
This commit is contained in:
Jelle Lauwers
2026-07-31 19:25:45 +02:00
parent fe9a1128ea
commit d90562017f
9 changed files with 176 additions and 11 deletions
@@ -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,
}),
)
+11
View File
@@ -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
@@ -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),
)
@@ -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',
@@ -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)
+34 -2
View File
@@ -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):
@@ -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": {
@@ -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": {
+100
View File
@@ -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'