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:
@@ -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')),
|
||||
),
|
||||
)
|
||||
|
||||
Vendored
+3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user