refactor: auto-derive pattern cap keys from href; move dumps to tests/fixtures

Remove key_fn from Capability — pattern cap keys are now always derived
automatically: strip digits from href segments, join with '_', append
'_' + desc.key. /door/cooler/0 + 'open' -> door_cooler_open.

DOOR_GENERIC entity key: 'open' (not 'door_open'). Remove _door_key helper.
Fridge registry: DOOR_FRIDGE/DOOR_FREEZER replaced by DOOR_GENERIC pattern cap.
Golden baseline: door_fridge_open -> door_cooler_open.

Move device dumps to tests/fixtures/dumps/ (tracked) so tests run in CI
without local-tools/ dependencies.
This commit is contained in:
Marc Billow committed 2026-06-30 16:33:36 -05:00
1 parent 1c4cb2e272
commit 27d6d09fd9
11 files changed
+37 -51

No files matched your search

@@ -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,
@@ -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'),
),
-1
View File
@@ -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
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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]:
+2 -2
View File
@@ -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",
+13 -19
View File
@@ -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}"
-1
View File
@@ -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 = {
+2 -3
View File
@@ -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',
+1 -1
View File
@@ -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)
+13 -11
View File
@@ -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