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