diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9f98099eb2cc..2dc16862fb9d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1276,17 +1276,24 @@ class DeviceAreaSuggestion(NamedTuple): area_id: str | None = None -def _match_area(name: str, area: AreaEntry) -> tuple[str, int] | None: +class _AreaMatch(NamedTuple): + """A device name with an area label stripped, and the length of that label.""" + + name: str + label_length: int + + +def _match_area(name: str, area: AreaEntry) -> _AreaMatch | None: """Return the stripped name and length of the area's longest matching label.""" - best: tuple[str, int] | None = None + best: _AreaMatch | None = None for label in (area.name, *area.aliases): if not label: continue stripped = strip_boundary_label(name, label) if stripped is None: continue - if best is None or len(label) > best[1]: - best = (stripped, len(label)) + if best is None or len(label) > best.label_length: + best = _AreaMatch(stripped, len(label)) return best @@ -1298,26 +1305,33 @@ def suggest_device_area( """Suggest a cleaned device name and area from a known area label. An already assigned area is never overridden, only stripped from the name. - Returns None when nothing should change. + Labels are not unique across areas, so an equally long match from another + area is ambiguous. Returns None when nothing should change. """ if not (name := (name or "").strip()): return None if current_area_id: area = next((area for area in areas if area.id == current_area_id), None) - if area and (match := _match_area(name, area)) and match[0]: - return DeviceAreaSuggestion(name=match[0]) + if area and (match := _match_area(name, area)) and match.name: + return DeviceAreaSuggestion(name=match.name) return None - best: tuple[str, str] | None = None - best_length = 0 + best: _AreaMatch | None = None + best_area_id: str | None = None + is_ambiguous = False for area in areas: - if (match := _match_area(name, area)) and match[1] > best_length: - best = (area.id, match[0]) - best_length = match[1] - if best and best[1]: - return DeviceAreaSuggestion(name=best[1], area_id=best[0]) - return None + if not (match := _match_area(name, area)): + continue + if best is None or match.label_length > best.label_length: + best = match + best_area_id = area.id + is_ambiguous = False + elif match.label_length == best.label_length: + is_ambiguous = True + if is_ambiguous or best is None or not best.name: + return None + return DeviceAreaSuggestion(name=best.name, area_id=best_area_id) class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index da399d83a924..9c9f88c3e96c 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -8111,6 +8111,27 @@ async def test_dict_repr_dual_writes_deprecated_keys( None, id="name-equals-area-no-fallback", ), + pytest.param( + [("Hall", ["Downstairs"]), ("Cellar", ["Downstairs"])], + "Downstairs Light", + None, + None, + id="ambiguous-shared-alias", + ), + pytest.param( + [("Kitchen", []), ("Cookhouse", ["Kitchen"])], + "Kitchen Sensor", + None, + None, + id="ambiguous-alias-matches-other-area-name", + ), + pytest.param( + [("Master", []), ("Spare", ["Master"]), ("Master Bedroom", [])], + "Master Bedroom Lamp", + None, + dr.DeviceAreaSuggestion("Lamp", "master_bedroom"), + id="longest-label-beats-shorter-ambiguity", + ), pytest.param( [("Kitchen", [])], "Kitchenette Sensor",