From d6639c99d4f41ba693f87e07dea2928fb47a2fae Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 22 Jul 2026 19:14:52 +0000 Subject: [PATCH] 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. --- .../registry/by_type/refrigerator.py | 2 +- .../localthings/registry/by_type/washer.py | 1 + .../registry/capabilities/common.py | 62 +++++++++++- .../registry/capabilities/fridge.py | 27 ------ custom_components/localthings/strings.json | 6 -- .../localthings/translations/en.json | 6 -- .../golden/refrigerator_tp1x_ref_21k_us.json | 1 + tests/fixtures/golden/washer.json | 1 + tests/fixtures/washer_device.json | 15 +++ tests/test_common_capabilities.py | 97 +++++++++++++++++++ tests/test_fridge_capabilities.py | 47 ++------- tests/test_washer_capabilities.py | 27 ++++++ 12 files changed, 212 insertions(+), 80 deletions(-) diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 02e7e44..ecd63f6 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -20,7 +20,7 @@ REGISTRY = DeviceRegistry( dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, - fridge.AI_ENERGY_LEVEL, + common.AI_ENERGY_LEVEL, fridge.REFRIGERATION, fridge.AUTOFILL, fridge.WELCOME_LIGHTING, diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index 2b84126..ac46cf4 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -19,6 +19,7 @@ REGISTRY = DeviceRegistry( common.ALARMS, common.ENERGY_METER, common.WATER_METER, + common.AI_ENERGY_LEVEL, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, fridge.FIRMWARE_UPDATE, diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index edc3f6c..a7660d2 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -11,7 +11,7 @@ against live device dumps: /filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus """ from ..capability import Capability -from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc def _num(v): @@ -231,3 +231,63 @@ WATER_FILTER = Capability( name='Filter status', icon='mdi:filter-check'), ), ) + +# AI energy-saving level -- '0' is off, and supportedAiLevel lists the +# additional level(s) the device offers ('1' meaning just "on" on most +# hardware, but multi-level boards have been reported). Verified cross-family: +# fridge (issue #21) and washer (issue #40) both expose this href. +# +# supportedAiLevel is a single-entry list on most captured hardware, where a +# select would offer only one real choice against an implicit "off" -- shown +# as a switch instead. '0' itself is never in supportedAiLevel but has been +# observed live as the off value of aiLevel, so the select synthesizes it +# back in as an explicit option rather than leaving no way to turn off. +# +# No translation_key: aiLevel's values are plain digit strings, and +# select.py's _display() already renders an untranslated numeric string +# as-is -- there's nothing a strings.json entry adds that's worth maintaining +# against an unknown, growing number of future levels. + + +def _ai_energy_supported_levels(rep): + """supportedAiLevel as a list -- a stray scalar (e.g. a string) must not + be len()-checked as if it were a list.""" + sl = rep.get('supportedAiLevel') + return list(sl) if isinstance(sl, (list, tuple)) else [] + + +def _ai_energy_level_options(resources): + rep = resources.get('/energy/ailevel/vs/0') or {} + return ['0', *_ai_energy_supported_levels(rep)] + + +def _ai_energy_level_write(p, rep, href=None): + return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p} + + +def _ai_energy_level_switch_write(p, rep, href=None): + levels = _ai_energy_supported_levels(rep) + on_level = levels[0] if levels else '1' + return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'} + + +AI_ENERGY_LEVEL = Capability( + href='/energy/ailevel/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='ai_energy_level', field='aiLevel', + name='AI energy level', icon='mdi:leaf', + entity_category='config', + value_fn=lambda v: v != '0', + exists_fn=lambda rep, resources: ( + len(_ai_energy_supported_levels(rep)) == 1), + write_fn=_ai_energy_level_switch_write), + SelectDesc(key='ai_energy_level', field='aiLevel', + name='AI energy level', icon='mdi:leaf', + entity_category='config', + options=_ai_energy_level_options, + exists_fn=lambda rep, resources: ( + not rep or len(_ai_energy_supported_levels(rep)) > 1), + write_fn=_ai_energy_level_write), + ), +) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 0a44bea..5529526 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -588,33 +588,6 @@ FLEX_ZONE = Capability( ), ) -# --------------------------------------------------------------------------- -# AI energy-saving level -# -# supportedAiLevel is a single-entry list ('1' only) on some hardware, where -# a select would offer no real choice — gated to >1 supported level. -# --------------------------------------------------------------------------- - -AI_ENERGY_LEVEL = Capability( - href='/energy/ailevel/vs/0', - poll_tier='cold', - entities=( - # Only levels '1'/'2' have translated labels in strings.json/translations; - # a higher supportedAiLevel entry renders as its raw value until added. - SelectDesc(key='ai_energy_level', field='aiLevel', - name='AI energy level', icon='mdi:leaf', - translation_key='ai_energy_level', - entity_category='config', - options_field='supportedAiLevel', - exists_fn=lambda rep, resources: ( - not rep or ( - isinstance((sl := rep.get('supportedAiLevel')), (list, tuple)) - and len(sl) > 1)), - write_fn=lambda p, rep, href=None: ( - ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p})), - ), -) - # --------------------------------------------------------------------------- # Firmware update # --------------------------------------------------------------------------- diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 4bba100..3347a5c 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -148,12 +148,6 @@ "180": "3 hr", "240": "4 hr" } - }, - "ai_energy_level": { - "state": { - "1": "Level 1", - "2": "Level 2" - } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4bba100..3347a5c 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -148,12 +148,6 @@ "180": "3 hr", "240": "4 hr" } - }, - "ai_energy_level": { - "state": { - "1": "Level 1", - "2": "Level 2" - } } }, "sensor": { diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json index ed004a4..63b71e8 100644 --- a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json @@ -1,5 +1,6 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", "autofill", "cooler_setpoint", diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index d3a99a1..b48cd1b 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -1,5 +1,6 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", "bubble_soak", "buzzer_sound", diff --git a/tests/fixtures/washer_device.json b/tests/fixtures/washer_device.json index 2f2eece..b76d18f 100644 --- a/tests/fixtures/washer_device.json +++ b/tests/fixtures/washer_device.json @@ -44,6 +44,21 @@ "href": "/energy/consumption/0", "rep": {} }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "0", + "supportedAiLevel": [ + "1" + ], + "energySavingInfo": [ + { + "aiLevel": "1", + "maxEnergySavingRate": "70" + } + ] + } + }, { "href": "/course/vs/0", "rep": { diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index f535d54..adc1431 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -130,3 +130,100 @@ class TestEnergyMeter: phantom power sensor (the exists_fn replaces the field-presence gate).""" pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False + + +# --------------------------------------------------------------------------- +# AI energy-saving level. '0' is off; supportedAiLevel lists the additional +# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer) +# is really a binary toggle, so it's exposed as a switch instead of a +# one-choice select; multiple entries get a select with '0' synthesized back +# in as the explicit off option (supportedAiLevel never lists '0' itself, but +# it's a real, observed value of aiLevel). +# --------------------------------------------------------------------------- + + +class TestAiEnergyLevelSwitch: + def _desc(self): + return next(e for e in common.AI_ENERGY_LEVEL.entities + if e.__class__.__name__ == 'SwitchDesc') + + def test_href(self): + assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' + + def test_shown_only_with_single_supported_level(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False + + def test_hidden_when_supported_level_is_non_list_scalar(self): + """A stray scalar (e.g. a string) must not be len()-checked as if it + were a list -- a 2-char string would otherwise wrongly pass `== 1` + style checks.""" + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False + + def test_hidden_on_empty_stub_rep(self): + """Unlike the select, the switch requires a confirmed single-entry + list -- an unfetched stub defaults to the select branch instead (see + TestAiEnergyLevelSelect.test_shown_for_empty_stub_rep).""" + desc = self._desc() + assert desc.exists_fn({}, {}) is False + + def test_value_fn(self): + desc = self._desc() + assert desc.value_fn('0') is False + assert desc.value_fn('1') is True + + def test_write_on_uses_the_single_supported_level(self): + """The on-value is whatever the device calls its one level, not a + hardcoded '1'.""" + desc = self._desc() + path, body = desc.write_fn('On', {'supportedAiLevel': ['2']}) + assert path == ['energy', 'ailevel', 'vs', '0'] + assert body == {'aiLevel': '2'} + + def test_write_off(self): + desc = self._desc() + path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']}) + assert body == {'aiLevel': '0'} + + +class TestAiEnergyLevelSelect: + def _desc(self): + return next(e for e in common.AI_ENERGY_LEVEL.entities + if e.__class__.__name__ == 'SelectDesc') + + def test_shown_only_with_multiple_supported_levels(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False + + def test_shown_for_empty_stub_rep(self): + """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- + defaults to the select so sub-polls can populate it either way.""" + desc = self._desc() + assert desc.exists_fn({}, {}) is True + + def test_no_translation_key(self): + """aiLevel's values are plain digits that render fine untranslated + (select.py's _display()) -- no strings.json entry to maintain + against an unknown number of future levels.""" + desc = self._desc() + assert desc.translation_key is None + + def test_options_synthesize_off(self): + """'0' is never in supportedAiLevel but is a real, observed aiLevel + value -- synthesized back in as the explicit off option.""" + desc = self._desc() + resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}} + assert desc.options(resources) == ['0', '1', '2'] + + def test_options_empty_when_resource_missing(self): + desc = self._desc() + assert desc.options({}) == ['0'] + + def test_write(self): + desc = self._desc() + path, body = desc.write_fn('2', {}) + assert path == ['energy', 'ailevel', 'vs', '0'] + assert body == {'aiLevel': '2'} diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index fa0f470..c76ddfe 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -267,48 +267,17 @@ class TestSelfCheckError: assert desc.value_fn([]) is None -class TestAiEnergyLevel: - """AI energy-saving level select on /energy/ailevel/vs/0 -- only - exposed when the device actually offers more than one level; hardware - that only ever reports a single supported level keeps this ignored - (see capabilities/ignored.py).""" +class TestRefrigeratorAiEnergyLevelFixtureCoverage: + """AI energy-saving level (common.AI_ENERGY_LEVEL, see + test_common_capabilities.py) is exercised end-to-end here against the + refrigerator fixture/registry. - def test_href(self): - assert fridge.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' - - def test_hidden_with_single_supported_level(self): - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False - - def test_shown_with_multiple_supported_levels(self): - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn( - {'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True - - def test_exists_for_empty_stub_rep(self): - """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- - must be included-for-now, same as ENERGY_METER's fields.""" - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn({}, {}) is True - - def test_hidden_when_supported_level_is_non_list_scalar(self): - """A stray scalar (e.g. a string) must not be len()-checked as if it - were a list -- a 5-char string would otherwise wrongly pass `> 1`.""" - desc = fridge.AI_ENERGY_LEVEL.entities[0] - assert desc.exists_fn( - {'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False - - def test_write(self): - desc = fridge.AI_ENERGY_LEVEL.entities[0] - path, body = desc.write_fn('2', {}) - assert path == ['energy', 'ailevel', 'vs', '0'] - assert body == {'aiLevel': '2'} + refrigerator_device.json's supportedAiLevel was extended to two entries + (['1', '2']) specifically to exercise the select branch -- the real + captured TP1X_REF_21K_US dump only ever reports one entry (switch + branch), so the select is synthetic-fixture-only for now.""" def test_synthetic_fixture_has_complete_coverage(self): - """refrigerator_device.json's supportedAiLevel was extended to two - entries (['1', '2']) specifically to exercise this select -- the - real captured TP1X_REF_21K_US dump only ever reports one entry, so - this capability is synthetic-fixture-only for now.""" from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import refrigerator from custom_components.localthings.registry.discovery import discover diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 0a1a3b4..a06243f 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -379,6 +379,33 @@ class TestWashOptionToggleValidation: 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