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:
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'),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user