From e6d7dcddc86da70ed8f0800e97785ba78cc4bfdf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 3 Aug 2026 19:14:29 +0000 Subject: [PATCH] Add dryer Drum Clean+ tracking; fix multi-entry DrumCleanLog parsing (#258) Dryers report the same DrumCleanProposal_/WashingTimes_/DrumCleanLog_ options[] tokens washer.py already models for issue #9, so drum_clean_cycles_remaining/drum_clean_last_cleaned move to laundry.py and get bound on dryer's /course/vs/0 too. DrumCleanLog_ on the reporter's dump is a '|'-joined history of every past clean rather than washer's single bare timestamp -- the shared helper now takes the last (most recent) entry, which turns out to also fix a latent bug on four existing washer fixtures whose own DrumCleanLog_ was already multi-entry and silently failing to parse into drum_clean_last_cleaned. No heat-exchanger-clean tracking was found in either dump #258 supplied; noted in dryer.py so a future report knows this was checked. --- .../registry/capabilities/dryer.py | 29 +- .../registry/capabilities/laundry.py | 42 ++ .../registry/capabilities/washer.py | 46 +- .../dryer_tp1_21_drum_clean_device.json | 438 ++++++++++++++++++ .../golden/dryer_tp1_21_drum_clean.json | 28 ++ tests/fixtures/golden/washer_dryer_combo.json | 1 + tests/fixtures/golden/washer_flexwash.json | 1 + tests/fixtures/golden/washer_wa55a7700av.json | 3 +- tests/fixtures/golden/washer_wa8000t.json | 1 + tests/test_dryer_drum_clean.py | 86 ++++ tests/test_golden_regression.py | 41 +- 11 files changed, 674 insertions(+), 42 deletions(-) create mode 100644 tests/fixtures/dryer_tp1_21_drum_clean_device.json create mode 100644 tests/fixtures/golden/dryer_tp1_21_drum_clean.json create mode 100644 tests/test_dryer_drum_clean.py diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index d903b6c..9887bf4 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -11,7 +11,7 @@ the /course/vs/0 cycle select -- lives in laundry.py. from ..capability import Capability from ..entities import SensorDesc, SwitchDesc -from .laundry import cycle_select +from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned def _wrinkle_write(p, rep, href=None): @@ -49,6 +49,17 @@ DRYER_SETTINGS = Capability( # #80). The /st/dryercourse/vs/0 resource re-encodes the same selected # course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 # is ignored for washers. +# +# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's +# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same +# options[] array -- see laundry.drum_clean_cycles_remaining/ +# drum_clean_last_cleaned's docstrings for the field contract, including +# the dryer-specific '|'-joined multi-entry DrumCleanLog_ shape. No +# separate heat-exchanger-clean tracking was found on either dump #258 +# supplied (DV90BB7445GES7, DV91T6440LE/SA) -- no HeatExchanger*-prefixed +# token, nor any other options[] entry that looks like a second maintenance +# counter -- so if the Samsung app surfaces that reminder for these units, +# it isn't computed from anything this integration can read locally. DRYER_COURSE = Capability( href="/course/vs/0", entities=( @@ -57,6 +68,22 @@ DRYER_COURSE = Capability( icon="mdi:tumble-dryer", table_href="/st/dryercourse/vs/0", ), + SensorDesc( + key="drum_clean_cycles_remaining", + unit="cycles", + icon="mdi:tumble-dryer-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/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 802e371..512d4b8 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -20,6 +20,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` / `setNightLight` -- preserved exactly as they appear in the OCF resource rep. """ +from datetime import UTC, datetime from datetime import time as dt_time from ...catalog import has_entity_translation @@ -234,6 +235,47 @@ def option_value(options, prefix): return None +# Drum Clean+ maintenance tracking, from the same options[] array as the +# selected course -- shared by washer.py (issue #9) and dryer.py (issue +# #258); both families use identical DrumCleanProposal_/WashingTimes_/ +# DrumCleanLog_ tokens. DrumCleanProposal_ is the wash/dry-cycle interval +# between recommended cleans; WashingTimes_ is the count since the last +# one -- their difference is exactly the "N cycles until due" figure the +# Samsung app shows (verified on a washer: DrumCleanProposal_40 - +# WashingTimes_3 == 37, matching a live app screenshot's "Potreba cistenia +# po 37 cykloch"). +def drum_clean_cycles_remaining(rep): + opts = rep.get("x.com.samsung.da.options") or [] + proposal = option_value(opts, "DrumCleanProposal") + washed = option_value(opts, "WashingTimes") + if proposal is None or washed is None: + return None + try: + return max(int(proposal) - int(washed), 0) + except ValueError: + return None + + +# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO +# datetime (the last clean, verified against the same app screenshot's "10 +# days ago"); a dryer (issue #258's Dillton-reported dump) instead reports a +# '|'-joined history of every past clean, ten deep on that dump, in +# strictly increasing order. Splitting on '|' and taking the last element +# handles both shapes identically -- a no-'|' value is unaffected. No +# explicit timezone field accompanies either shape, so it's treated as UTC, +# matching this integration's convention for other bare ISO datetime fields +# (see fridge.py's night-light schedule comment). +def drum_clean_last_cleaned(rep): + raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog") + if not raw: + return None + last = raw.rsplit("|", 1)[-1] + try: + return datetime.fromisoformat(last).replace(tzinfo=UTC) + except ValueError: + return None + + def _course_codes_from_supported_options(course_rep): """Fallback for an empty/missing editCourseList: derive the selectable course list from /course/vs/0's own x.com.samsung.da.supportedOptions diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index ef41fe4..2a78d4b 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -15,8 +15,6 @@ here; they read washer-only fields off the same shared /course/vs/0 options array. """ -from datetime import UTC, datetime - from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc from .laundry import ( @@ -24,6 +22,8 @@ from .laundry import ( bool_option_switch, cycle_options, cycle_select, + drum_clean_cycles_remaining, + drum_clean_last_cleaned, hex_pairs, option_value, option_write, @@ -149,36 +149,10 @@ WASHER_SETTINGS = Capability( # --------------------------------------------------------------------------- -# Drum Clean+ maintenance tracking, from the same options[] array as the -# selected course. DrumCleanProposal_ is the wash-cycle interval between -# recommended cleans; WashingTimes_ is the count since the last one -- -# their difference is exactly the "N cycles until due" figure the Samsung -# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching -# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_ -# is the last-clean timestamp (verified against the same screenshot's "10 -# days ago"); no explicit timezone field accompanies it on this resource, -# so it's treated as UTC, matching this integration's convention for other -# bare ISO datetime fields (see fridge.py's night-light schedule comment). -def _drum_clean_cycles_remaining(rep): - opts = rep.get("x.com.samsung.da.options") or [] - proposal = option_value(opts, "DrumCleanProposal") - washed = option_value(opts, "WashingTimes") - if proposal is None or washed is None: - return None - try: - return max(int(proposal) - int(washed), 0) - except ValueError: - return None - - -def _drum_clean_last_cleaned(rep): - raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog") - if not raw: - return None - try: - return datetime.fromisoformat(raw).replace(tzinfo=UTC) - except ValueError: - return None +# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/ +# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue +# #258) since both families report identical DrumCleanProposal_/ +# WashingTimes_/DrumCleanLog_ tokens on the same options[] array. # Detergent/softener auto-dispense dosing, from the same options[] array @@ -340,16 +314,16 @@ WASHER_COURSE = Capability( unit="cycles", icon="mdi:washing-machine-alert", state_class="measurement", - exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None, - rep_fn=_drum_clean_cycles_remaining, + 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, + exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None, + rep_fn=drum_clean_last_cleaned, ), SelectDesc( key="detergent_quantity", diff --git a/tests/fixtures/dryer_tp1_21_drum_clean_device.json b/tests/fixtures/dryer_tp1_21_drum_clean_device.json new file mode 100644 index 0000000..a839211 --- /dev/null +++ b/tests/fixtures/dryer_tp1_21_drum_clean_device.json @@ -0,0 +1,438 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "211700", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1785693600", + "x.com.samsung.da.cumulativeDateUTC": "1785686400", + "x.com.samsung.da.cumulativeSavedPower": "0" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0165", + "UpdateAllow_NotAllowed", + "Course_16", + "AiOption_On", + "MixedLoadBell_Disable", + "MixedLoadBellNoti_Nothing", + "LaundryOutTime_0", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "WashingTimes_25", + "DrumCleanProposal_25", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_3", + "DetergentTotal_0", + "SpecialFunction_20", + "AvailableDelayTime_215", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_B214DCB4003C", + "WrinklePreventRunning_Off", + "QuickDrive_Off", + "SendToDevice_Off", + "GMT_04", + "DrumLight_Off", + "DryTimeSet_FF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060007FE020007FE020007FE0000000000000000", + "EnergyLevelSet_05030403050302040503050203030402010205", + "MostUsed_16D31E", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0", + "MixedLoadBellSet_0202FF0202FFFFFF02FFFFFFFFFFFFFFFFFF", + "EmptyingTheBucket_Nothing", + "SavingModeCondition_01010202171D0300", + "SavingMode_Off", + "QuickDriveSet_F0000000F000000000000000000000000000", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2023-12-03T22:07:18|2024-02-09T15:18:59|2024-07-07T23:06:16|2024-10-19T19:29:45|2025-01-26T16:10:23|2025-05-10T19:59:59|2025-07-13T14:58:26|2026-01-09T21:27:43|2026-03-21T18:52:46|2026-05-24T21:38:08", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "116D31E29D31E23D00017D31E18D21E19D20C1DD3181BD3181CD31E21D4101AD1021ED31820D10227D00025D00024D0004CD21C2BD000" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "On" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "03:35:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Drying", + "Cooling", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Drying", + "Cooling", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "03:35:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20317441|30010102001611000AA3031F00000000", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DV9400B/DC92-03267A_2C56", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "DR1", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20317441|30010102001611000AA3031F00000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02986A260118(A182)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20317441221114080FFFFF203267412210245204FFFF(01652031744120326741_30221118)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "03174A22111408,03267A22102452", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2032244122050903042FFFFFFFFFFFFFFFFFFFFFFFFE(016520322441FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "03224A22050903,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off", + "x.com.samsung.da.dryLevel": "Normal", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Damp", + "Less", + "Normal", + "More" + ], + "x.com.samsung.da.dryTime": "00:00:00", + "x.com.samsung.da.supportedDryTime": [ + "00:00:00", + "00:20:00", + "00:30:00", + "00:40:00", + "00:50:00", + "01:00:00", + "01:30:00", + "02:00:00", + "02:30:00", + "03:00:00", + "04:00:00" + ], + "x.com.samsung.da.dryerType": "Electricity" + } + }, + { + "href": "/st/dryercourse/vs/0", + "rep": { + "x.com.samsung.da.st.dryerMode": "Table_03_Course_16", + "x.com.samsung.da.st.courseTable": "Table_03" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_161B1D29231718191C211A1E202725244C2B", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_1618202B" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(DV90BB7445GES7)" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + }, + { + "href": "/wm/personalcourse/vs/0", + "rep": { + "x.com.samsung.da.courses": [ + "F1_00", + "F2_00", + "F3_00", + "F4_00", + "F5_00", + "F6_00", + "F7_00", + "F8_00", + "F9_00", + "FA_00" + ], + "x.com.samsung.da.maxCourseNum": "10" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "2026-03-04", + "otnList": [ + { + "type": "WIFI", + "modelId": "DA_WM_TP1_21_COMMON", + "versions": [ + "30260118" + ], + "visVersion": "260118" + }, + { + "type": "Micom", + "modelId": "01652031744120326741", + "versions": [ + "22111408", + "22102452" + ], + "visVersion": "221114" + }, + { + "type": "Micom", + "modelId": "016520322441FFFFFFFF", + "versions": [ + "22050903", + "FFFFFFFF" + ], + "visVersion": "220509" + } + ] + } + }, + { + "href": "/buzzersound/vs/0", + "rep": { + "supportedBuzzerSound": [ + "Off", + "On" + ], + "setBuzzerSound": "Off" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "CZ" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "0000-00-00T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "0000-00-00T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Prague", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/golden/dryer_tp1_21_drum_clean.json b/tests/fixtures/golden/dryer_tp1_21_drum_clean.json new file mode 100644 index 0000000..b10ac51 --- /dev/null +++ b/tests/fixtures/golden/dryer_tp1_21_drum_clean.json @@ -0,0 +1,28 @@ +{ + "state_keys": [ + "alarm_code", + "buzzer_sound", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis", + "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", + "dry_level", + "dry_time", + "dryer_type", + "energy_kwh", + "energy_saved_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json index da6f20c..4fc1806 100644 --- a/tests/fixtures/golden/washer_dryer_combo.json +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -10,6 +10,7 @@ "detergent_low", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "dry_level", "energy_kwh", "finish_time", diff --git a/tests/fixtures/golden/washer_flexwash.json b/tests/fixtures/golden/washer_flexwash.json index e100a59..b752199 100644 --- a/tests/fixtures/golden/washer_flexwash.json +++ b/tests/fixtures/golden/washer_flexwash.json @@ -9,6 +9,7 @@ "detergent_low", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "energy_kwh", "finish_time", "job_beginning_status", diff --git a/tests/fixtures/golden/washer_wa55a7700av.json b/tests/fixtures/golden/washer_wa55a7700av.json index 930f097..069b0a1 100644 --- a/tests/fixtures/golden/washer_wa55a7700av.json +++ b/tests/fixtures/golden/washer_wa55a7700av.json @@ -11,6 +11,7 @@ "detergent_water_hardness", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "energy_kwh", "finish_time", "firmware_update", @@ -27,4 +28,4 @@ "spin_speed", "wash_temperature" ] -} \ No newline at end of file +} diff --git a/tests/fixtures/golden/washer_wa8000t.json b/tests/fixtures/golden/washer_wa8000t.json index b0e0ef6..7cfedb5 100644 --- a/tests/fixtures/golden/washer_wa8000t.json +++ b/tests/fixtures/golden/washer_wa8000t.json @@ -9,6 +9,7 @@ "detergent_low", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "energy_kwh", "finish_time", "firmware_update", diff --git a/tests/test_dryer_drum_clean.py b/tests/test_dryer_drum_clean.py new file mode 100644 index 0000000..bd3d7e4 --- /dev/null +++ b/tests/test_dryer_drum_clean.py @@ -0,0 +1,86 @@ +"""Tests for dryer Drum Clean+ maintenance tracking (issue #258). + +Same DrumCleanProposal_/WashingTimes_/DrumCleanLog_ options[] tokens as +washer.py's issue #9 feature, shared via laundry.drum_clean_cycles_remaining/ +drum_clean_last_cleaned -- except DrumCleanLog_ on a dryer is a '|'-joined +history of every past clean rather than one bare timestamp. +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities.laundry import ( + drum_clean_cycles_remaining, + drum_clean_last_cleaned, +) +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + + +def _state(fixture_name): + resources = _load_device(fixture_name) + info = resources["/information/vs/0"] + reg = for_device_by_model( + info["x.com.samsung.da.modelNum"], + info["x.com.samsung.da.description"], + ) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_no_unbound_hrefs(): + resources = _load_device("dryer_tp1_21_drum_clean") + info = resources["/information/vs/0"] + reg = for_device_by_model( + info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] + ) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_confirmed_dump_reports_cycles_remaining_and_last_cleaned(): + """DA_WM_TP1_21_COMMON/DV9400B reports DrumCleanProposal_25/ + WashingTimes_25 (0 cycles remaining -- due now) and a ten-entry + '|'-joined DrumCleanLog_, most recent 2026-05-24T21:38:08.""" + state = _state("dryer_tp1_21_drum_clean") + assert state["drum_clean_cycles_remaining"] == 0 + assert str(state["drum_clean_last_cleaned"]) == "2026-05-24 21:38:08+00:00" + + +def test_dump_with_no_drum_clean_data_reports_neither_entity(): + """The default dryer fixture only has 'DrumCleanLog_Empty', no + DrumCleanProposal_/WashingTimes_ tokens at all -- both entities stay + gated off rather than reporting a fabricated 0 or an unparseable date.""" + state = _state("dryer") + assert "drum_clean_cycles_remaining" not in state + assert "drum_clean_last_cleaned" not in state + + +def test_multi_entry_drum_clean_log_takes_the_last_timestamp(): + rep = { + "x.com.samsung.da.options": [ + "DrumCleanLog_2023-12-03T22:07:18|2024-02-09T15:18:59|2026-05-24T21:38:08", + ] + } + assert str(drum_clean_last_cleaned(rep)) == "2026-05-24 21:38:08+00:00" + + +def test_single_entry_drum_clean_log_matches_washer_shape(): + """A bare, no-'|' value (the washer's own shape) parses identically.""" + rep = {"x.com.samsung.da.options": ["DrumCleanLog_2024-02-09T15:18:59"]} + assert str(drum_clean_last_cleaned(rep)) == "2024-02-09 15:18:59+00:00" + + +def test_empty_drum_clean_log_reports_none(): + rep = {"x.com.samsung.da.options": ["DrumCleanLog_Empty"]} + assert drum_clean_last_cleaned(rep) is None + + +def test_cycles_remaining_never_negative(): + rep = { + "x.com.samsung.da.options": [ + "DrumCleanProposal_25", + "WashingTimes_40", + ] + } + assert drum_clean_cycles_remaining(rep) == 0 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 3181bb9..8cfa8ac 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -72,7 +72,12 @@ def test_registry_reproduces_golden_state_keys_for_ehs(): def test_registry_reproduces_golden_state_keys_for_washer_wa8000t(): """Top-load washer (WA8000T, issue #106) reports no oneUiVersion and used the 'WA' consumer-model prefix, previously unmapped in - _CONSUMER_PREFIX_TO_KEY -- fell back to 'unknown'.""" + _CONSUMER_PREFIX_TO_KEY -- fell back to 'unknown'. + + Also gains drum_clean_last_cleaned (issue #258): this fixture's + DrumCleanLog_ is a '|'-joined multi-entry history, which always failed + datetime.fromisoformat and silently returned None before + laundry.drum_clean_last_cleaned learned to take the last entry.""" from tests.conftest import _load_device resources = _load_device("washer_wa8000t") @@ -270,7 +275,10 @@ def test_registry_reproduces_golden_state_keys_for_range_hood(): def test_registry_reproduces_golden_state_keys_for_washer_flexwash(): """FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW) report no oneUiVersion and previously fell through for_device_by_model's - consumer-prefix map entirely -- issue #19.""" + consumer-prefix map entirely -- issue #19. + + Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test + above for why.""" from tests.conftest import _load_device resources = _load_device("washer_flexwash") @@ -285,7 +293,10 @@ def test_registry_reproduces_golden_state_keys_for_washer_flexwash(): def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo(): """Washer/dryer combo units carry a writable dryLevel field on - /washer/vs/0 itself, with no separate dryer resource -- issue #22.""" + /washer/vs/0 itself, with no separate dryer resource -- issue #22. + + Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test + above for why.""" from tests.conftest import _load_device resources = _load_device("washer_dryer_combo") @@ -622,7 +633,10 @@ def test_registry_reproduces_golden_state_keys_for_washer_wa55a7700av(): -- a different board generation than the WA8000T's TP2_20_COMMON, reached through the same 'WA' consumer-model-prefix fallback (issue #106). Binds cleanly against the existing washer registry with zero - unbound hrefs.""" + unbound hrefs. + + Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test + above for why.""" from tests.conftest import _load_device resources = _load_device("washer_wa55a7700av") @@ -1263,6 +1277,25 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_fac_time_ ) +def test_registry_reproduces_golden_state_keys_for_dryer_tp1_21_drum_clean(): + """DA_WM_TP1_21_COMMON/DV9400B (issue #258) is the first dryer dump with + live DrumCleanProposal_/WashingTimes_ tokens (dryer's default fixture + only ever had 'DrumCleanLog_Empty', same as the parallel washer capability + started from) -- locks in the new drum_clean_cycles_remaining/ + drum_clean_last_cleaned entities, including the dryer-specific + '|'-joined multi-timestamp DrumCleanLog_ shape.""" + from tests.conftest import _load_device + + resources = _load_device("dryer_tp1_21_drum_clean") + golden = json.loads((GOLDEN / "dryer_tp1_21_drum_clean.json").read_text()) + state_keys = _new_state_keys("dryer_tp1_21_drum_clean", resources) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump