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.
This commit is contained in:
Marc Billow
2026-08-03 19:14:29 +00:00
parent cf09247e39
commit e6d7dcddc8
11 changed files with 674 additions and 42 deletions
@@ -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,
),
),
)
@@ -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_<N> is the wash/dry-cycle interval
# between recommended cleans; WashingTimes_<N> 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
@@ -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_<N> is the wash-cycle interval between
# recommended cleans; WashingTimes_<N> 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",
+438
View File
@@ -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"
}
}
]
}
+28
View File
@@ -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"
]
}
+1
View File
@@ -10,6 +10,7 @@
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"dry_level",
"energy_kwh",
"finish_time",
+1
View File
@@ -9,6 +9,7 @@
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"finish_time",
"job_beginning_status",
+2 -1
View File
@@ -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"
]
}
}
+1
View File
@@ -9,6 +9,7 @@
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"finish_time",
"firmware_update",
+86
View File
@@ -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
+37 -4
View File
@@ -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