Add single target constraint to async_match_targets (#136643)

Add single target constraint
This commit is contained in:
Michael Hansen 2025-01-27 13:06:21 -06:00 committed by GitHub
parent 557b9d88b5
commit 7497beefed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 84 additions and 4 deletions

View File

@ -215,6 +215,9 @@ class MatchFailedReason(Enum):
DUPLICATE_NAME = auto()
"""Two or more entities matched the same name constraint and could not be disambiguated."""
MULTIPLE_TARGETS = auto()
"""Two or more entities matched when a single target is required."""
def is_no_entities_reason(self) -> bool:
"""Return True if the match failed because no entities matched."""
return self not in (
@ -255,6 +258,9 @@ class MatchTargetsConstraints:
allow_duplicate_names: bool = False
"""True if entities with duplicate names are allowed in result."""
single_target: bool = False
"""True if result must contain a single target."""
@property
def has_constraints(self) -> bool:
"""Returns True if at least one constraint is set (ignores assistant)."""
@ -266,6 +272,7 @@ class MatchTargetsConstraints:
or self.device_classes
or self.features
or self.states
or self.single_target
)
@ -291,7 +298,7 @@ class MatchTargetsResult:
"""Reason for failed match when is_match = False."""
states: list[State] = field(default_factory=list)
"""List of matched entity states when is_match = True."""
"""List of matched entity states."""
no_match_name: str | None = None
"""Name of invalid area/floor or duplicate name when match fails for those reasons."""
@ -357,7 +364,6 @@ class MatchTargetsCandidate:
is_exposed: bool
entity: entity_registry.RegistryEntry | None = None
area: area_registry.AreaEntry | None = None
floor: floor_registry.FloorEntry | None = None
device: device_registry.DeviceEntry | None = None
matched_name: str | None = None
@ -549,6 +555,7 @@ def async_match_targets( # noqa: C901
or constraints.device_classes
or constraints.area_name
or constraints.floor_name
or constraints.single_target
):
if constraints.assistant:
# Check exposure
@ -719,6 +726,48 @@ def async_match_targets( # noqa: C901
candidates = final_candidates
if constraints.single_target and len(candidates) > 1:
# Find best match using preferences
if not (preferences.area_id or preferences.floor_id):
# No preferences
return MatchTargetsResult(
False,
MatchFailedReason.MULTIPLE_TARGETS,
states=[c.state for c in candidates],
)
if not areas_added:
ar = area_registry.async_get(hass)
dr = device_registry.async_get(hass)
_add_areas(ar, dr, candidates)
areas_added = True
filtered_candidates: list[MatchTargetsCandidate] = candidates
if preferences.area_id:
# Filter by area
filtered_candidates = [
c for c in candidates if c.area and (c.area.id == preferences.area_id)
]
if (len(filtered_candidates) > 1) and preferences.floor_id:
# Filter by floor
filtered_candidates = [
c
for c in candidates
if c.area and (c.area.floor_id == preferences.floor_id)
]
if len(filtered_candidates) != 1:
# Filtering could not restrict to a single target
return MatchTargetsResult(
False,
MatchFailedReason.MULTIPLE_TARGETS,
states=[c.state for c in candidates],
)
# Filtering succeeded
candidates = filtered_candidates
return MatchTargetsResult(
True,
None,

View File

@ -234,7 +234,7 @@ async def test_async_match_targets(
# Floor 2
floor_2 = floor_registry.async_create("second floor", aliases={"upstairs"})
area_bedroom_2 = area_registry.async_get_or_create("bedroom")
area_bedroom_2 = area_registry.async_get_or_create("second floor bedroom")
area_bedroom_2 = area_registry.async_update(
area_bedroom_2.id, floor_id=floor_2.floor_id
)
@ -269,7 +269,7 @@ async def test_async_match_targets(
# Floor 3
floor_3 = floor_registry.async_create("third floor", aliases={"upstairs"})
area_bedroom_3 = area_registry.async_get_or_create("bedroom")
area_bedroom_3 = area_registry.async_get_or_create("third floor bedroom")
area_bedroom_3 = area_registry.async_update(
area_bedroom_3.id, floor_id=floor_3.floor_id
)
@ -510,6 +510,37 @@ async def test_async_match_targets(
bathroom_light_3.entity_id,
}
# Check single target constraint
result = intent.async_match_targets(
hass,
intent.MatchTargetsConstraints(domains={"light"}, single_target=True),
states=states,
)
assert not result.is_match
assert result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS
# Only one light on the ground floor
result = intent.async_match_targets(
hass,
intent.MatchTargetsConstraints(domains={"light"}, single_target=True),
preferences=intent.MatchTargetsPreferences(floor_id=floor_1.floor_id),
states=states,
)
assert result.is_match
assert len(result.states) == 1
assert result.states[0].entity_id == bathroom_light_1.entity_id
# Only one switch in bedroom
result = intent.async_match_targets(
hass,
intent.MatchTargetsConstraints(domains={"switch"}, single_target=True),
preferences=intent.MatchTargetsPreferences(area_id=area_bedroom_2.id),
states=states,
)
assert result.is_match
assert len(result.states) == 1
assert result.states[0].entity_id == bedroom_switch_2.entity_id
async def test_match_device_area(
hass: HomeAssistant,