From 367017cc4c598e8d3ea54ffaa85aaf172da9e292 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 17 Aug 2026 15:50:29 +0000 Subject: [PATCH] Add Drum Clean+ sensors for dishwasher The dishwasher's /course/vs/0 options[] array carries the same WashingTimes_/DrumCleanProposal_/DrumCleanLog_ trio already read by washer.py (issue #9) and dryer.py (issue #258), confirmed against a live dump, but dishwasher.py never wired the shared laundry.drum_clean_cycles_remaining/drum_clean_last_cleaned readers in. Add the two sensors to CYCLE_OPTIONS the same way, plus tests and an updated golden fixture. --- .../registry/capabilities/dishwasher.py | 31 +++++++++++- tests/fixtures/golden/dishwasher.json | 4 ++ tests/test_dishwasher_capabilities.py | 49 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index 2b3e656..993bbe3 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -10,7 +10,12 @@ wash, auto release dry) are read locally here. from ..capability import Capability from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc from .common import diagnosis_status -from .laundry import bool_option_switch, cycle_select +from .laundry import ( + bool_option_switch, + cycle_select, + drum_clean_cycles_remaining, + drum_clean_last_cleaned, +) # --------------------------------------------------------------------------- # /dishwasher/vs/0 — cycle wash/dry settings @@ -52,6 +57,14 @@ DISHWASHER_SETTINGS = Capability( # kind of adjacent pair a manual screenshot transcription slips on. The # reporter's live confirmation (selecting 'Normal' ran the physical Express # 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal. +# +# Drum Clean+ maintenance tracking reuses washer.py/dryer.py's (issues #9, +# #258) DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens riding on this +# same options[] array -- a live dump confirmed the dishwasher reports the +# identical trio (WashingTimes_18/DrumCleanProposal_20, plus a '|'-joined +# DrumCleanLog_ history matching the dryer's multi-entry shape), so the +# shared laundry.drum_clean_cycles_remaining/drum_clean_last_cleaned readers +# apply unchanged; see laundry.py for the field semantics. CYCLE_OPTIONS = Capability( href="/course/vs/0", @@ -61,6 +74,22 @@ CYCLE_OPTIONS = Capability( bool_option_switch( "auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True ), + SensorDesc( + key="drum_clean_cycles_remaining", + unit="cycles", + icon="mdi:dishwasher-alert", + state_class="measurement", + exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None, + rep_fn=drum_clean_cycles_remaining, + ), + SensorDesc( + key="drum_clean_last_cleaned", + device_class="timestamp", + icon="mdi:calendar-clock", + entity_category="diagnostic", + exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None, + rep_fn=drum_clean_last_cleaned, + ), ), ) diff --git a/tests/fixtures/golden/dishwasher.json b/tests/fixtures/golden/dishwasher.json index 6dedaa7..c2df5f6 100644 --- a/tests/fixtures/golden/dishwasher.json +++ b/tests/fixtures/golden/dishwasher.json @@ -8,6 +8,8 @@ "cycle_active", "delay_start_hours", "diagnosis_status", + "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "energy_kwh", "energy_saved_kwh", "finish_time", @@ -40,6 +42,8 @@ "samsung_dishwasher_delay_start_hours", "samsung_dishwasher_diagnosis_start", "samsung_dishwasher_diagnosis_status", + "samsung_dishwasher_drum_clean_cycles_remaining", + "samsung_dishwasher_drum_clean_last_cleaned", "samsung_dishwasher_energy_kwh", "samsung_dishwasher_energy_saved_kwh", "samsung_dishwasher_finish_time", diff --git a/tests/test_dishwasher_capabilities.py b/tests/test_dishwasher_capabilities.py index f836326..f49a139 100644 --- a/tests/test_dishwasher_capabilities.py +++ b/tests/test_dishwasher_capabilities.py @@ -5,6 +5,8 @@ cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we check the dishwasher wiring and its device-specific options. """ +from datetime import UTC, datetime + from custom_components.localthings.registry.capabilities import dishwasher from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc @@ -62,6 +64,53 @@ class TestDishwasherOptions: assert desc.exists_fn({"x.com.samsung.da.options": ["AutoDoorRelease_On"]}, {}) is True +class TestDrumClean: + """Drum Clean+ maintenance tracking shares washer.py's (issue #9) / + dryer.py's (issue #258) options[]-array readers -- a live dishwasher + dump confirmed the same WashingTimes_/DrumCleanProposal_/DrumCleanLog_ + trio, so these entities are wired the same way here.""" + + def test_cycles_remaining(self): + desc = next( + e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_cycles_remaining" + ) + assert desc.rep_fn is not None + rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]} + assert desc.rep_fn(rep) == 2 + + def test_cycles_remaining_exists_only_when_computable(self): + desc = next( + e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_cycles_remaining" + ) + assert desc.exists_fn is not None + assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False + rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]} + assert desc.exists_fn(rep, {}) is True + + def test_last_cleaned(self): + """A live dump's DrumCleanLog_ is a '|'-joined history (the dryer + shape, not the washer's single-entry one) -- the last entry wins.""" + desc = next( + e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_last_cleaned" + ) + assert desc.rep_fn is not None + rep = { + "x.com.samsung.da.options": [ + "DrumCleanLog_2026-06-26T04:18:58|2026-06-28T00:36:34", + ] + } + assert desc.rep_fn(rep) == datetime(2026, 6, 28, 0, 36, 34, tzinfo=UTC) + + def test_last_cleaned_missing(self): + desc = next( + e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_last_cleaned" + ) + assert desc.rep_fn is not None + assert desc.rep_fn({"x.com.samsung.da.options": []}) is None + assert desc.exists_fn is not None + assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False + + def test_diagnosis_status_is_a_translatable_enum(): desc = next( e