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..f7cebab 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,63 @@ 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. +# +# 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}') + 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 +231,52 @@ 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', + translation_key='washer_dosing_quantity', + 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', + translation_key='washer_detergent_water_hardness', + 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', + translation_key='washer_dosing_quantity', + 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', + translation_key='washer_softener_concentration', + 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/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/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..378fa1c 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -155,6 +155,89 @@ 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_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): + 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'