feat: add bubble soak, pre-wash, and intensive switches for washers (#22)

A follow-up dump confirmed these ride as plain BubbleSoak_On/Off,
PreWashSetting_On/Off, and IntensiveSetting_On/Off tokens in the same
/course/vs/0 options array as the cycle select, so they're exposed as
self-gating config switches the same way other options-array fields are.

Per-cycle availability (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet)
lines up positionally with editCourseList but isn't used for gating, since
exists_fn only runs once at setup against whatever course happened to be
active then.
This commit is contained in:
Marc Billow
2026-07-22 19:45:57 +00:00
parent dbe70ffc5b
commit e49b61e01e
4 changed files with 114 additions and 1 deletions
@@ -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 '<Prefix>_On'/'<Prefix>_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 '<Prefix>Set'/'<Prefix>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')),
),
)
+3
View File
@@ -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",
+3
View File
@@ -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",
+53
View File
@@ -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