From 6003394cb29a9a558f4b610d090b76d6f8056f95 Mon Sep 17 00:00:00 2001 From: Marek Tyburec Date: Wed, 19 Aug 2026 10:02:07 +0200 Subject: [PATCH 1/3] Add AddWash controls and sensors for washers --- .../registry/capabilities/washer.py | 110 ++++++++- .../localthings/translations/cs.json | 18 ++ .../localthings/translations/de.json | 18 ++ .../localthings/translations/en.json | 18 ++ .../localthings/translations/es.json | 18 ++ .../localthings/translations/it.json | 18 ++ .../localthings/translations/ko.json | 18 ++ .../localthings/translations/nl.json | 18 ++ tests/fixtures/golden/washer_dryer_combo.json | 6 + tests/fixtures/golden/washer_ww6500.json | 28 +++ tests/fixtures/washer_ww6500_device.json | 233 ++++++++++++++++++ tests/test_golden_regression.py | 16 ++ tests/test_washer_add_wash.py | 223 +++++++++++++++++ 13 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/golden/washer_ww6500.json create mode 100644 tests/fixtures/washer_ww6500_device.json create mode 100644 tests/test_washer_add_wash.py diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index af289b8..97d26a4 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -16,7 +16,7 @@ array. """ from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc from .laundry import ( bool_option_exists, bool_option_switch, @@ -264,6 +264,90 @@ def _bool_option_switch(key, icon, prefix, availability_field): ) +# AddWash -- the little door for adding a forgotten sock mid-cycle -- rides +# three independent tokens on the same options[] array: +# +# AddWashSet_<0-7> the alarm setting, and the only writable one: +# a 3-bit mask over the moments it fires, bit 0 +# rinse, bit 1 final rinse, bit 2 spin. +# AddWashAvailable_<0-7> the same three bits, but what the running +# course still permits. +# AddWashIndicator_On/Off the panel lamp: laundry may go in right now. +# +# Bit order confirmed by watching a WW6500 run a cycle: AddWashAvailable +# shed one bit as each moment passed (7 through Rinse, then 6, 4, and 0 as +# Spin began) and reset to 7 at the end, while the lamp tracked the phase +# with the alarm switched off throughout. + + +def _add_wash_mask(rep, prefix): + """One of the 3-bit AddWash masks, or None when its token is absent, + malformed, or outside 0-7. Never 0 for a missing token: 0 is a real + value, and a mask this model can't represent is a wrong model rather + than something to write back.""" + raw = option_value(rep.get("x.com.samsung.da.options"), prefix) + try: + mask = int(raw) + except (TypeError, ValueError): + return None + return mask if 0 <= mask <= 0b111 else None + + +def _add_wash_any(prefix): + """Whether any of the three moments is set in `prefix`'s mask.""" + + def read(rep): + mask = _add_wash_mask(rep, prefix) + return None if mask is None else mask != 0 + + return read + + +def _add_wash_set_write(mask): + return ["course", "vs", "0"], { + "x.com.samsung.da.options": option_write("AddWashSet", str(mask)), + } + + +def _add_wash_alarm_write(p, rep, href=None): + if p not in ("On", "Off") or not rep.get("x.com.samsung.da.options"): + return None + return _add_wash_set_write(0b111 if p == "On" else 0) + + +def _add_wash_bit_switch(key, icon, bit): + """One moment the alarm fires at, as its own bit of the mask. + + The mask is the only state, so switching the last moment off lands on 0 + and takes the alarm with it, and switching one on from 0 turns the alarm + back on. + """ + + def read(rep): + mask = _add_wash_mask(rep, "AddWashSet") + return None if mask is None else bool(mask >> bit & 1) + + def write(p, rep, href=None): + mask = _add_wash_mask(rep, "AddWashSet") + if p not in ("On", "Off") or mask is None: + return None + return _add_wash_set_write(mask | 1 << bit if p == "On" else mask & ~(1 << bit)) + + return SwitchDesc( + key=key, + icon=icon, + entity_category="config", + exists_fn=bool_option_exists("AddWashSet"), + rep_fn=read, + write_fn=write, + ) + + +def _add_wash_indicator(rep): + raw = option_value(rep.get("x.com.samsung.da.options"), "AddWashIndicator") + return raw.lower() == "on" if isinstance(raw, str) else None + + WASHER_COURSE = Capability( href="/course/vs/0", entities=( @@ -350,5 +434,29 @@ WASHER_COURSE = Capability( _bool_option_switch( "intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet" ), + SwitchDesc( + key="add_wash_alarm", + icon="mdi:bell-ring", + entity_category="config", + exists_fn=bool_option_exists("AddWashSet"), + rep_fn=_add_wash_any("AddWashSet"), + write_fn=_add_wash_alarm_write, + ), + _add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0), + _add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1), + _add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2), + BinarySensorDesc( + key="add_wash_available", + icon="mdi:tshirt-crew-outline", + entity_category="diagnostic", + exists_fn=bool_option_exists("AddWashAvailable"), + rep_fn=_add_wash_any("AddWashAvailable"), + ), + BinarySensorDesc( + key="add_wash_indicator", + icon="mdi:door-open", + exists_fn=bool_option_exists("AddWashIndicator"), + rep_fn=_add_wash_indicator, + ), ), ) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 2b70ad2..7d6736c 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -109,6 +109,12 @@ "softener_low": { "name": "Málo aviváže" }, + "add_wash_available": { + "name": "AddWash povoleno" + }, + "add_wash_indicator": { + "name": "AddWash připraveno" + }, "stick_ble_connected": { "name": "Tyč připojena přes BLE" } @@ -1351,6 +1357,18 @@ "intensive": { "name": "Intenzivní" }, + "add_wash_alarm": { + "name": "Alarm AddWash" + }, + "add_wash_alarm_rinse": { + "name": "AddWash při máchání" + }, + "add_wash_alarm_final_rinse": { + "name": "AddWash při posledním máchání" + }, + "add_wash_alarm_spin": { + "name": "AddWash při odstřeďování" + }, "lamp": { "name": "Lampa" }, diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index aedd8aa..3b9fe6d 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -103,6 +103,12 @@ "softener_low": { "name": "Weichspüler niedrig" }, + "add_wash_available": { + "name": "AddWash erlaubt" + }, + "add_wash_indicator": { + "name": "AddWash bereit" + }, "pouring": { "name": "Ausgabe läuft" }, @@ -1345,6 +1351,18 @@ "intensive": { "name": "Intensiv" }, + "add_wash_alarm": { + "name": "AddWash-Hinweis" + }, + "add_wash_alarm_rinse": { + "name": "AddWash Spülen" + }, + "add_wash_alarm_final_rinse": { + "name": "AddWash letztes Spülen" + }, + "add_wash_alarm_spin": { + "name": "AddWash Schleudern" + }, "lamp": { "name": "Lampe" }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 8f55d63..7727b32 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -109,6 +109,12 @@ "softener_low": { "name": "Softener low" }, + "add_wash_available": { + "name": "AddWash allowed" + }, + "add_wash_indicator": { + "name": "AddWash ready" + }, "stick_ble_connected": { "name": "Stick BLE connected" } @@ -1351,6 +1357,18 @@ "intensive": { "name": "Intensive" }, + "add_wash_alarm": { + "name": "AddWash alarm" + }, + "add_wash_alarm_rinse": { + "name": "AddWash rinse" + }, + "add_wash_alarm_final_rinse": { + "name": "AddWash final rinse" + }, + "add_wash_alarm_spin": { + "name": "AddWash spin" + }, "lamp": { "name": "Lamp" }, diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 9192ae8..bd805f9 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -303,6 +303,12 @@ "softener_low": { "name": "Poco suavizante" }, + "add_wash_available": { + "name": "AddWash permitido" + }, + "add_wash_indicator": { + "name": "AddWash disponible" + }, "auto_clean_running": { "name": "Limpieza automática en curso" }, @@ -1548,6 +1554,18 @@ "intensive": { "name": "Intensivo" }, + "add_wash_alarm": { + "name": "Aviso AddWash" + }, + "add_wash_alarm_rinse": { + "name": "AddWash en aclarado" + }, + "add_wash_alarm_final_rinse": { + "name": "AddWash en último aclarado" + }, + "add_wash_alarm_spin": { + "name": "AddWash en centrifugado" + }, "lamp": { "name": "Lámpara" }, diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 8bc2e64..5f16b43 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -109,6 +109,12 @@ "softener_low": { "name": "Aggiungi ammorbidente" }, + "add_wash_available": { + "name": "AddWash consentito" + }, + "add_wash_indicator": { + "name": "AddWash disponibile" + }, "stick_ble_connected": { "name": "Scopa elettrica connessa via BLE" } @@ -1351,6 +1357,18 @@ "intensive": { "name": "Intensivo" }, + "add_wash_alarm": { + "name": "Avviso AddWash" + }, + "add_wash_alarm_rinse": { + "name": "AddWash al risciacquo" + }, + "add_wash_alarm_final_rinse": { + "name": "AddWash all'ultimo risciacquo" + }, + "add_wash_alarm_spin": { + "name": "AddWash alla centrifuga" + }, "lamp": { "name": "Lampada" }, diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 16eb962..84c938c 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -109,6 +109,12 @@ "softener_low": { "name": "섬유유연제 부족" }, + "add_wash_available": { + "name": "애드워시 허용" + }, + "add_wash_indicator": { + "name": "애드워시 가능" + }, "stick_ble_connected": { "name": "스틱 BLE 연결됨" } @@ -1351,6 +1357,18 @@ "intensive": { "name": "강력" }, + "add_wash_alarm": { + "name": "애드워시 알림" + }, + "add_wash_alarm_rinse": { + "name": "애드워시 헹굼" + }, + "add_wash_alarm_final_rinse": { + "name": "애드워시 마지막 헹굼" + }, + "add_wash_alarm_spin": { + "name": "애드워시 탈수" + }, "lamp": { "name": "램프" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b44e5dc..c60919b 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -109,6 +109,12 @@ "softener_low": { "name": "Wasverzachter bijna op" }, + "add_wash_available": { + "name": "AddWash toegestaan" + }, + "add_wash_indicator": { + "name": "AddWash beschikbaar" + }, "stick_ble_connected": { "name": "Steel via BLE verbonden" } @@ -1351,6 +1357,18 @@ "intensive": { "name": "Intensief" }, + "add_wash_alarm": { + "name": "AddWash-melding" + }, + "add_wash_alarm_rinse": { + "name": "AddWash bij spoelen" + }, + "add_wash_alarm_final_rinse": { + "name": "AddWash laatste spoeling" + }, + "add_wash_alarm_spin": { + "name": "AddWash centrifugeren" + }, "lamp": { "name": "Lamp" }, diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json index 4fc1806..c01ec02 100644 --- a/tests/fixtures/golden/washer_dryer_combo.json +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -1,5 +1,11 @@ { "state_keys": [ + "add_wash_alarm", + "add_wash_alarm_final_rinse", + "add_wash_alarm_rinse", + "add_wash_alarm_spin", + "add_wash_available", + "add_wash_indicator", "alarm_code", "bubble_soak", "child_lock", diff --git a/tests/fixtures/golden/washer_ww6500.json b/tests/fixtures/golden/washer_ww6500.json new file mode 100644 index 0000000..ab207e3 --- /dev/null +++ b/tests/fixtures/golden/washer_ww6500.json @@ -0,0 +1,28 @@ +{ + "state_keys": [ + "add_wash_alarm", + "add_wash_alarm_final_rinse", + "add_wash_alarm_rinse", + "add_wash_alarm_spin", + "add_wash_available", + "add_wash_indicator", + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "spin_speed", + "wash_temperature" + ] +} diff --git a/tests/fixtures/washer_ww6500_device.json b/tests/fixtures/washer_ww6500_device.json new file mode 100644 index 0000000..4fda0b6 --- /dev/null +++ b/tests/fixtures/washer_ww6500_device.json @@ -0,0 +1,233 @@ +{ + "device0": [ + {}, + { + "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.instantaneousPowerUnit": "W", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.cumulativePower": "2016700", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1787050800", + "x.com.samsung.da.cumulativeDateUTC": "1787050800" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "DeviceType_0167", + "Course_5C", + "LaundryOutTime_0", + "AddWashSet_0", + "AddWashAvailable_7", + "AddWashIndicator_Off", + "QuickWash_Not_Used", + "QuickWashSet_5B847E933FA53F", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_Empty", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "35B847E933FA53F5C841E923FA53F5D8102923FA43F66841E930FA30F5E831E920FA2075F867E943FA53F60831E930FA43F61841E943FA43F6385209204A204648000913FA53F6B80009000A53E65841E920FA30F67843E923FA43F688430923FA53F" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "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:07:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.supportedProgress": [ + "None", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "idle", + "machineStates": [ + "pause", + "active", + "idle" + ], + "jobStates": [ + "None", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "01:07:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WW6500", + "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|FFFFFFFF|20010102001011070000000000000000", + "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": "Unknown", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "30", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "20", + "30", + "40", + "60", + "95" + ], + "x.com.samsung.da.spinLevel": "1400", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "400", + "800", + "1200", + "1400" + ], + "x.com.samsung.da.rinseCycles": "3", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_00_Course_5C", + "x.com.samsung.da.st.courseTable": "Table_00" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": true + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "CZ" + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 3af2723..5a3d805 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -389,6 +389,22 @@ def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo(): ) +def test_registry_reproduces_golden_state_keys_for_washer_ww6500(): + """DA_WM_A51_20 front-loader, routed by /oic/d's oic.d.washer rather + than a board token. Reports the AddWash tokens and an empty + /wm/editcourse/vs/0, so its cycle list comes from supportedOptions.""" + from tests.conftest import _load_device + + resources = _load_device("washer_ww6500") + golden = json.loads((GOLDEN / "washer_ww6500.json").read_text()) + state_keys = _new_state_keys("washer_ww6500", 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_artik051_ref_17k(): """ARTIK051_REF_17K's Cool Select Zone pantry compartment (/status/pantry/one/vs/0) -- issue #20.""" diff --git a/tests/test_washer_add_wash.py b/tests/test_washer_add_wash.py new file mode 100644 index 0000000..305bb0c --- /dev/null +++ b/tests/test_washer_add_wash.py @@ -0,0 +1,223 @@ +"""Tests for the AddWash entities on /course/vs/0. + +Three independent tokens: AddWashSet (the alarm's 3-bit mask, the only +writable one), AddWashAvailable (what the loaded course permits) and +AddWashIndicator (the live panel lamp). See washer.py for where the bit +meanings and the write contract come from. +""" + +import pytest + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import resolve +from custom_components.localthings.registry.capabilities import washer +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc +from tests.conftest import _load_device + +COURSE = ["course", "vs", "0"] +BITS = { + "add_wash_alarm_rinse": 0, + "add_wash_alarm_final_rinse": 1, + "add_wash_alarm_spin": 2, +} +ENTITY_TOKENS = { + "add_wash_alarm": "AddWashSet", + "add_wash_alarm_rinse": "AddWashSet", + "add_wash_alarm_final_rinse": "AddWashSet", + "add_wash_alarm_spin": "AddWashSet", + "add_wash_available": "AddWashAvailable", + "add_wash_indicator": "AddWashIndicator", +} +# A value each token really carries, so presence gating is exercised against +# what a washer reports rather than a synthetic one. +SAMPLE = {"AddWashSet": "0", "AddWashAvailable": "7", "AddWashIndicator": "Off"} + + +def _desc(key, kind): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key and isinstance(e, kind)) + + +def _rep(*tokens): + return {"x.com.samsung.da.options": list(tokens)} + + +def _write(desc, payload, rep): + return desc.write_fn(payload, rep) + + +def _options(result): + """The tokens a write_fn result carries, asserting it targets /course/vs/0.""" + path, body = result + assert path == COURSE + return body["x.com.samsung.da.options"] + + +def _flatten(fixture): + resources = _load_device(fixture) + reg = resolve(resources) + return flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + + +class TestAlarmMasterSwitch: + def test_zero_reads_off_and_seven_reads_on(self): + desc = _desc("add_wash_alarm", SwitchDesc) + assert desc.rep_fn(_rep("AddWashSet_0")) is False + assert desc.rep_fn(_rep("AddWashSet_7")) is True + + @pytest.mark.parametrize("mask", range(8)) + def test_any_non_zero_mask_reads_on(self, mask): + desc = _desc("add_wash_alarm", SwitchDesc) + assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is (mask != 0) + + def test_turning_on_enables_every_moment(self): + desc = _desc("add_wash_alarm", SwitchDesc) + assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"] + + def test_turning_off_clears_the_mask(self): + desc = _desc("add_wash_alarm", SwitchDesc) + assert _options(_write(desc, "Off", _rep("AddWashSet_5"))) == ["AddWashSet_0"] + + def test_rejects_a_payload_that_is_not_on_or_off(self): + desc = _desc("add_wash_alarm", SwitchDesc) + assert _write(desc, "7", _rep("AddWashSet_0")) is None + + def test_rejects_a_write_against_an_empty_options_array(self): + desc = _desc("add_wash_alarm", SwitchDesc) + assert _write(desc, "On", {}) is None + + +class TestAlarmMomentSwitches: + @pytest.mark.parametrize("key,bit", BITS.items()) + @pytest.mark.parametrize("mask", range(8)) + def test_every_mask_decodes_to_the_right_bits(self, key, bit, mask): + desc = _desc(key, SwitchDesc) + assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is bool(mask >> bit & 1) + + def test_setting_one_moment_leaves_the_others_alone(self): + desc = _desc("add_wash_alarm_final_rinse", SwitchDesc) + # 5 is rinse + spin; adding the final rinse must reach 7, not 2. + assert _options(_write(desc, "On", _rep("AddWashSet_5"))) == ["AddWashSet_7"] + + def test_clearing_one_moment_leaves_the_others_alone(self): + desc = _desc("add_wash_alarm_rinse", SwitchDesc) + assert _options(_write(desc, "Off", _rep("AddWashSet_7"))) == ["AddWashSet_6"] + + def test_clearing_the_last_moment_yields_zero(self): + desc = _desc("add_wash_alarm_spin", SwitchDesc) + assert _options(_write(desc, "Off", _rep("AddWashSet_4"))) == ["AddWashSet_0"] + + def test_enabling_a_moment_from_zero_turns_the_alarm_on(self): + """The mask is the only state, so this is the intended outcome -- + there is no remembered combination to restore.""" + moment = _desc("add_wash_alarm_spin", SwitchDesc) + written = _options(_write(moment, "On", _rep("AddWashSet_0"))) + assert written == ["AddWashSet_4"] + assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep(*written)) is True + + @pytest.mark.parametrize("rep", [{}, _rep("AddWashSet_x"), _rep("Course_5C")]) + def test_refuses_to_write_when_the_mask_is_unreadable(self, rep): + desc = _desc("add_wash_alarm_rinse", SwitchDesc) + assert _write(desc, "On", rep) is None + + +class TestMaskParsing: + def test_a_missing_token_is_unavailable_not_zero(self): + assert washer._add_wash_mask(_rep("Course_5C"), "AddWashSet") is None + assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep("Course_5C")) is None + + def test_a_malformed_token_is_unavailable(self): + assert washer._add_wash_mask(_rep("AddWashSet_On"), "AddWashSet") is None + + def test_zero_is_a_real_value(self): + assert washer._add_wash_mask(_rep("AddWashSet_0"), "AddWashSet") == 0 + + @pytest.mark.parametrize("raw", ["8", "255", "-1"]) + def test_a_mask_outside_three_bits_is_unavailable(self, raw): + """This models exactly three moments, so a wider value means the + model is wrong -- refuse it rather than read-modify-write it back.""" + assert washer._add_wash_mask(_rep(f"AddWashSet_{raw}"), "AddWashSet") is None + assert _desc("add_wash_alarm_rinse", SwitchDesc).rep_fn(_rep(f"AddWashSet_{raw}")) is None + assert ( + _write(_desc("add_wash_alarm_spin", SwitchDesc), "On", _rep(f"AddWashSet_{raw}")) + is None + ) + + +class TestReadOnlySensors: + @pytest.mark.parametrize("raw,expected", [("0", False), ("6", True), ("7", True)]) + def test_available_is_true_for_any_permitted_moment(self, raw, expected): + desc = _desc("add_wash_available", BinarySensorDesc) + assert desc.rep_fn(_rep(f"AddWashAvailable_{raw}")) is expected + + @pytest.mark.parametrize("raw,expected", [("On", True), ("Off", False)]) + def test_indicator_maps_on_off(self, raw, expected): + desc = _desc("add_wash_indicator", BinarySensorDesc) + assert desc.rep_fn(_rep(f"AddWashIndicator_{raw}")) is expected + + def test_indicator_ships_enabled_and_uncategorised(self): + desc = _desc("add_wash_indicator", BinarySensorDesc) + assert desc.enabled_default is True + assert desc.entity_category is None + + def test_missing_tokens_are_unavailable(self): + assert _desc("add_wash_available", BinarySensorDesc).rep_fn(_rep()) is None + assert _desc("add_wash_indicator", BinarySensorDesc).rep_fn(_rep()) is None + + +class TestCapabilityDetection: + """Each entity self-gates on its own token, so a washer advertising only + a subset gets only that subset.""" + + @pytest.mark.parametrize("key,token", ENTITY_TOKENS.items()) + def test_absent_on_a_washer_that_never_reports_the_token(self, key, token): + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key) + assert desc.exists_fn(_rep("Course_5C"), {}) is False + + @pytest.mark.parametrize("key,token", ENTITY_TOKENS.items()) + def test_present_once_the_token_appears(self, key, token): + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key) + assert desc.exists_fn(_rep(f"{token}_{SAMPLE[token]}"), {}) is True + + def test_a_washer_with_only_the_indicator_gets_only_that_entity(self): + present = { + e.key + for e in washer.WASHER_COURSE.entities + if e.key in ENTITY_TOKENS and e.exists_fn(_rep("AddWashIndicator_On"), {}) + } + assert present == {"add_wash_indicator"} + + def test_pre_add_wash_washers_gain_nothing(self): + """washer_device is a DA_WM_TP1_21 dump with no AddWash tokens.""" + state = _flatten("washer") + assert not [key for key in state if key.startswith("add_wash")] + + +class TestAgainstTheWW6500Dump: + """DA_WM_A51_20_COMMON_WW6500, captured with the alarm off, the course + permitting all three moments and the lamp dark.""" + + def test_no_unbound_hrefs(self): + resources = _load_device("washer_ww6500") + reg = resolve(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + def test_reports_every_add_wash_entity(self): + state = _flatten("washer_ww6500") + assert {key for key in state if key.startswith("add_wash")} == { + "add_wash_alarm", + "add_wash_alarm_rinse", + "add_wash_alarm_final_rinse", + "add_wash_alarm_spin", + "add_wash_available", + "add_wash_indicator", + } + + def test_alarm_off_course_permits_lamp_dark(self): + state = _flatten("washer_ww6500") + assert state["add_wash_alarm"] is False + assert all(state[f"add_wash_alarm_{m}"] is False for m in ("rinse", "final_rinse", "spin")) + assert state["add_wash_available"] is True + assert state["add_wash_indicator"] is False From 51bce3c8cf5aa1b6f58809e3a8e18dbdf6310d89 Mon Sep 17 00:00:00 2001 From: Marek Tyburec Date: Wed, 19 Aug 2026 19:41:13 +0200 Subject: [PATCH 2/3] Address review: gate the AddWash master write on a readable mask The master alarm switch wrote AddWashSet_7/_0 without consulting _add_wash_mask, so a device reporting a wider mask (AddWashSet_15) or a non-numeric one had it truncated to three bits -- the write that mask's own docstring rules out, while the per-moment switches already refused it. Also corrects the washer_ww6500 golden docstring: the fixture carries no /oic/d and the test passes no device_types, so the device is typed solely by the WW consumer prefix in its description. A51 is not a board token, which makes that the fragile route worth naming. --- .../registry/capabilities/washer.py | 14 ++++++++++-- tests/test_golden_regression.py | 9 +++++--- tests/test_washer_add_wash.py | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 97d26a4..594adf1 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -310,7 +310,10 @@ def _add_wash_set_write(mask): def _add_wash_alarm_write(p, rep, href=None): - if p not in ("On", "Off") or not rep.get("x.com.samsung.da.options"): + # Gated on the mask being readable, like the per-moment writes: a device + # reporting a wider mask than these three bits would otherwise have it + # truncated to 7 here, silently dropping a moment it supports. + if p not in ("On", "Off") or _add_wash_mask(rep, "AddWashSet") is None: return None return _add_wash_set_write(0b111 if p == "On" else 0) @@ -320,7 +323,10 @@ def _add_wash_bit_switch(key, icon, bit): The mask is the only state, so switching the last moment off lands on 0 and takes the alarm with it, and switching one on from 0 turns the alarm - back on. + back on. The corollary is that switching the master off and on again + writes 7, resetting a rinse-only selection to all three moments -- the + appliance remembers no previous subset either, so there is nothing to + restore. """ def read(rep): @@ -445,6 +451,10 @@ WASHER_COURSE = Capability( _add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0), _add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1), _add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2), + # On at rest: an idle washer reports AddWashAvailable_7 and the mask + # only empties as the cycle consumes each moment. This says the cycle + # permits AddWash, not that laundry can go in now -- that is + # add_wash_indicator. BinarySensorDesc( key="add_wash_available", icon="mdi:tshirt-crew-outline", diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 5a3d805..63e8b4b 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -390,9 +390,12 @@ def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo(): def test_registry_reproduces_golden_state_keys_for_washer_ww6500(): - """DA_WM_A51_20 front-loader, routed by /oic/d's oic.d.washer rather - than a board token. Reports the AddWash tokens and an empty - /wm/editcourse/vs/0, so its cycle list comes from supportedOptions.""" + """DA_WM_A51_20 front-loader, typed solely by the WW consumer prefix in + its /information/vs/0 description: A51 is not a board token, so with the + description blanked this device resolves to nothing and drops to the + unknown-device fallback. That is the fragile route this test pins. + Reports the AddWash tokens and an empty /wm/editcourse/vs/0, so its cycle + list comes from supportedOptions.""" from tests.conftest import _load_device resources = _load_device("washer_ww6500") diff --git a/tests/test_washer_add_wash.py b/tests/test_washer_add_wash.py index 305bb0c..24138b0 100644 --- a/tests/test_washer_add_wash.py +++ b/tests/test_washer_add_wash.py @@ -86,6 +86,17 @@ class TestAlarmMasterSwitch: desc = _desc("add_wash_alarm", SwitchDesc) assert _write(desc, "On", {}) is None + @pytest.mark.parametrize("raw", ["15", "On", "-1"]) + def test_refuses_to_write_over_a_mask_it_cannot_read(self, raw): + """A device reporting a wider mask must not have it truncated to 7. + The master is gated exactly like the per-moment writes, so an + unrecognized mask leaves every AddWash switch read-only.""" + desc = _desc("add_wash_alarm", SwitchDesc) + rep = _rep(f"AddWashSet_{raw}") + assert desc.rep_fn(rep) is None + assert _write(desc, "On", rep) is None + assert _write(desc, "Off", rep) is None + class TestAlarmMomentSwitches: @pytest.mark.parametrize("key,bit", BITS.items()) @@ -197,6 +208,17 @@ class TestAgainstTheWW6500Dump: """DA_WM_A51_20_COMMON_WW6500, captured with the alarm off, the course permitting all three moments and the lamp dark.""" + def test_types_only_by_the_description_consumer_prefix(self): + """A51 is not a board token, so the WW prefix in the description is + the only thing routing this device -- see the golden test's docstring. + Pinned here so the claim can't quietly stop being true.""" + from custom_components.localthings.registry.by_type import for_device_by_model + + info = _load_device("washer_ww6500")["/information/vs/0"] + model = info["x.com.samsung.da.modelNum"] + assert for_device_by_model(model, info["x.com.samsung.da.description"]).name == "washer" + assert for_device_by_model(model, "") is None + def test_no_unbound_hrefs(self): resources = _load_device("washer_ww6500") reg = resolve(resources) From 6c980f927be245892a84ff0059efc1c8fd112102 Mon Sep 17 00:00:00 2001 From: Marek Tyburec Date: Thu, 20 Aug 2026 06:56:47 +0200 Subject: [PATCH 3/3] Address review: make the AddWash master switch's On idempotent Home Assistant calls turn_on regardless of current state, and applying a scene re-asserts every captured state, so asserting the alarm on over a rinse-only AddWashSet_1 rewrote it to _7 -- silently widening the moments the user picked, with no state change on this switch to point at it. The write is now refused when the mask is already non-zero. Distinct from the off-then-on path documented in _add_wash_bit_switch, where the appliance has no subset left to keep. Also satisfies the ty check on the new tests: resolve(), for_device_by_model() and exists_fn are all optional, asserted the way the other capability tests do. --- .../registry/capabilities/washer.py | 11 +++++- tests/test_washer_add_wash.py | 34 +++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 594adf1..df9ce24 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -313,7 +313,16 @@ def _add_wash_alarm_write(p, rep, href=None): # Gated on the mask being readable, like the per-moment writes: a device # reporting a wider mask than these three bits would otherwise have it # truncated to 7 here, silently dropping a moment it supports. - if p not in ("On", "Off") or _add_wash_mask(rep, "AddWashSet") is None: + mask = _add_wash_mask(rep, "AddWashSet") + if p not in ("On", "Off") or mask is None: + return None + if p == "On" and mask: + # Already on, so "on" is a no-op rather than a rewrite to 7. Home + # Assistant calls turn_on regardless of current state, so an + # automation asserting the alarm on over a rinse-only mask would + # otherwise widen it to all three moments with no state change on + # this switch to point at. Distinct from the off-then-on case in + # _add_wash_bit_switch, where there is no subset left to keep. return None return _add_wash_set_write(0b111 if p == "On" else 0) diff --git a/tests/test_washer_add_wash.py b/tests/test_washer_add_wash.py index 24138b0..7617398 100644 --- a/tests/test_washer_add_wash.py +++ b/tests/test_washer_add_wash.py @@ -53,9 +53,17 @@ def _options(result): return body["x.com.samsung.da.options"] +def _exists(key, rep): + """Whether `key`'s descriptor gates itself in for `rep`.""" + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key) + assert desc.exists_fn is not None + return desc.exists_fn(rep, {}) + + def _flatten(fixture): resources = _load_device(fixture) reg = resolve(resources) + assert reg is not None return flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) @@ -78,6 +86,20 @@ class TestAlarmMasterSwitch: desc = _desc("add_wash_alarm", SwitchDesc) assert _options(_write(desc, "Off", _rep("AddWashSet_5"))) == ["AddWashSet_0"] + @pytest.mark.parametrize("mask", range(1, 8)) + def test_on_over_an_alarm_already_on_keeps_the_chosen_moments(self, mask): + """Home Assistant calls turn_on regardless of current state, so + re-asserting "on" over a rinse-only mask must not widen it to all + three -- this switch reads on either way, so no state change would + point at the loss. Reaching 7 from a subset still means off, then + on.""" + desc = _desc("add_wash_alarm", SwitchDesc) + rep = _rep(f"AddWashSet_{mask}") + assert desc.rep_fn(rep) is True + assert _write(desc, "On", rep) is None + assert _options(_write(desc, "Off", rep)) == ["AddWashSet_0"] + assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"] + def test_rejects_a_payload_that_is_not_on_or_off(self): desc = _desc("add_wash_alarm", SwitchDesc) assert _write(desc, "7", _rep("AddWashSet_0")) is None @@ -182,19 +204,17 @@ class TestCapabilityDetection: @pytest.mark.parametrize("key,token", ENTITY_TOKENS.items()) def test_absent_on_a_washer_that_never_reports_the_token(self, key, token): - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key) - assert desc.exists_fn(_rep("Course_5C"), {}) is False + assert _exists(key, _rep("Course_5C")) is False @pytest.mark.parametrize("key,token", ENTITY_TOKENS.items()) def test_present_once_the_token_appears(self, key, token): - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key) - assert desc.exists_fn(_rep(f"{token}_{SAMPLE[token]}"), {}) is True + assert _exists(key, _rep(f"{token}_{SAMPLE[token]}")) is True def test_a_washer_with_only_the_indicator_gets_only_that_entity(self): present = { e.key for e in washer.WASHER_COURSE.entities - if e.key in ENTITY_TOKENS and e.exists_fn(_rep("AddWashIndicator_On"), {}) + if e.key in ENTITY_TOKENS and _exists(e.key, _rep("AddWashIndicator_On")) } assert present == {"add_wash_indicator"} @@ -216,12 +236,14 @@ class TestAgainstTheWW6500Dump: info = _load_device("washer_ww6500")["/information/vs/0"] model = info["x.com.samsung.da.modelNum"] - assert for_device_by_model(model, info["x.com.samsung.da.description"]).name == "washer" + reg = for_device_by_model(model, info["x.com.samsung.da.description"]) + assert reg is not None and reg.name == "washer" assert for_device_by_model(model, "") is None def test_no_unbound_hrefs(self): resources = _load_device("washer_ww6500") reg = resolve(resources) + assert reg is not None unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == []