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.
This commit is contained in:
Marc Billow
2026-08-20 02:53:16 +00:00
parent 16dfcd4e50
commit 2a52cd9cfa
12 changed files with 451 additions and 11 deletions
@@ -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
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
+244
View File
@@ -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"
}
}
]
}
+23
View File
@@ -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"
]
}
+61 -4
View File
@@ -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
+19
View File
@@ -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