fix(washer): stale idle progress%, add detergent/softener dosing entities (#9)

progress_percentage lacked the active-state gate already applied to
progress/cycle_active/finish_time, so it kept showing a stale device value
(e.g. 1%) while idle -- now zeroed the same way. Shared by dryer/dishwasher/
oven via operational.py's OPERATIONAL_STATE.

Also exposes detergent/softener auto-dispense quantity, water hardness/
concentration, and low-reservoir alarms from /course/vs/0's options array,
using the same decode/RMW helpers already used for course selection and
drum-clean tracking. Gated by exists_fn so washer models without these
fields (e.g. the existing test fixture) are unaffected.

Water consumption is unaffected -- common.WATER_METER is already wired
into the washer registry; this reporter's device just doesn't expose
/water/consumption/vs/0.
This commit is contained in:
Marc Billow
2026-07-18 18:15:41 +00:00
parent aee19fd953
commit 2a6bbc9dfb
5 changed files with 189 additions and 4 deletions
@@ -93,9 +93,11 @@ OPERATIONAL_STATE = Capability(
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
value_fn=_int),
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
@@ -47,12 +47,16 @@ from .common import clamp_power, wh_to_kwh
# ---------------------------------------------------------------------------
def _hex_pairs(codes):
"""'1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
def _parse_edit_course_list(raw):
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
if not isinstance(raw, str) or '_' not in raw:
return []
codes = raw.split('_', 1)[1]
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
return _hex_pairs(raw.split('_', 1)[1])
def _cycle_options(resources):
@@ -150,6 +154,52 @@ def _drum_clean_last_cleaned(rep):
return None
# Detergent/softener auto-dispense dosing, from the same options[] array
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
# detergent, concentration for softener -- matching the SmartThings app's
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
# valid raw codes for its field, same hex-pair shape as EditCourseList.
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
#
# Raw codes are shown as-is (no translation_key): unlike Course_XX, there's
# no code->label mapping cross-referenced against multiple devices yet --
# just a single dump's DetergentLevelCtrl_3/SoftenerLevelCtrl_3 plausibly
# matching that same report's screenshot of "Élevé" (High), which isn't
# enough to commit a full 00-03 mapping to strings.json.
def _supported_level_options(resources, prefix):
rep = resources.get('/course/vs/0') or {}
raw = _option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
return _hex_pairs(raw) if raw else []
def _level_options(prefix):
return lambda resources: _supported_level_options(resources, prefix)
def _level_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, p),
}
return write
def _dosing_low(prefix):
return lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
def _dosing_alarm_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=(
@@ -170,6 +220,48 @@ WASHER_COURSE = Capability(
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
entity_category='config',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
icon='mdi:water-opacity',
entity_category='config',
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration',
icon='mdi:flask-plus-outline',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', name='Detergent low',
icon='mdi:alert-circle-outline', device_class='problem',
exists_fn=_dosing_alarm_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', name='Softener low',
icon='mdi:alert-circle-outline', device_class='problem',
exists_fn=_dosing_alarm_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
),
)
+2
View File
@@ -6,6 +6,7 @@
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
@@ -19,6 +20,7 @@
"progress_percentage",
"remote_control",
"rinse_cycles",
"softener_low",
"spin_speed",
"wash_temperature",
"water_liters"
+16
View File
@@ -8,6 +8,22 @@ def test_machine_state_maps_samsung_to_ocf():
assert ms.value_fn('Ready') == 'idle'
class TestProgressPercentage:
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
after a cycle ends instead of resetting it, so it must be gated on
active state the same way `progress`/`cycle_active`/`finish_time` are."""
def test_zeroed_when_not_active(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage')
rep = {'x.com.samsung.da.state': 'Ready', 'x.com.samsung.da.progressPercentage': '1'}
assert desc.rep_fn(rep) == 0
def test_passes_through_when_active(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage')
rep = {'x.com.samsung.da.state': 'Run', 'x.com.samsung.da.progressPercentage': '42'}
assert desc.rep_fn(rep) == 42
class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
+73
View File
@@ -155,6 +155,79 @@ class TestDrumClean:
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):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_quantity').rep_fn(rep) == '3'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '2'
assert self._desc('softener_quantity').rep_fn(rep) == '3'
assert self._desc('softener_concentration').rep_fn(rep) == '2'
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):
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_01' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not 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_03' 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 TestBuzzerSound:
def test_href(self):
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'