feat(washer): add washer-specific capabilities

This commit is contained in:
Marc Billow
2026-07-11 12:31:20 -05:00
parent 82f1588da4
commit 2868938d37
2 changed files with 193 additions and 0 deletions
@@ -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'),
),
)
+83
View File
@@ -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'