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