Issue #27: the flex-zone/cooler-drawer select vanished after a device stopped including supportedOptions on an update for /mode/vs/0. ObserveManager.apply() handed reps straight to StateCache.apply_rep, which fully replaces the cached rep -- so a partial update (missing a field the select's exists_fn/options_field gate on) silently erased data a fuller update had previously supplied. apply() now merges incoming reps onto whatever's already cached instead. Also give ice-maker entities (and any future pattern-cap instance) a device-given display name instead of the href-derived "Icemaker One"/"Icemaker Two": Capability.name_field lets a pattern capability read and normalize an instance name (e.g. iceMaker.name's "CUBED_ICE") for use as the entity name prefix, independent of the stable key/unique_id.
212 lines
8.2 KiB
Python
212 lines
8.2 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_pattern_cap_reads_name_field():
|
|
"""A pattern cap's name_field normalizes a device-given instance name
|
|
(e.g. an ice maker's "CUBED_ICE") for use in the entity's display name,
|
|
independent of the href-derived key (issue #27)."""
|
|
cap = Capability(
|
|
href=None,
|
|
href_prefix='/icemaker/',
|
|
name_field='x.com.samsung.da.iceMaker.name',
|
|
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
|
|
)
|
|
resources = {
|
|
'/icemaker/one/vs/0': {
|
|
'x.com.samsung.da.iceMaker.state': 'On',
|
|
'x.com.samsung.da.iceMaker.name': 'CUBED_ICE',
|
|
},
|
|
'/icemaker/two/vs/0': {
|
|
'x.com.samsung.da.iceMaker.state': 'On',
|
|
'x.com.samsung.da.iceMaker.name': 'ICE_BITES',
|
|
},
|
|
}
|
|
bound = discover(resources, {}, pattern_caps=[cap])
|
|
names = {b.href: b.instance_name for b in bound}
|
|
assert names == {
|
|
'/icemaker/one/vs/0': 'Cubed Ice',
|
|
'/icemaker/two/vs/0': 'Ice Bites',
|
|
}
|
|
|
|
|
|
def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none():
|
|
cap = Capability(
|
|
href=None,
|
|
href_prefix='/icemaker/',
|
|
name_field='x.com.samsung.da.iceMaker.name',
|
|
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
|
|
)
|
|
resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}}
|
|
bound = discover(resources, {}, pattern_caps=[cap])
|
|
assert bound[0].instance_name is None
|
|
|
|
|
|
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 == []
|