diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py new file mode 100644 index 0000000..587eb51 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -0,0 +1,110 @@ +"""Capabilities specific to washer appliances (Samsung DA_WM_TP1-class +front-load washers). + +Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course +family). Washers never report `oneUiVersion` -- see +`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback +detection this device type requires. +""" +from ..capability import Capability +from ..entities import SelectDesc, SensorDesc + +# --------------------------------------------------------------------------- +# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count +# +# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also +# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF +# path for different device families. Only one of the two ever binds for a +# given device, since dryer and washer are separate by_type registries. +# --------------------------------------------------------------------------- + +WASHER_SETTINGS = Capability( + href='/washer/vs/0', + entities=( + SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature', + name='Wash temperature', icon='mdi:thermometer-water', + entity_category='config', + options_field='x.com.samsung.da.supportedWaterTemperature', + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})), + SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel', + name='Spin speed', icon='mdi:sync', + entity_category='config', + options_field='x.com.samsung.da.supportedSpinLevel', + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})), + SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles', + name='Rinse cycles', icon='mdi:water-sync', + entity_category='config', + options_field='x.com.samsung.da.supportedRinseCycles', + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})), + ), +) + +# --------------------------------------------------------------------------- +# /course/vs/0 -- selected course, read-only. +# +# Unlike dishwasher.CYCLE_OPTIONS, we have no verified Course_XX -> name +# table for washer hardware (only two codes observed: 1C, 65) and no +# supported-course list field to validate a write against. Exposing this +# as a plain sensor of the raw hex code is honest about what we actually +# know; add write support once a real course-name table exists. +# --------------------------------------------------------------------------- + +def _option_value(options, prefix): + for o in (options or []): + if isinstance(o, str) and o.startswith(prefix + '_'): + return o.split('_', 1)[1] + return None + + +WASHER_COURSE = Capability( + href='/course/vs/0', + entities=( + SensorDesc(key='cycle', name='Cycle', icon='mdi:washing-machine', + rep_fn=lambda rep: _option_value( + rep.get('x.com.samsung.da.options'), 'Course')), + ), +) + +# --------------------------------------------------------------------------- +# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish +# chime. Fields have no 'x.com.samsung.da.' prefix in this resource, unlike +# most other washer hrefs. +# --------------------------------------------------------------------------- + +BUZZER_SOUND = Capability( + href='/buzzersound/vs/0', + entities=( + SelectDesc(key='buzzer_sound', field='setBuzzerSound', + name='Buzzer sound', icon='mdi:volume-high', + entity_category='config', + options_field='supportedBuzzerSound', + write_fn=lambda p, rep, href=None: ( + ['buzzersound', 'vs', '0'], {'setBuzzerSound': p})), + SelectDesc(key='finish_sound', field='setFinishSound', + name='Finish sound', icon='mdi:bell-ring', + entity_category='config', + exists_fn=lambda rep: 'supportedFinishSound' in rep, + options_field='supportedFinishSound', + write_fn=lambda p, rep, href=None: ( + ['buzzersound', 'vs', '0'], {'setFinishSound': p})), + ), +) + +# --------------------------------------------------------------------------- +# /wm/jobbeginingstatus/vs/0 -- same href as dryer.JOB_BEGINNING_STATUS, but +# a different field name (currentStatus, not jobBeginingStatus). +# --------------------------------------------------------------------------- + +WASHER_JOB_BEGINNING_STATUS = Capability( + href='/wm/jobbeginingstatus/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='job_beginning_status', + field='x.com.samsung.da.currentStatus', + name='Job beginning status', + entity_category='diagnostic'), + ), +) diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py new file mode 100644 index 0000000..7ab32c4 --- /dev/null +++ b/tests/test_washer_capabilities.py @@ -0,0 +1,83 @@ +"""Tests for washer-specific capabilities.""" +from custom_components.localthings.registry.capabilities import washer + + +def _rep_by_href(cap, href): + assert cap.href == href + return cap + + +class TestWasherSettings: + def test_href(self): + assert washer.WASHER_SETTINGS.href == '/washer/vs/0' + + def test_wash_temperature_read(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature') + assert desc.field == 'x.com.samsung.da.waterTemperature' + assert desc.options_field == 'x.com.samsung.da.supportedWaterTemperature' + + def test_wash_temperature_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature') + path, body = desc.write_fn('60', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.waterTemperature': '60'} + + def test_spin_speed_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'spin_speed') + path, body = desc.write_fn('1400', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.spinLevel': '1400'} + + def test_rinse_cycles_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'rinse_cycles') + path, body = desc.write_fn('3', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.rinseCycles': '3'} + + +class TestWasherCourse: + def test_href(self): + assert washer.WASHER_COURSE.href == '/course/vs/0' + + def test_reads_course_code_from_options_array(self): + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']} + assert desc.rep_fn(rep) == '1C' + + def test_missing_course_option_returns_none(self): + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None + + def test_no_write_fn_yet(self): + """Read-only until a verified course-name table exists.""" + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + assert getattr(desc, 'write_fn', None) is None + + +class TestBuzzerSound: + def test_href(self): + assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0' + + def test_buzzer_sound_write(self): + desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'buzzer_sound') + path, body = desc.write_fn('Volume_High', {}) + assert path == ['buzzersound', 'vs', '0'] + assert body == {'setBuzzerSound': 'Volume_High'} + + def test_finish_sound_exists_only_when_supported(self): + desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound') + assert desc.exists_fn({'setBuzzerSound': 'On'}) is False + assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}) is True + + def test_finish_sound_write(self): + desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound') + path, body = desc.write_fn('FinishSound_2', {}) + assert path == ['buzzersound', 'vs', '0'] + assert body == {'setFinishSound': 'FinishSound_2'} + + +class TestJobBeginningStatus: + def test_href_and_field(self): + assert washer.WASHER_JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0' + desc = washer.WASHER_JOB_BEGINNING_STATUS.entities[0] + assert desc.field == 'x.com.samsung.da.currentStatus'