Files
localthings/tests/test_washer_capabilities.py
T
Marc Billow d6639c99d4 Bind AI energy level on washer; switch/select split, drop translations
Issue #40: /energy/ailevel/vs/0 was unbound on a plain washer. The
capability already existed for fridges but was gated off entirely on
single-level hardware (the common case), so it's moved to common.py
(cross-family, like fridge + washer now) and split into two entities:
a switch when supportedAiLevel has exactly one entry (aiLevel is really
just an on/off toggle there), and a select otherwise, with '0' (off)
synthesized back into the select's options since supportedAiLevel never
lists it but it's a real observed value.

Also drops the translation_key/strings.json entries -- aiLevel's raw
digit values already render fine untranslated, and translating a
handful of levels can't cover devices with more.
2026-07-23 00:44:57 +00:00

450 lines
21 KiB
Python

"""Tests for washer-specific capabilities.
Shared laundry capabilities (cycle-select machinery, buzzer, job status) are
tested in test_laundry_capabilities.py; the OCF power/kids-lock/remote-control
fallback pairs and the energy meter in test_common_capabilities.py.
"""
from custom_components.localthings.registry.capabilities import laundry, 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 TestDryLevel:
"""Washer/dryer combo units carry a writable dryLevel field on
/washer/vs/0 itself, self-gated off on plain washers -- issue #22."""
def test_exists_only_when_supported_dry_level_present(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True
assert desc.exists_fn({}, {}) is False
def test_write(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
path, body = desc.write_fn('Cupboard', {})
assert path == ['washer', 'vs', '0']
assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'}
class TestWasherCourse:
def test_href(self):
assert washer.WASHER_COURSE.href == '/course/vs/0'
def test_translation_key(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.translation_key == 'washer_cycle'
def test_reads_raw_course_code_from_options_array(self):
"""rep_fn returns the raw device code; display names come from
strings.json via translation_key, not from Python (see select.py's
_display())."""
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_cycle_desc_uses_cycle_options_callable(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.options is laundry.cycle_options
def test_exists_only_when_edit_course_list_is_live(self):
"""No hardcoded course table is kept -- the selector only appears
when a device actually populates editCourseList (see
_cycle_options's docstring for why MostUsed_ isn't used either)."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.exists_fn({}, {}) is False
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_1C'}}
assert desc.exists_fn({}, live) is True
def test_cycle_write(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']}
path, body = desc.write_fn('1D', rep)
assert path == ['course', 'vs', '0']
assert body == {
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
}
class TestDrumClean:
def test_cycles_remaining(self):
"""DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live
app screenshot's 'Potreba cistenia po 37 cykloch'."""
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
assert desc.rep_fn(rep) == 37
def test_cycles_remaining_never_negative(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
rep = {'x.com.samsung.da.options': ['WashingTimes_50', 'DrumCleanProposal_40']}
assert desc.rep_fn(rep) == 0
def test_cycles_remaining_missing_fields(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
def test_cycles_remaining_exists_only_when_computable(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
assert desc.exists_fn(rep, {}) is True
def test_last_cleaned(self):
"""DrumCleanLog_2026-07-01T20:18:07 -> a UTC-aware datetime,
matching the same screenshot's '10 days ago' (as of 2026-07-11)."""
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_last_cleaned')
rep = {'x.com.samsung.da.options': ['DrumCleanLog_2026-07-01T20:18:07']}
from datetime import datetime, timezone
assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=timezone.utc)
def test_last_cleaned_missing(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_last_cleaned')
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
# Raw options array from issue #9's diagnostic dump (trimmed to the fields
# relevant to detergent/softener dosing).
_DOSING_OPTIONS = [
'Course_1C',
'DetergentAlarm_Off',
'SoftenerAlarm_Off',
'DetergentLevelCtrl_3',
'SoftenerLevelCtrl_3',
'SupportedDetergentLevelCtrl_00010203',
'SupportedSoftenerLevelCtrl_00010203',
'DetergentLevel2Ctrl_2',
'SoftenerLevel2Ctrl_2',
'SupportedDetergentLevel2Ctrl_010203',
'SupportedSoftenerLevel2Ctrl_010203',
]
_DOSING_RESOURCES = {'/course/vs/0': {'x.com.samsung.da.options': _DOSING_OPTIONS}}
class TestDetergentSoftenerDosing:
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_quantity_and_hardness_read(self):
"""The device reports the level un-padded ('3'), but the options and
translation keys are zero-padded supported codes ('03'); rep_fn
normalizes to the supported code so the value is a valid option
(issue #9 -- otherwise HA renders the select 'unknown')."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_quantity').rep_fn(rep) == '03'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '02'
assert self._desc('softener_quantity').rep_fn(rep) == '03'
assert self._desc('softener_concentration').rep_fn(rep) == '02'
def test_current_value_is_a_valid_option_for_every_dosing_select(self):
"""The core regression: HA shows a select 'unknown' when current_option
is not in options. Each dosing select's value must be one of its own
options."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key
def test_read_passes_through_when_no_supported_match(self):
"""A value with no matching supported code is returned as-is rather than
dropped, so an unexpected device stays visible instead of blank."""
rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7',
'SupportedDetergentLevelCtrl_00010203']}
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers,
same shape as fridge.py's shared 'brightness_level' key); hardness
and concentration each have their own since their labels differ."""
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity'
assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity'
assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness'
assert self._desc('softener_concentration').translation_key == 'washer_softener_concentration'
def test_quantity_and_hardness_options_decode_supported_list(self):
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
assert self._desc('softener_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
assert self._desc('detergent_water_hardness').options(_DOSING_RESOURCES) == ['01', '02', '03']
assert self._desc('softener_concentration').options(_DOSING_RESOURCES) == ['01', '02', '03']
def test_exists_only_when_supported_list_present(self):
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.exists_fn({}, {}) is False
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
def test_quantity_write(self):
"""The UI selects a padded supported code ('01'); the write posts the
un-padded device code ('1'), mirroring how the device reports it."""
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('detergent_quantity').write_fn('01', rep)
assert path == ['course', 'vs', '0']
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
# untouched siblings survive the read-modify-write
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
def test_hardness_write(self):
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('softener_concentration').write_fn('03', rep)
assert path == ['course', 'vs', '0']
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
def test_low_reservoir_off_when_alarm_off(self):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_low').rep_fn(rep) is False
assert self._desc('softener_low').rep_fn(rep) is False
def test_low_reservoir_on_when_alarm_active(self):
opts = ['DetergentAlarm_On', 'SoftenerAlarm_On']
rep = {'x.com.samsung.da.options': opts}
assert self._desc('detergent_low').rep_fn(rep) is True
assert self._desc('softener_low').rep_fn(rep) is True
def test_low_reservoir_exists_only_when_alarm_field_present(self):
assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
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):
"""write_fn receives the same 'On'/'Off' string switch.py sends
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
implementation always wrote 'On', since any non-empty string
(including 'Off') is truthy."""
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('On', 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('Off', rep)
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
def test_write_rejects_non_on_off_payload(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert self._desc(key).write_fn('bogus', rep) is None
# editCourseList and availability bitmaps from the reporter's issue #22
# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just
# turned on in the app): 24 courses, course '30' at position 1 reads 'F0'
# (available) on all three bitmaps; course '1C' at position 0 reads '00' on
# BubbleSoakSet (matching the app graying that control out for Eco 40-60).
_EDIT_COURSE_RESOURCES = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList':
'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729',
},
}
_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000'
_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
class TestWashOptionToggleValidation:
"""validate_fn rejects turning a toggle on for a course whose byte in
its availability bitmap isn't 'F0', with a user-facing message switch.py
raises as ServiceValidationError -- distinct from write_fn's silent
no-op for a malformed payload."""
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_allowed_on_a_supported_course(self):
rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_rejected_on_an_unsupported_course(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
assert msg == "Bubble soak isn't available on the selected cycle."
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}
assert self._desc('pre_wash').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]}
assert self._desc('intensive').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_turning_off_is_never_blocked(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'Off', rep, _EDIT_COURSE_RESOURCES) is None
def test_allows_write_when_course_unresolvable(self):
"""No editCourseList, no Course_ token, or a bitmap whose length
doesn't match editCourseList -- in every case, fail open rather than
block a write we can't actually verify."""
desc = self._desc('bubble_soak')
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, {}) is None
rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
class TestAiEnergyLevel:
"""Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer.
The capability itself (common.AI_ENERGY_LEVEL) is tested in
test_common_capabilities.py; this just confirms it's wired into the
washer registry and that the fixture's single-entry supportedAiLevel
(['1'], matching the issue's dump) surfaces as a switch, not a select."""
def test_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] is False # aiLevel '0' -- off
class TestFlexWashAndComboFixturesHaveCompleteCoverage:
"""FlexWash (issue #19, previously unrecognized entirely) and
washer/dryer combo (issue #22, dry_level) dumps must both resolve to
zero unbound hrefs."""
def test_flexwash(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer_flexwash')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert 'dry_level' not in state # plain washer -- no supportedDryLevel field
def test_dryer_combo(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer_dryer_combo')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
# the device's own "off" sentinel is the literal string 'None', not absence
assert state['dry_level'] == 'None'