diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index caddc51..02e7e44 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -20,6 +20,7 @@ REGISTRY = DeviceRegistry( dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, + fridge.AI_ENERGY_LEVEL, fridge.REFRIGERATION, fridge.AUTOFILL, fridge.WELCOME_LIGHTING, diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index a258fa2..0a44bea 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -242,6 +242,14 @@ SELF_CHECK = Capability( SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result', name='Self-check result', icon='mdi:clipboard-check-outline', entity_category='diagnostic'), + # List of error codes from the last self-check; joined for display. + # Not every fridge reports the field, hence the exists_fn. + SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error', + name='Self-check error', icon='mdi:alert-circle-outline', + entity_category='diagnostic', + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.error' in rep), + value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v), ButtonDesc(key='selfcheck_start', field='', name='Start self-check', payload='Start', icon='mdi:play-circle-outline', entity_category='diagnostic', @@ -580,6 +588,33 @@ 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/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index be54b62..b7e3cc1 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -85,14 +85,9 @@ IGNORED: list[Capability] = [ # Warranty/service-plan enrollment status — every field reads "Unknown" # on hardware not enrolled in a Samsung Care+ style program. Capability(href='/dginformation/vs/0'), - # AI energy-saving level; only one value ('1') is ever in - # supportedAiLevel on hardware seen so far, so there's no real choice - # to expose. Revisit if a device surfaces more than one supported level. - Capability(href='/energy/ailevel/vs/0'), # OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF') - # has ever been seen in `modes` (issue #7's dump), same "no real choice - # to expose yet" situation as /energy/ailevel/vs/0 above. Revisit if a - # device surfaces it toggled on. + # has ever been seen in `modes` (issue #7's dump) -- no real choice to + # expose yet. Revisit if a device surfaces it toggled on. Capability(href='/mode/0'), # Opaque integer with no supportedModes/options list to interpret it # against — meaning unclear from the raw resource alone. diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 2b2eca4..b6cb7e6 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -143,6 +143,12 @@ "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 2b2eca4..b6cb7e6 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -143,6 +143,12 @@ "180": "3 hr", "240": "4 hr" } + }, + "ai_energy_level": { + "state": { + "1": "Level 1", + "2": "Level 2" + } } }, "sensor": { diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index 45ca68b..0945c9a 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -1,5 +1,6 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", "auto_door_opener", "autofill", @@ -39,11 +40,13 @@ "rapid_freezing", "rapid_fridge", "sabbath_mode", + "selfcheck_error", "selfcheck_result", "selfcheck_status", "welcome_lighting" ], "discovery_unique_ids": [ + "samsung_refrigerator_ai_energy_level", "samsung_refrigerator_alarm_code", "samsung_refrigerator_auto_door_opener", "samsung_refrigerator_autofill", @@ -83,6 +86,7 @@ "samsung_refrigerator_rapid_freezing", "samsung_refrigerator_rapid_fridge", "samsung_refrigerator_sabbath_mode", + "samsung_refrigerator_selfcheck_error", "samsung_refrigerator_selfcheck_result", "samsung_refrigerator_selfcheck_start", "samsung_refrigerator_selfcheck_status", diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json index dd94692..ed004a4 100644 --- a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json @@ -28,6 +28,7 @@ "rapid_freezing", "rapid_fridge", "sabbath_mode", + "selfcheck_error", "selfcheck_result", "selfcheck_status" ] diff --git a/tests/fixtures/refrigerator_device.json b/tests/fixtures/refrigerator_device.json index abf85c5..ca6d750 100644 --- a/tests/fixtures/refrigerator_device.json +++ b/tests/fixtures/refrigerator_device.json @@ -279,7 +279,8 @@ "rep": { "aiLevel": "1", "supportedAiLevel": [ - "1" + "1", + "2" ], "href": "/energy/ailevel/vs/0" } diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index cc4daea..fa0f470 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -171,6 +171,7 @@ class TestTp1xNativeDuplicateResources: assert 'ice_maker_enabled' not in state assert state['icemaker_one_enabled'] is True assert state['icemaker_two_enabled'] is True + assert state['selfcheck_error'] == 'DA_ERROR_NONE' class TestPantryZone: @@ -228,3 +229,99 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: assert unbound == [] state = flatten(bound, resources) assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' + + +class TestSelfCheckError: + """Self-check diagnostic error list -- surfaced on hardware that reports + x.com.samsung.da.error, joined into a single display string.""" + + def _desc(self): + return next(e for e in fridge.SELF_CHECK.entities if e.key == 'selfcheck_error') + + def test_exists_when_field_present(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True + + def test_does_not_exist_when_field_absent(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False + + 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 = self._desc() + assert desc.exists_fn({}, {}) is True + + def test_value_joins_list(self): + desc = self._desc() + assert desc.value_fn(['E1', 'E2']) == 'E1, E2' + + def test_value_passes_through_scalar(self): + desc = self._desc() + assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE' + + def test_value_none_for_empty_list(self): + """An empty error list means no value to show -- None (unknown), + not an empty string.""" + desc = self._desc() + 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).""" + + 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'} + + 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 + from tests.conftest import _load_device + + resources = _load_device('refrigerator') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['ai_energy_level'] == '1'