Merge pull request #109 from mbillow/claude/device-support-issue-106-wa-washer

Add WA (top-load washer) consumer-model prefix (issue #106)
This commit is contained in:
Marc Billow authored and GitHub committed 2026-07-26 23:38:19 -05:00
commit 1177f0ed64
5 files changed
+399 -1

No files matched your search

@@ -86,6 +86,7 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
'DV': 'dryer',
'DW': 'dishwasher',
}
@@ -118,6 +119,13 @@ def _consumer_model_key(description: str) -> Optional[str]:
('8600', a bare second model number with no recognizable prefix). Scan
segments from the end and take the first one that resolves, rather
than assuming the last segment is always it.
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
board-family token, issue #87) also starts with 'WA' (the top-load-washer
prefix, issue #106) at this granularity. for_device_by_model() calls the
board-family modelNum checks first and this function only as a fallback,
so that ambiguity resolves correctly without this function needing to
know about unrelated device families.
"""
segments = (description or '').split('/', 1)[0].split('_')
for segment in reversed(segments):
@@ -140,7 +148,12 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
DeviceRegistry if the consumer-model code or modelNum resolves to a
known type, None otherwise.
"""
key = _consumer_model_key(description)
# Board-family modelNum tokens are checked first, and the fuzzier
# 2-letter consumer-model prefix from `description` only as a fallback
# (see the bottom of this function) -- some board tokens are themselves
# only 2-3 letters long ('WAC', issue #87) and would otherwise collide
# with an unrelated consumer prefix at that granularity ('WA', issue #106).
key = None
if key is None and 'REF' in _model_num_segments(model_num):
key = 'refrigerator'
# Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and
@@ -210,6 +223,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# board family's.
if key is None and '-COOKTOP-' in (model_num or '').upper():
key = 'induction_cooktop'
# Consumer-model prefix from `description` (washer/dryer/dishwasher) --
# last, since it's the fuzziest match (a bare 2-letter prefix) and would
# otherwise shadow the more specific board-family tokens above.
if key is None:
key = _consumer_model_key(description)
return _REGISTRY_BY_KEY.get(key) if key else None
+26
View File
@@ -0,0 +1,26 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"energy_kwh",
"finish_time",
"firmware_update",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"rinse_cycles",
"softener_low",
"spin_speed",
"wash_temperature"
]
}
+314
View File
@@ -0,0 +1,314 @@
{
"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": "121100",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785142800",
"x.com.samsung.da.cumulativeDateUTC": "1785110400"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015C",
"UpdateAllow_NotAllowed",
"Course_01",
"LaundryOutTime_0",
"SeamlessControl_Enable",
"KidsLockBypass_On",
"FreezeProtectionAlarm_On",
"WashingTimes_19",
"DrumCleanProposal_20",
"DrumCleanLog_2026-02-04T04:05:54|2026-02-28T08:48:43|2026-03-16T03:15:29|2026-03-26T02:55:05|2026-04-15T05:57:11|2026-04-30T05:37:44|2026-05-14T05:29:20|2026-06-04T01:55:39|2026-06-25T05:21:23|2026-07-11T03:10:21",
"DetergentOnce_0",
"DetergentLeft_0",
"DetergentBase_0",
"DetergentAlarm_Off",
"DetergentType_0",
"DetergentTotal_0",
"SoftenerOnce_0",
"SoftenerLeft_0",
"SoftenerBase_0",
"SoftenerAlarm_Off",
"SoftenerType_0",
"SoftenerTotal_0",
"SpecialFunction_4",
"AvailableDelayTime_73",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_420780820438A205BE",
"FreezeProtectionMode_Off",
"SendToDevice_Off",
"EnergyLevelSet_0503030303040304010505020204",
"MostUsed_01923FA33F510E60A17340",
"GeoFenceAlarm",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2026-02-04T04:05:54|2026-02-28T08:48:43|2026-03-16T03:15:29|2026-03-26T02:55:05|2026-04-15T05:57:11|2026-04-30T05:37:44|2026-05-14T05:29:20|2026-06-04T01:55:39|2026-06-25T05:21:23|2026-07-11T03:10:21",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"501923FA33F510E60A1734050923FA33F510E60A173400F933FA33F510E60A173407B923FA31F520E60A1724006933FA31F510E68A172400C933FA31F520E60A172404F9204A41052046666733308913FA33F510E60A170004A923FA31F520C645175554B923FA31F520C64517555269204A102510664A171114C913FA31F510E624171404D9204A410520466667555"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"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": "01:13: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",
"Weightsensing",
"Wash",
"Rinse",
"Spin",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Weightsensing",
"Wash",
"Rinse",
"Spin",
"Finish"
],
"currentJobState": "None",
"remainingTime": "01:13:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000",
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WA8000T/DC92-02810A_0002",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"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": "210",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02674A250414(F546)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20233741181128160FFFFF202810412011303403FFFF(015C2023374120281041_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "18112816,20113034",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2023394400000000032FFFFFFFFFFFFFFFFFFFFFFFFE(015C20233944FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "00000000,FFFFFFFF"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.spinLevel": "Medium",
"x.com.samsung.da.supportedSpinLevel": [
"NoSpin",
"Delicate",
"Low",
"Medium",
"High",
"ExtraHigh"
],
"x.com.samsung.da.rinseCycles": "2",
"x.com.samsung.da.supportedRinseCycles": [
"0",
"1",
"2",
"3",
"4",
"5"
],
"x.com.samsung.da.waterValve": "Cold",
"x.com.samsung.da.supportedWaterValve": [
"None",
"Cold",
"Warm",
"Hot"
],
"x.com.samsung.da.waterHeight": "0",
"x.com.samsung.da.supportedWaterHeight": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"NoDisplay"
],
"x.com.samsung.da.washTime": "21",
"x.com.samsung.da.supportedWashTime": [
"0",
"9",
"15",
"21",
"27",
"50+"
]
}
},
{
"href": "/st/washercourse/vs/0",
"rep": {
"x.com.samsung.da.st.washerMode": "Table_02_Course_01",
"x.com.samsung.da.st.courseTable": "Table_02"
}
},
{
"href": "/setting/vs/0",
"rep": {}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "true",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {
"x.com.samsung.da.currentStatus": "None"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"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": "4137000000",
"x.com.samsung.da.countryCode": "KR"
}
}
]
}
+25
View File
@@ -300,6 +300,20 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'airconditioner'
def test_wac_token_not_shadowed_by_wa_washer_prefix(self):
"""Regression: adding the 'WA' consumer-model prefix (issue #106)
must not hijack devices whose description literally equals their
modelNum (e.g. the Window AC family, issue #87) and so contains a
'WAC' segment -- 'WAC'[:2] == 'WA' would otherwise match the washer
prefix before the more specific '_WAC_' modelNum check ever runs."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000',
'TP1X_DA_AC_WAC_01001_0000',
)
assert reg is not None
assert reg.name == 'airconditioner'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
@@ -339,6 +353,17 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'washer'
def test_washer_wa_prefix(self):
"""Top-load washers (e.g. WA8000T) use the WA consumer-model prefix
-- issue #106."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000',
'DA_WM_TP2_20_COMMON_WA8000T/DC92-02810A_0002',
)
assert reg is not None
assert reg.name == 'washer'
def test_oven_via_oven_token(self):
"""Issue #55: a wall oven (NV7000BS/ET5) reports no oneUiVersion and
an unrecognized consumer token ('NV'); it falls back to the '-OVEN-'
+15
View File
@@ -61,6 +61,21 @@ def test_registry_reproduces_golden_state_keys_for_washer():
)
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'."""
from tests.conftest import _load_device
resources = _load_device('washer_wa8000t')
golden = json.loads((GOLDEN / 'washer_wa8000t.json').read_text())
state_keys = _new_state_keys('washer_wa8000t', 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_registry_reproduces_golden_state_keys_for_dryer():
from tests.conftest import _load_device
resources = _load_device('dryer')