feat: update discover() for pattern caps, match_fn, rt_filter gating

Registry changes from dict[str,Capability] to dict[str,list[Capability]].
discover() gains pattern_caps parameter (fallback for unmatched hrefs)
and gates each candidate on rt_filter and match_fn before binding.
Duplicate hrefs in registry allowed only if all caps set a discriminator.
This commit is contained in:
Marc Billow committed 2026-06-30 16:32:42 -05:00
1 parent 211fcd1324
commit b10cfad194
7 files changed
+176 -32

No files matched your search

+2 -2
View File
@@ -6,12 +6,12 @@ from samsung_appliance.registry.discovery import discover
def _bound(resources):
reg = {c.href: c for c in (common.KIDS_LOCK, common.ENERGY_METER)}
reg = {c.href: [c] for c in (common.KIDS_LOCK, common.ENERGY_METER)}
return discover(resources, reg)
def _bound_with_power(resources):
reg = {c.href: c for c in (common.KIDS_LOCK, common.ENERGY_METER, common.POWER)}
reg = {c.href: [c] for c in (common.KIDS_LOCK, common.ENERGY_METER, common.POWER)}
return discover(resources, reg)
+1 -1
View File
@@ -3,7 +3,7 @@ from samsung_appliance.registry.discovery import discover
def _reg():
return {c.href: c for c in (
return {c.href: [c] for c in (
common.KIDS_LOCK, common.REMOTE_CONTROL, common.POWER,
common.ALARMS, common.ENERGY_METER, common.WATER_METER,
common.WATER_FILTER,
+95 -3
View File
@@ -8,7 +8,7 @@ LOCK = Capability(
href='/kidslock/vs/0',
entities=(BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock'),),
)
REG = {LOCK.href: LOCK}
REG = {LOCK.href: [LOCK]}
def test_instance_suffix():
@@ -41,7 +41,7 @@ def test_discover_multi_instance_suffixes():
'/door/vs/0': {'x.com.samsung.da.doorState': 'Open'},
'/door/vs/1': {'x.com.samsung.da.doorState': 'Closed'},
}
reg = {'/door/vs/0': cap, '/door/vs/1': cap}
reg = {'/door/vs/0': [cap], '/door/vs/1': [cap]}
bound = discover(resources, reg)
insts = sorted(b.instance for b in bound)
assert insts == ['', '_1']
@@ -55,6 +55,98 @@ def test_discover_logs_unregistered_href():
'/kidslock/vs/0': {'x.com.samsung.da.kidsLock': 'On'},
'/mystery/vs/0': {'x.com.samsung.da.mystery': 'x'},
}
bound = discover(resources, {'/kidslock/vs/0': cap}, log=seen.append)
bound = discover(resources, {'/kidslock/vs/0': [cap]}, log=seen.append)
assert len(bound) == 1
assert any('mystery' in m for m in seen)
# ---------------------------------------------------------------------------
# New tests for Task 2: pattern caps, rt_filter, match_fn
# ---------------------------------------------------------------------------
def test_discover_pattern_cap_binds_unmatched_href():
"""Pattern cap with rt_filter binds unmatched hrefs; exact-href cap does not
steal unmatched hrefs."""
cooler_cap = Capability(
href='/door/cooler/0',
entities=(BinarySensorDesc(key='cooler_door', field='x.com.samsung.da.doorState'),),
)
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 = {
'/door/cooler/0': {'x.com.samsung.da.doorState': 'Closed', 'rt': ['oic.r.door']},
'/door/wine/0': {'x.com.samsung.da.doorState': 'Open', 'rt': ['oic.r.door']},
}
reg = {'/door/cooler/0': [cooler_cap]}
bound = discover(resources, reg, pattern_caps=[door_pattern])
hrefs = [b.href for b in bound]
# exact cap claims /door/cooler/0
assert '/door/cooler/0' in hrefs
# pattern cap claims /door/wine/0 (unmatched by registry)
assert '/door/wine/0' in hrefs
# /door/cooler/0 is not also claimed by the pattern cap
cooler_bindings = [b for b in bound if b.href == '/door/cooler/0']
assert all(b.capability is cooler_cap for b in cooler_bindings)
def test_discover_pattern_cap_skips_already_bound_href():
"""Pattern cap must not bind an href already claimed by an exact-href cap."""
exact_cap = Capability(
href='/door/cooler/0',
entities=(BinarySensorDesc(key='cooler_door', field='x.com.samsung.da.doorState'),),
)
pattern = Capability(
href=None,
rt_filter='oic.r.door',
entities=(BinarySensorDesc(key='generic_door', field='x.com.samsung.da.doorState'),),
)
resources = {'/door/cooler/0': {'x.com.samsung.da.doorState': 'Closed', 'rt': ['oic.r.door']}}
reg = {'/door/cooler/0': [exact_cap]}
bound = discover(resources, reg, pattern_caps=[pattern])
# exactly one binding for /door/cooler/0 — the exact cap, not the pattern
assert len(bound) == 1
assert bound[0].capability is exact_cap
def test_discover_match_fn_filters_wrong_device():
"""A cap with match_fn must not bind when its condition is not met."""
oven_cap = Capability(
href='/oven/vs/0',
match_fn=lambda r, rs: '/oven/vs/0' in rs,
entities=(BinarySensorDesc(key='oven_status', field='x.com.samsung.da.state'),),
)
# resources does NOT include /oven/vs/0 — match_fn should fail
resources = {'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}}
reg = {'/oven/vs/0': [oven_cap]}
# Without /oven/vs/0 present as a key in resources, match_fn should be False
resources_no_oven = {'/other/vs/0': {'x.com.samsung.da.state': 'Ready'}}
reg_no_oven = {}
# Put oven cap on a different href to test match_fn rejection
oven_cap2 = Capability(
href='/other/vs/0',
match_fn=lambda r, rs: '/oven/vs/0' in rs,
entities=(BinarySensorDesc(key='oven_status', field='x.com.samsung.da.state'),),
)
bound = discover(resources_no_oven, {'/other/vs/0': [oven_cap2]})
# match_fn returns False → no bindings
assert bound == []
def test_discover_rt_filter_gates_binding():
"""Cap with rt_filter must not bind a rep whose rt list does not match."""
oven_mode_cap = Capability(
href='/mode/vs/0',
rt_filter='x.com.samsung.da.ovenMode',
entities=(BinarySensorDesc(key='oven_mode', field='x.com.samsung.da.mode'),),
)
# rep has a different rt → should not bind
resources = {'/mode/vs/0': {'rt': ['x.com.samsung.da.mode'], 'x.com.samsung.da.mode': 'Bake'}}
bound = discover(resources, {'/mode/vs/0': [oven_mode_cap]})
assert bound == []
+2 -2
View File
@@ -158,7 +158,7 @@ def test_cook_time_rejects_out_of_range():
def test_adapter_sets_cycle_active_field_for_oven():
"""build_runtime_descriptor must set cycle_active_field when oven keys present."""
reg = {c.href: c for c in (oven.OVEN_OPERATIONAL_STATE, oven.OVEN_SETPOINT)}
reg = {c.href: [c] for c in (oven.OVEN_OPERATIONAL_STATE, oven.OVEN_SETPOINT)}
resources = {
'/operational/state/vs/0': {'x.com.samsung.da.state': 'Run'},
'/temperatures/vs/0': {'x.com.samsung.da.items': [
@@ -177,7 +177,7 @@ def test_adapter_sets_cycle_active_field_for_oven():
def test_adapter_no_cycle_active_without_oven_keys():
"""Non-oven appliances must not get cycle_active_field."""
from samsung_appliance.registry.capabilities import common
reg = {c.href: c for c in (common.KIDS_LOCK,)}
reg = {c.href: [c] for c in (common.KIDS_LOCK,)}
resources = {'/kidslock/vs/0': {'x.com.samsung.da.kidsLock': 'Ready'}}
bound = discover(resources, reg)
rd = build_runtime_descriptor(
+4 -3
View File
@@ -3,9 +3,10 @@ from samsung_appliance.registry.registry import CAPABILITIES
def test_registry_is_keyed_by_href():
"""Registry keys should match capability hrefs."""
for href, cap in CAPABILITIES.items():
assert cap.href == href
"""Registry keys should match capability hrefs for all caps in the group."""
for href, caps in CAPABILITIES.items():
for cap in caps:
assert cap.href == href
def test_registry_has_no_duplicate_href():