diff --git a/samsung_appliance/registry/by_type/refrigerator.py b/samsung_appliance/registry/by_type/refrigerator.py index fd9579d..45d7323 100644 --- a/samsung_appliance/registry/by_type/refrigerator.py +++ b/samsung_appliance/registry/by_type/refrigerator.py @@ -15,8 +15,6 @@ REGISTRY = DeviceRegistry( fridge.TEMP_FREEZER_CURRENT, fridge.TEMP_FRIDGE_SETPOINT, fridge.TEMP_FREEZER_SETPOINT, - fridge.DOOR_FRIDGE, - fridge.DOOR_FREEZER, fridge.DOORS_STATUS, fridge.ICEMAKER_ONE, fridge.ICEMAKER_TWO, diff --git a/samsung_appliance/registry/capabilities/fridge.py b/samsung_appliance/registry/capabilities/fridge.py index 5a6c3ed..939816e 100644 --- a/samsung_appliance/registry/capabilities/fridge.py +++ b/samsung_appliance/registry/capabilities/fridge.py @@ -345,19 +345,12 @@ FIRMWARE_UPDATE = Capability( # Generic door pattern capability (href=None — use as pattern_cap only) # --------------------------------------------------------------------------- -def _door_key(href: str) -> str: - # /door/cooler/0 -> door_cooler_open ; /door/wine/0 -> door_wine_open - segs = [s for s in href.strip('/').split('/') if s and not s.isdigit()] - return '_'.join(segs) + '_open' - - DOOR_GENERIC = Capability( href=None, href_prefix='/door/', poll_tier='hot', - key_fn=_door_key, entities=( - BinarySensorDesc(key='door_open', field='openState', + BinarySensorDesc(key='open', field='openState', name=None, device_class='door', value_fn=lambda v: v == 'Open'), ), diff --git a/samsung_appliance/registry/capability.py b/samsung_appliance/registry/capability.py index 5247c71..346a9ed 100644 --- a/samsung_appliance/registry/capability.py +++ b/samsung_appliance/registry/capability.py @@ -15,7 +15,6 @@ class Capability: rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ()) href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool - key_fn: Optional[Callable[[str], str]] = None # href -> entity key (pattern caps) # Rare optional hooks — only operational-state-style resources use these. active_when: Optional[Callable[[dict], bool]] = None on_observation: Optional[Callable[[dict, dict], None]] = None diff --git a/samsung_appliance/registry/discovery.py b/samsung_appliance/registry/discovery.py index 73006c4..8f1e84c 100644 --- a/samsung_appliance/registry/discovery.py +++ b/samsung_appliance/registry/discovery.py @@ -68,10 +68,12 @@ def discover( if cap.match_fn is not None and not cap.match_fn(rep, resources): continue inst = instance_suffix(href) - key = cap.key_fn(href) if cap.key_fn else None + # Auto-derive key prefix from href: strip digits, join non-empty segs with '_' + segs = [s for s in href.strip('/').split('/') if s and not s.isdigit()] for desc in cap.entities: + key_override = '_'.join(segs) + '_' + desc.key out.append(BoundEntity(href=href, capability=cap, desc=desc, - instance=inst, key_override=key)) + instance=inst, key_override=key_override)) matched = True break diff --git a/tests/conftest.py b/tests/conftest.py index 50a47b1..0d8e31d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -DUMPS = Path(__file__).resolve().parent.parent / 'local-tools' / 'dumps' +DUMPS = Path(__file__).resolve().parent / 'fixtures' / 'dumps' def _resources_from_dump(dump: dict) -> dict[str, dict]: diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index d7ac79e..da27fb0 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -7,8 +7,8 @@ "cabinet_light", "cabinet_light_on", "cabinet_light_switch", + "door_cooler_open", "door_freezer_open", - "door_fridge_open", "energy_kwh", "filter_status", "filter_usage", @@ -37,8 +37,8 @@ "samsung_refrigerator_cabinet_light", "samsung_refrigerator_cabinet_light_on", "samsung_refrigerator_cabinet_light_switch", + "samsung_refrigerator_door_cooler_open", "samsung_refrigerator_door_freezer_open", - "samsung_refrigerator_door_fridge_open", "samsung_refrigerator_energy_kwh", "samsung_refrigerator_filter_status", "samsung_refrigerator_filter_usage", diff --git a/tests/test_by_type_capabilities.py b/tests/test_by_type_capabilities.py index 8dc5614..1d9ad29 100644 --- a/tests/test_by_type_capabilities.py +++ b/tests/test_by_type_capabilities.py @@ -8,14 +8,9 @@ from samsung_appliance.registry.adapter import _key from samsung_appliance.registry.by_type import dishwasher, refrigerator from samsung_appliance.registry.capabilities import fridge from samsung_appliance.registry.discovery import discover +from tests.conftest import _load_resources GOLDEN = Path(__file__).parent / 'fixtures' / 'golden' -DUMPS = Path(__file__).resolve().parent.parent / 'local-tools' / 'dumps' - - -def _load_resources(ip: str) -> dict[str, dict]: - data = json.loads((DUMPS / f'{ip}.json').read_text()) - return {k: v for k, v in data['resources'].items() if isinstance(v, dict)} # --------------------------------------------------------------------------- @@ -62,6 +57,7 @@ def test_door_generic_binds_unknown_door_href(): bound = discover(resources, {}, [fridge.DOOR_GENERIC]) assert len(bound) == 1 b = bound[0] + # entity key 'open' + href segs ['door','wine'] -> 'door_wine_open' assert _key(b) == 'door_wine_open', f"Expected 'door_wine_open', got {_key(b)!r}" @@ -76,21 +72,19 @@ def test_door_generic_does_not_bind_doors_aggregate(): assert bound == [], f"Expected no bindings, got {bound}" -def test_door_generic_does_not_bind_known_door(): - """/door/cooler/0 is claimed by DOOR_FRIDGE exact cap; DOOR_GENERIC must not also bind it.""" +def test_door_generic_binds_cooler_door_with_auto_derived_key(): + """/door/cooler/0 is now handled by DOOR_GENERIC pattern cap with auto-derived key door_cooler_open.""" resources = { '/door/cooler/0': {'openState': 'Closed'}, } reg = refrigerator.REGISTRY - bound = discover(resources, reg.capabilities, [fridge.DOOR_GENERIC]) - # Should be bound only by DOOR_FRIDGE (exact cap), not DOOR_GENERIC + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) cooler_bindings = [b for b in bound if b.href == '/door/cooler/0'] - assert len(cooler_bindings) > 0, "Expected at least one binding for /door/cooler/0" - for b in cooler_bindings: - assert b.capability is fridge.DOOR_FRIDGE, ( - f"Expected capability DOOR_FRIDGE, got {b.capability!r}" - ) - # key_override should be None when bound via exact cap - assert b.key_override is None, ( - f"Expected no key_override (exact cap binding), got {b.key_override!r}" - ) + assert len(cooler_bindings) == 1, ( + f"Expected exactly one binding for /door/cooler/0, got {len(cooler_bindings)}" + ) + b = cooler_bindings[0] + assert b.capability is fridge.DOOR_GENERIC, ( + f"Expected DOOR_GENERIC, got {b.capability!r}" + ) + assert _key(b) == 'door_cooler_open', f"Expected 'door_cooler_open', got {_key(b)!r}" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 3671c75..cfbf0c7 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -74,7 +74,6 @@ def test_discover_pattern_cap_binds_unmatched_href(): door_pattern = Capability( href=None, rt_filter='oic.r.door', - key_fn=lambda href: href.split('/')[-2], entities=(BinarySensorDesc(key='door', field='x.com.samsung.da.doorState'),), ) resources = { diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 19a5bf1..49fce9b 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -1,7 +1,6 @@ -import json - import pytest +from tests.conftest import _load_resources from samsung_appliance.registry import (CAPABILITIES, discover, build_runtime_descriptor) @@ -11,7 +10,7 @@ from samsung_appliance.registry import (CAPABILITIES, discover, ('refrigerator', '10.0.0.254'), ]) def test_full_pipeline_produces_entities_and_intervals(name, ip): - resources = json.load(open(f'local-tools/dumps/{ip}.json'))['resources'] + resources = _load_resources(ip) bound = discover(resources, CAPABILITIES) rd = build_runtime_descriptor( bound, topic_prefix=f'samsung_{name}', ha_prefix='homeassistant', diff --git a/tests/test_end_to_end_v2.py b/tests/test_end_to_end_v2.py index 56a4fad..5ad7b9d 100644 --- a/tests/test_end_to_end_v2.py +++ b/tests/test_end_to_end_v2.py @@ -11,7 +11,7 @@ from samsung_appliance.registry.adapter import build_runtime_descriptor ('refrigerator', '10.0.0.254', 'refrigerator'), ]) def test_full_pipeline_v2(name, ip, expected_type_key): - with open(f'local-tools/dumps/{ip}.json') as f: + with open(f'tests/fixtures/dumps/{ip}.json') as f: dump = json.load(f) resources = _resources_from_dump(dump) diff --git a/tests/test_fridge_multi_instance.py b/tests/test_fridge_multi_instance.py index a238b3e..58cdc78 100644 --- a/tests/test_fridge_multi_instance.py +++ b/tests/test_fridge_multi_instance.py @@ -2,27 +2,29 @@ The two physical door resources (/door/cooler/0, /door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0, /icemaker/two/vs/0) use named path -segments rather than numeric indices, so they are modelled as separate -Capability objects with distinct entity keys. +segments rather than numeric indices, so they are modelled via DOOR_GENERIC +pattern capability that auto-derives distinct keys from the href segments. """ -import json +from tests.conftest import _load_resources -from samsung_appliance.registry.registry import CAPABILITIES +from samsung_appliance.registry.adapter import _key +from samsung_appliance.registry.by_type import refrigerator from samsung_appliance.registry.discovery import discover def test_two_doors_get_distinct_keys(): - res = json.load(open('local-tools/dumps/10.0.0.254.json'))['resources'] - bound = discover(res, CAPABILITIES) - door_keys = sorted(f"{b.desc.key}{b.instance}" for b in bound - if b.desc.key.startswith('door')) - # at least the two physical doors produce distinct keys + resources = _load_resources('10.0.0.254') + reg = refrigerator.REGISTRY + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + door_keys = sorted(_key(b) for b in bound if _key(b).startswith('door')) + # at least the two physical doors (cooler + freezer) produce distinct keys assert len(set(door_keys)) >= 2, f"Expected >= 2 distinct door keys, got: {door_keys}" def test_two_iceMakers_get_distinct_keys(): - res = json.load(open('local-tools/dumps/10.0.0.254.json'))['resources'] - bound = discover(res, CAPABILITIES) + resources = _load_resources('10.0.0.254') + reg = refrigerator.REGISTRY + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) ice_keys = sorted(f"{b.desc.key}{b.instance}" for b in bound if b.desc.key.startswith('ice')) # ice1_* and ice2_* are distinct