From 2a6bbc9dfbe12a4b0efc22446b3ad7a72a449be7 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 18 Jul 2026 18:15:41 +0000 Subject: [PATCH 1/2] 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' From 35f76ce2dffa5c850fcf3a666522d51f54a8b0e2 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 18 Jul 2026 18:24:10 +0000 Subject: [PATCH 2/2] feat(washer): add label translations for detergent/softener dosing selects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assumes the LevelCtrl code scheme is None/Low/Medium/High (00-03) on both dispensers -- code 00 has no on-screen equivalent in the app's 3-choice Faible/Moyen/Élevé picker, assumed to be what "Activation" off collapses to -- and Level2Ctrl is Soft/Medium/Hard for detergent water hardness, 1x/2x/3x for softener concentration. detergent_quantity and softener_quantity share one translation_key (same vocabulary), same pattern as fridge.py's shared 'brightness_level' key. Not cross-device verified: only one dump + screenshot set (issue #9) to go on, and the softener concentration reading doesn't cleanly match its screenshot (assumed to be a setting changed between dump and screenshots, not a different code scheme -- see the comment in washer.py). --- .../registry/capabilities/washer.py | 25 +++++++++++++++---- custom_components/localthings/strings.json | 22 ++++++++++++++++ .../localthings/translations/en.json | 22 ++++++++++++++++ tests/test_washer_capabilities.py | 10 ++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index f6617ca..f7cebab 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -164,11 +164,22 @@ def _drum_clean_last_cleaned(rep): # 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. +# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_ +# water_hardness/washer_softener_concentration in strings.json) is an +# assumed, not cross-device-verified, reading of the single issue #9 dump + +# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no +# on-screen equivalent -- the app's Quantité picker only offers +# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what +# "Activation" off collapses to) matches DetergentLevelCtrl_3/ +# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as +# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure) +# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as +# 1x/2x/3x for softener concentration does *not* cleanly match +# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a +# setting the user changed in the app between the dump (issue body) and the +# screenshots (a later comment), not a different code scheme, since it's +# otherwise identical in shape to the detergent side. Revisit if a second +# device's dump contradicts this. 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}') @@ -221,6 +232,7 @@ WASHER_COURSE = Capability( 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', + translation_key='washer_dosing_quantity', entity_category='config', options=_level_options('DetergentLevelCtrl'), exists_fn=lambda rep, resources: bool( @@ -230,6 +242,7 @@ WASHER_COURSE = Capability( write_fn=_level_write('DetergentLevelCtrl')), SelectDesc(key='detergent_water_hardness', name='Detergent water hardness', icon='mdi:water-opacity', + translation_key='washer_detergent_water_hardness', entity_category='config', options=_level_options('DetergentLevel2Ctrl'), exists_fn=lambda rep, resources: bool( @@ -238,6 +251,7 @@ WASHER_COURSE = Capability( rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'), write_fn=_level_write('DetergentLevel2Ctrl')), SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline', + translation_key='washer_dosing_quantity', entity_category='config', options=_level_options('SoftenerLevelCtrl'), exists_fn=lambda rep, resources: bool( @@ -247,6 +261,7 @@ WASHER_COURSE = Capability( write_fn=_level_write('SoftenerLevelCtrl')), SelectDesc(key='softener_concentration', name='Softener concentration', icon='mdi:flask-plus-outline', + translation_key='washer_softener_concentration', entity_category='config', options=_level_options('SoftenerLevel2Ctrl'), exists_fn=lambda rep, resources: bool( diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 4a41e15..98cd5ff 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -78,6 +78,28 @@ "8f": "Intense Cold", "96": "Less Microfiber" } + }, + "washer_dosing_quantity": { + "state": { + "00": "None", + "01": "Low", + "02": "Medium", + "03": "High" + } + }, + "washer_detergent_water_hardness": { + "state": { + "01": "Soft", + "02": "Medium", + "03": "Hard" + } + }, + "washer_softener_concentration": { + "state": { + "01": "1x", + "02": "2x", + "03": "3x" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4a41e15..98cd5ff 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -78,6 +78,28 @@ "8f": "Intense Cold", "96": "Less Microfiber" } + }, + "washer_dosing_quantity": { + "state": { + "00": "None", + "01": "Low", + "02": "Medium", + "03": "High" + } + }, + "washer_detergent_water_hardness": { + "state": { + "01": "Soft", + "02": "Medium", + "03": "Hard" + } + }, + "washer_softener_concentration": { + "state": { + "01": "1x", + "02": "2x", + "03": "3x" + } } }, "sensor": { diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 6c3875d..378fa1c 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -185,6 +185,16 @@ class TestDetergentSoftenerDosing: assert self._desc('softener_quantity').rep_fn(rep) == '3' assert self._desc('softener_concentration').rep_fn(rep) == '2' + 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']