From 2a52cd9cfa419d0374a700aad17d3a7f93c656f0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 20 Aug 2026 02:24:34 +0000 Subject: [PATCH] laundry: add DV6800N's Table_00 dryer courses (issue #394) A DV6800N (DA_WM_A51_20_COMMON) reports the same /st/dryercourse/vs/0 courseTable 'Table_00' as issue #357's DVE45R6300W/A3 -- also DA_WM_A51_20_COMMON -- and its reporter listed 14 selectable courses read straight off the appliance's own menu, in the same order /course/vs/0's supportedOptions enumerates them. Initially treated this as a second, incompatible Table_00 code family and added a device-model-keyed disambiguation layer to laundry.cycle_select. That was an unproven assumption: the two reporters' confirmed sets are mostly non-overlapping subsets (11 vs 14 codes), which is exactly what you'd expect from two models on a shared board exposing different subsets of one course table via their own /course/vs/0 supportedOptions -- not evidence of two different code dictionaries. The one code both reporters confirmed, 'a5', means Bedding on both, which is corroborating, not neutral. No confirmed code conflicts between the two sets, so this folds #394's 14 codes straight into the existing dryer_cycle_table_00 catalog entry (mirrored to all shipped languages), same shape as #357's original translations-only change. Adds a scrubbed fixture + golden for the DV6800N dump and tests confirming zero unbound hrefs and that its course codes resolve through the shared, now-larger dryer_cycle_table_00 catalog. --- .../registry/capabilities/dryer.py | 6 + .../localthings/translations/cs.json | 15 +- .../localthings/translations/de.json | 15 +- .../localthings/translations/en.json | 15 +- .../localthings/translations/es.json | 15 +- .../localthings/translations/it.json | 15 +- .../localthings/translations/ko.json | 15 +- .../localthings/translations/nl.json | 15 +- tests/fixtures/dryer_dv6800n_device.json | 244 ++++++++++++++++++ tests/fixtures/golden/dryer_dv6800n.json | 23 ++ tests/test_dryer_capabilities.py | 65 ++++- tests/test_golden_regression.py | 19 ++ 12 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/dryer_dv6800n_device.json create mode 100644 tests/fixtures/golden/dryer_dv6800n.json diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index 7f02b8d..62a5b34 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -63,6 +63,12 @@ DRYER_SETTINGS = Capability( # code. It shares no codes with Table_03 above -- 'a5' Bedding here and # '01' Normal are both table-scoped, so a Table_03 dryer never picks up a # Table_00 label or vice versa (see laundry.cycle_select's table_href). +# A DV6800N -- same DA_WM_A51_20_COMMON board, also Table_00 -- confirmed +# 14 more courses the same way (issue #394); its /course/vs/0 supportedOptions +# only advertises a different subset of this same table (each model exposes +# whichever courses its hardware supports), not a conflicting code family -- +# the one code both reporters confirmed, 'a5', means Bedding on both. Folded +# into the same catalog entry below rather than a new one. # # Drum Clean+ maintenance tracking (issue #258) reuses washer.py's # DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 2b70ad2..d04d46e 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -344,7 +344,20 @@ "a4": "Časové sušení", "a6": "Rychlé sušení", "a3": "Sportovní oblečení", - "a2": "Jemné prádlo" + "a2": "Jemné prádlo", + "9a": "Bavlna", + "ca": "Provětrání", + "db": "Super Speed", + "99": "Smíšená náplň", + "93": "Žehlení", + "b5": "Vlna", + "d7": "Outdoor péče", + "96": "Studený vzduch", + "97": "Teplý vzduch", + "7f": "Časové sušení", + "98": "Rychlé sušení 35", + "eb": "Jemné prádlo", + "b6": "Syntetika" } }, "dryer_cycle_table_03": { diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index aedd8aa..1981d99 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -344,7 +344,20 @@ "a4": "Zeittrocknen", "a6": "Schnelltrocknen", "a3": "Sportkleidung", - "a2": "Feinwäsche" + "a2": "Feinwäsche", + "9a": "Baumwolle", + "ca": "Frischluft", + "db": "Super Speed", + "99": "Mischwäsche", + "93": "Bügeltrocken", + "b5": "Wolle", + "d7": "Outdoor-Pflege", + "96": "Kaltluft", + "97": "Warmluft", + "7f": "Zeittrocknen", + "98": "Schnelltrocknen 35", + "eb": "Feinwäsche", + "b6": "Synthetik" } }, "dryer_cycle_table_03": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 8f55d63..79a0d8e 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -344,7 +344,20 @@ "a4": "Time Dry", "a6": "Quick Dry", "a3": "Active Wear", - "a2": "Delicates" + "a2": "Delicates", + "9a": "Cotton", + "ca": "Air Wash", + "db": "Super Speed", + "99": "Mixed Load", + "93": "Iron Dry", + "b5": "Wool", + "d7": "Outdoor Care", + "96": "Cool Air", + "97": "Warm Air", + "7f": "Time Dry", + "98": "Quick Dry 35", + "eb": "Delicates", + "b6": "Synthetics" } }, "dryer_cycle_table_03": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 9192ae8..7a7f7ee 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -541,7 +541,20 @@ "a4": "Secado por tiempo", "a6": "Secado rápido", "a3": "Ropa deportiva", - "a2": "Delicados" + "a2": "Delicados", + "9a": "Algodón", + "ca": "Aireación", + "db": "Súper velocidad", + "99": "Carga mixta", + "93": "Planchado fácil", + "b5": "Lana", + "d7": "Cuidado para exterior", + "96": "Aire frío", + "97": "Aire caliente", + "7f": "Secado por tiempo", + "98": "Secado rápido 35", + "eb": "Delicados", + "b6": "Sintéticos" } }, "dryer_cycle_table_03": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 8bc2e64..d9a8179 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -344,7 +344,20 @@ "a4": "Asciugatura a tempo", "a6": "Asciugatura rapida", "a3": "Abbigliamento sportivo", - "a2": "Delicati" + "a2": "Delicati", + "9a": "Cotone", + "ca": "Arieggiatura", + "db": "Super Speed", + "99": "Carico misto", + "93": "Pronto da stirare", + "b5": "Lana", + "d7": "Cura outdoor", + "96": "Aria fredda", + "97": "Aria calda", + "7f": "Asciugatura a tempo", + "98": "Asciugatura rapida 35", + "eb": "Delicati", + "b6": "Sintetici" } }, "dryer_cycle_table_03": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 16eb962..a284d49 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -344,7 +344,20 @@ "a4": "시간건조", "a6": "쾌속건조", "a3": "운동복", - "a2": "섬세의류" + "a2": "섬세의류", + "9a": "면의류", + "ca": "송풍", + "db": "쾌속건조", + "99": "혼합", + "93": "다림질건조", + "b5": "울", + "d7": "아웃도어케어", + "96": "송풍건조", + "97": "온풍건조", + "7f": "시간건조", + "98": "쾌속건조 35분", + "eb": "섬세의류", + "b6": "합성섬유" } }, "dryer_cycle_table_03": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b44e5dc..d5f3a4e 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -344,7 +344,20 @@ "a4": "Tijdprogramma", "a6": "Snel drogen", "a3": "Sportkleding", - "a2": "Fijne was" + "a2": "Fijne was", + "9a": "Katoen", + "ca": "Luchtverfrissing", + "db": "Super Speed", + "99": "Gemengde was", + "93": "Strijkdroog", + "b5": "Wol", + "d7": "Outdoorverzorging", + "96": "Koude lucht", + "97": "Warme lucht", + "7f": "Tijdprogramma", + "98": "Snel drogen 35", + "eb": "Fijne was", + "b6": "Synthetisch" } }, "dryer_cycle_table_03": { diff --git a/tests/fixtures/dryer_dv6800n_device.json b/tests/fixtures/dryer_dv6800n_device.json new file mode 100644 index 0000000..62341f3 --- /dev/null +++ b/tests/fixtures/dryer_dv6800n_device.json @@ -0,0 +1,244 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "US\u0001" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0165", + "UpdateAllow_NotAllowed", + "Course_9A", + "MixedLoadBell_Disable", + "MixedLoadBellNoti_Nothing", + "LaundryOutTime_0", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_188", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_B22D00B4003C", + "WrinklePreventRunning_Off", + "EnergyLevelSet_050502050503020304010204030203", + "MostUsed_9AD20EE000", + "MixedLoadBellSet_02FFFF02FFFFFFFFFFFFFFFFFF02", + "UsagesDB_ok", + "EnergyKW_396", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "29AD20EE000CAD10EE000DBD204E00099D20EE00093D102E000B5D102E000D7D204E000A5D204E00096D000E10E97D000E10E7FD000E33E98D000E000EBD204E000B6D20EE000" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.cumulativePower": "692600", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1786964400", + "x.com.samsung.da.cumulativeDateUTC": "1786960800" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A230708(E257)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "17111305,17122616", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Drying", + "Cooling", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "03:08:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "03:08: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": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/st/dryercourse/vs/0", + "rep": { + "x.com.samsung.da.st.dryerMode": "Table_00_Course_9A", + "x.com.samsung.da.st.courseTable": "Table_00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off", + "x.com.samsung.da.dryLevel": "2", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "1", + "2", + "3" + ], + "x.com.samsung.da.dryTime": "00:00:00", + "x.com.samsung.da.supportedDryTime": [ + "00:00:00", + "00:30:00", + "01:00:00", + "01:30:00", + "02:00:00", + "02:30:00" + ], + "x.com.samsung.da.dryerType": "Electricity" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + } + ] +} diff --git a/tests/fixtures/golden/dryer_dv6800n.json b/tests/fixtures/golden/dryer_dv6800n.json new file mode 100644 index 0000000..a8c74de --- /dev/null +++ b/tests/fixtures/golden/dryer_dv6800n.json @@ -0,0 +1,23 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis", + "dry_level", + "dry_time", + "dryer_type", + "energy_kwh", + "finish_time", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index 4bdd19d..e707959 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -1,7 +1,7 @@ """Tests for dryer support and washer/dryer consistency (issue #14).""" 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.by_type import for_device_by_model, resolve from custom_components.localthings.registry.capabilities import dryer, ignored from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import SelectDesc @@ -90,8 +90,12 @@ def test_course_bound_to_shared_course_vs_0(): def test_reported_table_00_course_codes_are_translated(): - """The reporter confirmed these codes on a DVE45R6300W/A3 by selecting - each cycle and reading back the raw course code (issue #357).""" + """The DVE45R6300W/A3 reporter confirmed these codes by selecting each + cycle and reading back the raw course code (issue #357). A DV6800N -- + same DA_WM_A51_20_COMMON board, also Table_00 -- later confirmed 14 more + (issue #394): a different subset of the same table, not a conflicting + code family (its one code in common with #357, 'a5', means Bedding on + both), so both sets share the one dryer_cycle_table_00 catalog entry.""" from custom_components.localthings.catalog import translated_states desc = next( @@ -99,7 +103,32 @@ def test_reported_table_00_course_codes_are_translated(): ) table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}} assert desc.translation_key(table_00) == "dryer_cycle_table_00" - confirmed = {"01", "9c", "a5", "9e", "9b", "27", "a0", "a4", "a6", "a3", "a2"} + confirmed = { + "01", + "9c", + "a5", + "9e", + "9b", + "27", + "a0", + "a4", + "a6", + "a3", + "a2", # issue #357 + "9a", + "ca", + "db", + "99", + "93", + "b5", + "d7", + "96", + "97", + "7f", + "98", + "eb", + "b6", # issue #394 + } assert confirmed <= translated_states("select", "dryer_cycle_table_00") @@ -109,3 +138,31 @@ def test_st_dryercourse_is_ignored(): ignored_hrefs = {c.href for c in ignored.IGNORED} assert "/st/dryercourse/vs/0" in ignored_hrefs assert "/st/washercourse/vs/0" in ignored_hrefs + + +def _dv6800n(): + resources = _load_device("dryer_dv6800n") + reg = resolve(resources, device_types=("oic.wk.d", "oic.d.dryer")) + return reg, resources + + +def test_dv6800n_no_unbound_hrefs(): + """Every resource in the issue #394 dump binds or is ignored.""" + reg, resources = _dv6800n() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_dv6800n_course_codes_read_from_dump(): + """A DV6800N (DA_WM_A51_20_COMMON, issue #394) reports 'Table_00' same + as #357's DVE45R6300W/A3, so it resolves to the same catalog entry -- + its /course/vs/0 supportedOptions just advertises a different subset of + the same table (see test_reported_table_00_course_codes_are_translated).""" + _, resources = _dv6800n() + desc = next( + e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc) + ) + assert desc.translation_key(resources) == "dryer_cycle_table_00" + confirmed = ["9A", "CA", "DB", "99", "93", "B5", "D7", "A5", "96", "97", "7F", "98", "EB", "B6"] + assert desc.options(resources) == confirmed diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 3af2723..83de666 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -121,6 +121,25 @@ def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600(): ) +def test_registry_reproduces_golden_state_keys_for_dryer_dv6800n(): + """DA_WM_A51_20_COMMON/DV6800N (issue #394) resolves via /oic/d's + 'oic.d.dryer' device type, not board-token guessing -- its modelNum's + board tokens ('DA', 'WM', 'COMMON') are all deliberately excluded from + _BOARD_TOKEN_TO_KEY (see registry/by_type's module docstring).""" + from tests.conftest import _load_device + + resources = _load_device("dryer_dv6800n") + golden = json.loads((GOLDEN / "dryer_dv6800n.json").read_text()) + state_keys = _new_state_keys( + "dryer_dv6800n", resources, device_types=("oic.wk.d", "oic.d.dryer") + ) + 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_registry_reproduces_golden_state_keys_for_airconditioner(): from tests.conftest import _load_device