diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index e997afb..28e3f99 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -16,7 +16,7 @@ array. from datetime import datetime, timezone from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc from .laundry import cycle_select, hex_pairs, option_value, replace_in_options # --------------------------------------------------------------------------- @@ -238,6 +238,45 @@ def _dosing_alarm_exists(prefix): rep.get('x.com.samsung.da.options'), prefix) is not None +# Bubble soak / pre-wash / intensive-wash toggles, from the same options[] +# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a +# plain '_On'/'_Off' token, confirmed by a dump taken with +# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape +# already used by AiOption and KidsLockBypass in this same array, so +# PreWashSetting/IntensiveSetting are assumed to follow suit. +# +# Each also has a same-shaped 'Set'/'AvailableSet' hex-pair +# string that lines up positionally with editCourseList -- e.g. on the combo +# dump, BubbleSoakSet's byte for the active course (Course_1C, position 0) +# is '00' while PreWashAvailableSet/IntensiveAvailableSet are both 'F0', +# suggesting F0/00 is an available/unavailable flag per course. That's not +# exposed here: exists_fn only runs once, against the setup-time snapshot +# (see entity._is_included), so gating on the *current* course would just +# make the entity's existence depend on whatever course happened to be +# selected when Home Assistant started, not on the device's real, static +# capability. Toggling one of these on a course the app would gray it out +# for is untested; treat it like any other write this integration doesn't +# validate against device-side state. +def _bool_option_write(prefix): + def write(p, rep, href=None): + opts = list(rep.get('x.com.samsung.da.options') or []) + if not opts: + return None + return ['course', 'vs', '0'], { + 'x.com.samsung.da.options': replace_in_options(opts, prefix, 'On' if p else 'Off'), + } + return write + + +def _bool_option_value(prefix): + return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On' + + +def _bool_option_exists(prefix): + return lambda rep, resources: option_value( + rep.get('x.com.samsung.da.options'), prefix) is not None + + WASHER_COURSE = Capability( href='/course/vs/0', entities=( @@ -294,5 +333,20 @@ WASHER_COURSE = Capability( icon='mdi:alert-circle-outline', device_class='problem', exists_fn=_dosing_alarm_exists('SoftenerAlarm'), rep_fn=_dosing_low('SoftenerAlarm')), + SwitchDesc(key='bubble_soak', name='Bubble soak', icon='mdi:chart-bubble', + entity_category='config', + exists_fn=_bool_option_exists('BubbleSoak'), + rep_fn=_bool_option_value('BubbleSoak'), + write_fn=_bool_option_write('BubbleSoak')), + SwitchDesc(key='pre_wash', name='Pre wash', icon='mdi:washing-machine', + entity_category='config', + exists_fn=_bool_option_exists('PreWashSetting'), + rep_fn=_bool_option_value('PreWashSetting'), + write_fn=_bool_option_write('PreWashSetting')), + SwitchDesc(key='intensive', name='Intensive', icon='mdi:washing-machine', + entity_category='config', + exists_fn=_bool_option_exists('IntensiveSetting'), + rep_fn=_bool_option_value('IntensiveSetting'), + write_fn=_bool_option_write('IntensiveSetting')), ), ) diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index 69ac2a8..d3a99a1 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -1,6 +1,7 @@ { "state_keys": [ "alarm_code", + "bubble_soak", "buzzer_sound", "child_lock", "cycle", @@ -14,9 +15,11 @@ "energy_saved_kwh", "finish_time", "firmware_update", + "intensive", "job_beginning_status", "machine_state", "power_switch", + "pre_wash", "progress", "progress_percentage", "remote_control", diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json index a228067..d2bab38 100644 --- a/tests/fixtures/golden/washer_dryer_combo.json +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -1,6 +1,7 @@ { "state_keys": [ "alarm_code", + "bubble_soak", "child_lock", "cycle", "cycle_active", @@ -12,9 +13,11 @@ "energy_kwh", "finish_time", "firmware_update", + "intensive", "job_beginning_status", "machine_state", "power_switch", + "pre_wash", "progress", "progress_percentage", "remote_control", diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index ac143d8..88c875d 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -251,6 +251,59 @@ class TestDetergentSoftenerDosing: assert self._desc('detergent_low').exists_fn(rep, {}) is True +class TestWashOptionToggles: + """Bubble soak / pre-wash / intensive-wash switches, from the same + options[] array as the cycle select (issue #22 follow-up). Confirmed + On/Off shape from a dump with Bubble Soak toggled on in the app.""" + + @staticmethod + def _desc(key): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key) + + @staticmethod + def _keys(): + return ('bubble_soak', 'pre_wash', 'intensive') + + @staticmethod + def _prefix(key): + return {'bubble_soak': 'BubbleSoak', + 'pre_wash': 'PreWashSetting', + 'intensive': 'IntensiveSetting'}[key] + + def test_exists_only_when_field_present(self): + for key in self._keys(): + desc = self._desc(key) + assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert desc.exists_fn(rep, {}) is True + + def test_reads_off(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert self._desc(key).rep_fn(rep) is False + + def test_reads_on(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + assert self._desc(key).rep_fn(rep) is True + + def test_write_on_and_off(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']} + path, body = self._desc(key).write_fn(True, rep) + assert path == ['course', 'vs', '0'] + assert f'{prefix}_On' in body['x.com.samsung.da.options'] + assert 'GMT_02' in body['x.com.samsung.da.options'] + + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + path, body = self._desc(key).write_fn(False, rep) + assert f'{prefix}_Off' in body['x.com.samsung.da.options'] + + class TestFlexWashAndComboFixturesHaveCompleteCoverage: """FlexWash (issue #19, previously unrecognized entirely) and washer/dryer combo (issue #22, dry_level) dumps must both resolve to