Merge pull request #239 from jelle514/Reduce-Estimated-finish-activity
Reduce Estimated-finish activity-log spam (washer/dryer/dishwasher)
This commit is contained in:
@@ -89,6 +89,15 @@ Entities appear under one HA device per appliance, named `Samsung Appliance (<ip
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Per-device settings
|
||||
|
||||
Each device has its own **Configure** option in Settings > 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
|
||||
|
||||
@@ -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_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))
|
||||
_HYSTERESIS_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_HYSTERESIS_MINUTES,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
),
|
||||
): _HYSTERESIS_MINUTES,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
# 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),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
@@ -156,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),
|
||||
hysteresis=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_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.
|
||||
hysteresis: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
|
||||
@@ -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_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_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._hysteresis_value = None
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
@@ -52,7 +55,38 @@ 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.hysteresis:
|
||||
return raw
|
||||
return self._apply_hysteresis(raw)
|
||||
|
||||
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
|
||||
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_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
|
||||
)
|
||||
if (
|
||||
threshold_min
|
||||
and raw is not None
|
||||
and self._hysteresis_value is not None
|
||||
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
|
||||
):
|
||||
return self._hysteresis_value
|
||||
self._hysteresis_value = raw
|
||||
return raw
|
||||
|
||||
|
||||
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
|
||||
|
||||
@@ -1144,15 +1144,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_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
|
||||
@@ -1144,15 +1144,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_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""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_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
|
||||
|
||||
_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_HYSTERESIS_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_hysteresis():
|
||||
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_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'
|
||||
)
|
||||
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'
|
||||
Reference in New Issue
Block a user