Maps the fridge/dishwasher hrefs flagged by last session's coverage-gap Repairs issue: defrost delay (switch) + block status (diagnostic binary sensor), master ice-maker enable, self-check trigger/status, dishwasher diagnosis trigger/status, and last-operation-source. Adds fallback capabilities for /doors, /temperatures, and /icemaker/status that only bind when the richer per-instance hrefs they duplicate are absent, so simpler devices without those hrefs still get the data. Everything else in the original gap list moves to capabilities.ignored (Bixby audio feedback, inert DR/energy-planner resources, redundant metadata). Fixes discover() reporting a known href as an unregistered gap whenever its match_fn declined to bind for that device (e.g. a filter capability on hardware without that filter) — it's now only a gap when no capability is registered for the href at all. Device naming now uses x.com.samsung.da.modelNum (not the OCF /oic/p,d metadata, which produced inconsistent names like "[dishwasher] Samsung") to build "Samsung <Type> (<model>)".
172 lines
6.8 KiB
Python
172 lines
6.8 KiB
Python
from custom_components.localthings.registry.capability import Capability
|
|
from custom_components.localthings.registry.discovery import (
|
|
BoundEntity, discover, instance_suffix,
|
|
)
|
|
from custom_components.localthings.registry.entities import BinarySensorDesc
|
|
|
|
LOCK = Capability(
|
|
href='/kidslock/vs/0',
|
|
entities=(BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock'),),
|
|
)
|
|
REG = {LOCK.href: [LOCK]}
|
|
|
|
|
|
def test_instance_suffix():
|
|
assert instance_suffix('/kidslock/vs/0') == ''
|
|
assert instance_suffix('/door/vs/1') == '_1'
|
|
assert instance_suffix('/icemaker/vs/2') == '_2'
|
|
|
|
|
|
def test_discover_binds_present_capability():
|
|
resources = {'/kidslock/vs/0': {'x.com.samsung.da.kidsLock': 'On'}}
|
|
bound = discover(resources, REG)
|
|
assert len(bound) == 1
|
|
assert bound[0].href == '/kidslock/vs/0'
|
|
assert bound[0].desc.key == 'child_lock'
|
|
assert bound[0].instance == ''
|
|
|
|
|
|
def test_discover_skips_unknown_href():
|
|
seen = []
|
|
resources = {'/mystery/vs/0': {'x.com.samsung.da.mystery': 'x'}}
|
|
bound = discover(resources, REG, log=seen.append)
|
|
assert bound == []
|
|
assert any('mystery' in m for m in seen)
|
|
|
|
|
|
def test_discover_multi_instance_suffixes():
|
|
cap = Capability(href='/door/vs/0',
|
|
entities=(BinarySensorDesc(key='door', field='x.com.samsung.da.doorState'),))
|
|
resources = {
|
|
'/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]}
|
|
bound = discover(resources, reg)
|
|
insts = sorted(b.instance for b in bound)
|
|
assert insts == ['', '_1']
|
|
|
|
|
|
def test_discover_logs_unregistered_href():
|
|
seen = []
|
|
cap = Capability(href='/kidslock/vs/0',
|
|
entities=(BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock'),))
|
|
resources = {
|
|
'/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)
|
|
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',
|
|
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_match_fn_decline_is_not_logged_as_gap():
|
|
"""A registered href whose match_fn declines is known, not a gap.
|
|
|
|
Distinguishes "no capability registered for this href" (a real
|
|
coverage gap) from "a capability is registered but chose not to bind
|
|
for this device" (e.g. a filter capability on hardware that doesn't
|
|
have that filter) — only the former should be reported via `log`.
|
|
"""
|
|
seen = []
|
|
cap = Capability(
|
|
href='/filter/vs/0',
|
|
match_fn=lambda rep, resources: False,
|
|
entities=(BinarySensorDesc(key='filter', field='x.com.samsung.da.filterStatus'),),
|
|
)
|
|
resources = {'/filter/vs/0': {'x.com.samsung.da.filterStatus': 'notused'}}
|
|
bound = discover(resources, {'/filter/vs/0': [cap]}, log=seen.append)
|
|
assert bound == []
|
|
assert seen == []
|
|
|
|
|
|
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 == []
|