From 2a6bbc9dfbe12a4b0efc22446b3ad7a72a449be7 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 18 Jul 2026 18:15:41 +0000 Subject: [PATCH] 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. --- .../registry/capabilities/operational.py | 6 +- .../registry/capabilities/washer.py | 96 ++++++++++++++++++- tests/fixtures/golden/washer.json | 2 + tests/test_operational_capability.py | 16 ++++ tests/test_washer_capabilities.py | 73 ++++++++++++++ 5 files changed, 189 insertions(+), 4 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 0292020..8056a9f 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -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'. diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index f5e2f16..f6617ca 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -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). 'LevelCtrl_' is the selected dose quantity; +# 'Level2Ctrl_' 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). 'SupportedCtrl_' lists the +# valid raw codes for its field, same hex-pair shape as EditCourseList. +# 'Alarm_' 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')), ), ) diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index d889cc0..e8beedc 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -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" diff --git a/tests/test_operational_capability.py b/tests/test_operational_capability.py index 3cfd259..992467f 100644 --- a/tests/test_operational_capability.py +++ b/tests/test_operational_capability.py @@ -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 diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 2d26883..6c3875d 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -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'